Browse Source

Merge pull request #3470 from maziggy/1.3.0

**Bambuddy 1.3.0**

**What this is**

The largest Bambuddy release so far, on top of 1.2.5.7: 40 new features, 11 changes, 320 fixes and three dependency advisories closed. 24 community PRs went in, and 83 people are credited.

The headline is **archives that hold every print of a file**. An archive used to show only its latest print; it now opens with each plate, every print of it, and each print's own photos, timelapse, note and outcome, and the calendar, Print Log and filters work per print. Reprinting a file from the File Manager adds a run to its archive instead of a duplicate. **Groups can be limited to some printers**, and **jobs can wait for staff review** before they print, for shared and school farms. Failure detection gets a second provider, **OctoEverywhere**, next to Obico. Notifications gain **Notify!** with iOS Live Activities, and **Gotify**. The File Manager can move folders, and users who see only their own files now see only their own folders. **Manyfold** joins MakerWorld under **Model Sources**. Printer cameras open from any page. The interface is available in **Polish**, and downloads about 70% less on each new version.

Most of the 320 fixes come from a review of the whole code base: queue and dispatch, inventory and Spoolman charges, archives, costs, permissions and the database layer on both SQLite and PostgreSQL. Read **Before you update** below: this release changes more defaults and API answers than usual.

New tables and columns are added automatically on both SQLite and PostgreSQL. Some API routes and settings fields were removed; see below.

If you are coming from 1.2.5.6 or earlier, read the 1.2.5.7 release notes too. If you are coming from 1.2.5 or earlier, read the 1.2.5 notes first; all of their upgrade callouts apply to you as well.

**Before you update**

These change behaviour you may rely on. Most need no action unless they apply to your setup.

- **The plate now moves after a print for the finish photo (#3240).** **Restore plate for finish photo** is on by default and now really moves the bed. Turn it off under **Settings > General** to keep the old behaviour.

- **Queued jobs wait instead of printing on the wrong filament (#2799, #3280).** A job whose filament can't be matched to a loaded tray is held for a manual start; **Start** offers **Print Anyway** for the old behaviour. With **Force color match**, a job waits for a spool of the exact colour and variant, and two slots asking for the same filament need two spools of it. A stored AMS mapping that doesn't fit the printer is recomputed.

- **A printer that refuses the upload no longer fails the job (#3409).** SD card, USB and access-code refusals keep the job queued and retry the printer with a growing wait. They send **Job Waiting** instead of **Job Failed**, and "Any <model>" jobs go back to the pool.

- **Groups can be limited to some printers (#1727).** API keys, camera stream links, Cam Wall and overlay tokens now reach only the printers of whoever created them, and a key limited to some printers can't queue to "Any <model>". Nothing changes until an admin limits a group.

- **New permissions are granted once on upgrade, so nothing changes until you remove them.** **Print Without Review** (#1620) goes to every group that could queue, start or run jobs. The Manyfold permissions (#1471) go to groups with the matching MakerWorld permissions. Permissions taken away from a group no longer come back on every restart (#3238).

- **Users who see only their own library files now see only their own folders (#3201).** On upgrade, a folder whose files all belong to one user becomes theirs; every other existing folder is shared with everyone, so it stays visible.

- **Archives count prints, not files.** Reprinting a File Manager or project file from the queue adds a run to its archive instead of a new archive, so scripts that count archives per print will see fewer. Aborted prints now count as failed. The CSV/Excel export has one row per print with new columns; spreadsheets built on the old layout need checking.

- **Costs can change for past prints.** Energy is now costed per hour at that hour's price, and a price change no longer re-costs history, so **Total consumption** figures can differ (#1251). **Recalculate Costs** now reprices past usage from current spool prices (#3414). Cancelled prints are charged each filament's slicer weight times the share fed (#3317). Running prints are priced from their spools at start and marked `~` until they finish (#3261).

- **Obico scores like Obico (#3459).** **Pause print** and **Pause and cut power** need a clearly stronger signal than before, **Notify only** fires at Obico's alert level, and a resumed print is not paused again.

- **Inventory slot assignments.** Spools on an AMS that stays unplugged for more than two minutes are unassigned; RFID spools come back on their own (#3356). Spoolman mode no longer shows built-in inventory slot assignments (#3430). Reading a spool's tag in one slot removes it from any other slot (#3427). Archiving a spool unassigns it.

- **The sidebar's MakerWorld entry is now Model Sources (#1471).** Old `/makerworld` links still work. Camera tokens and the Streaming Overlay builder moved from **API Keys** to the new **Camera** settings tab; old links redirect.

- **Streaming overlay (#3185).** The Classic design no longer adds its own "Printer is idle" / "Printer offline" line. A Classic URL whose `show=` list leaves out `status` shows no idle or offline text; add `status` to get it back.

- **Password Reset email template.** `{password}` and `{login_url}` are gone and the reset link is appended automatically. An untouched template is updated on upgrade; an edited one needs rewording for a link.

- **Built-in calibration on an A1, A1 Mini, P1 or A2L is no longer archived as a print.** The printer kill switch still stops it on these models; switch the kill switch off to calibrate.

- **API, webhook and MQTT changes.**
  - Removed: `POST /printers/test`, `POST /printers/{id}/debug/simulate-print-complete`, `GET /printers/{id}/runtime-debug`, `POST /spoolbuddy/devices/{id}/update-status` and the global virtual printer fields of `GET /settings` and `/settings/virtual-printer`. Use `PUT /virtual-printers/{id}`.
  - `null` for a non-nullable field on PATCH/PUT leaves it unchanged instead of answering 500.
  - Bambu Cloud failures answer 502, and an API key that can't be checked answers 503 instead of "Invalid API key". A K-profile read the printer doesn't answer returns 504 instead of an empty list (#3404).
  - Queue `target_model` is matched case-insensitively and stored in the printers' spelling, and batches can have status **failed**.
  - Archive and statistics date filters take a `tz` parameter; without it, dates are days in Bambuddy's own `TZ` instead of UTC.
  - MQTT relay: `smart_plugs/energy` is published once a minute, `maintenance/alert` carries real values and a `unit` field, and there are new `offline`, `online` and `filament/low` topics.

- **Databases.** PostgreSQL 13 or newer is required (Bambuddy never started on 12 or older). On SQLite, the database is converted once at the first start so ids are never reused, deletes cascade like PostgreSQL, and old leftovers are repaired. The SQLite pool shrinks from 20 + 200 to 10 + 90 connections (#2883); a large SQLite farm may need `DB_MAX_OVERFLOW` or PostgreSQL.

- **Native installs need Python 3.11 or newer**, and Python dependencies are now pinned in `requirements.lock`, which Docker, the installers and the update scripts install. The update scripts stop on an older Python and link to the migration steps. Docker and Windows ship Python 3.13.

- **Update the slicer sidecar image** for the new filament lists, preset fixes and renamed printer presets (#3234, #3250). Some plates get a different automatic filament pick, for example `Generic PA` instead of `Bambu Support For PA/PET`.

- **Update the SpoolBuddy daemon** on the kiosk (**Settings > Updates**) for the spool ID fix (#984). In Spoolman mode, the spool ID replaces the tag UID as the spool's tag.

---

**How to update**

**Docker**

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

**Native install - recommended path**

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

**Native install - manual path**

```bash
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.lock
cd frontend && sudo npm i
sudo systemctl start bambuddy
```

**Windows install**

Download `bambuddy-1.3.0-windows-x64-setup.exe` from this release page (or the unversioned `bambuddy-windows-x64-setup.exe` alias). Existing Windows installs upgrade in place via the in-app Install Update flow.

---

**New**

**Print outcome and archives:**

- **Each archive opens with all its prints** - a click on an archive card or its name opens the archive at `/archives/<id>`: the file, each plate with its estimate, photos and timelapse, and a table of every print of it. Each print has its own photos, video, note and outcome, with **Reprint this print**. The calendar, Print Log and the printer and **Failed Prints** filters now work per print.
- **Wear cost per printing hour** (#694, requested by @wreuel) - an optional rate under **Edit printer** adds the printer's own wear to each print's cost, failed and cancelled prints included. It shows on the archive card, in a **Wear Cost** column in the Print Log, in Quick Stats, projects and batch orders; billing stays filament only.

**File Manager:**

- **Move a folder** (#3305, requested and contributed by @rojosinalma in #3306) - **Move folder...** in a folder's menu, or drag and drop, moves a folder to the root or into another folder. External folders can't be moved, and nothing can be moved into one.
- **Users who see only their own files now see only their own folders** (#3201, requested by @Minebuddy, reported by @lonix) - folders now have an owner, and an admin can mark a folder **Share with everyone**. Uploads and imports go only into your own folders, shared ones and the top level, and folder counts no longer reveal other users' files. See **Before you update**.
- **Browse and import from your Manyfold library** (#1471, requested by @hibikipr) - the **MakerWorld** sidebar entry is now **Model Sources**, with a new **Manyfold** tab to search models and import their 3MF, STL or STEP files, one at a time or in bulk. Bambuddy only reads from Manyfold, and two new permissions control the tab.

**Inventory and spools:**

- **Custom label sheets and saved label templates** (#3296, requested and contributed by @koshilki in #3385) - **Print spool labels** gets a **Custom sheet...** entry with page, label size, columns, rows, margins and gaps, drawn to scale. Custom sheets can be saved under a name and shared by everyone on the install, in both inventory modes.
- **Add a scanned Bambu spool in SpoolBuddy in one tap** (#2685, requested by @BenBBSLA, contributed by @hibikipr in #3389 and #3390) - an unknown Bambu Lab spool on the reader shows the material, colour and weight from its RFID tag, and Quick Add creates the spool with them, tray UUID and tare included.
- **Assign a spool by scanning its QR label** (#1574, requested and contributed by @samedyuksel in #1606) - **Assign by QR** on the **Inventory** page and in SpoolBuddy puts the scanned spool into an AMS slot or a storage location, and the **Assign Spool** dialog gets **Scan a spool QR**. The live camera view needs HTTPS; over HTTP, **Take photo** uses the phone's camera.
- **New spools start with their product's presets and K profiles** (#1326, requested by @mithkr) - tick **Save as default for new ... spools** on a spool's Printers tab, and every new spool of that product starts with its presets and K profiles, however it is added. **Settings > Filament > Profile defaults** lists them and can apply them to existing spools.
- **Spoolman mode uses Spoolman 0.27's native tags** (#3168, requested and contributed by @Niko11111 in #3252) - on Spoolman 0.27 or later a spool can hold several tags, scans look the tag up directly and are faster, and **Settings > Filament** can copy existing tags over. Older Spoolman versions behave as before.
- **Each spool remembers when it was last dried** (#2863, requested by @USER19464727) - a finished AMS drying run marks every spool in that AMS with the date, temperature and duration, shown in a **Last dried** column and on the spool card. It can also be set by hand for an external dryer.

**Queue and printing:**

- **Time to the next pause, and a heads-up before it** (#2984, requested by @apizz) - the printer card shows the time until the next pause added in the slicer, e.g. "20m to pause - 20:27". A new **Pause Coming Up** notification is sent a set number of minutes before, off until turned on.
- **Larger queue thumbnails on hover** (#3313, requested by @humplemann) - hovering a job's thumbnail on a **Queue** card shows it at 256x256, as in **History** (now also 256x256).
- **The queue says when a job printed in a colour the file didn't ask for** (#3279, requested by @Lioncat55) - an amber line per slot on the queue row and in **History**, e.g. "Slot 1 printed in Black, the file asks for Silver". Turn on **Force color match** to have the queue wait instead.
- **Jobs can wait for staff review before they print** (#1620, requested by @ImNanou) - users without the new **Print Without Review** permission can still queue, but their jobs wait with a **Waiting for review** badge until a reviewer starts them. It covers every way a job is queued, including API keys and the webhook.

**Slicing and profiles:**

- **Slice dialog: sorted presets, search, stock presets listed once** (#3386, contributed by @pleasantone in #3393) - preset groups are sorted, every preset dropdown has a search, and a Bambu stock preset that both Bambu Cloud and the bundled Standard profiles list appears once, as the Bambu Cloud copy.
- **OrcaSlicer's third-party filaments and every brand-folder filament in the Slice dialog** (#3234, requested by @nvx) - Elegoo, PolyLite, COEX, SUNLU and many more are now listed, the automatic pick prefers Bambu's own presets and real filament over support material, and custom presets based on brand or P1P filaments now slice with all their inherited settings. Needs the updated slicer sidecar image.
- **Show only printers that are online and spools that are loaded** (#3172, requested by @wolfrage76) - two checkboxes in the Slice dialog narrow the printer and filament lists to what is connected and loaded right now, and a **Pick** button per filament row opens the loaded spools laid out like the AMS. Off until you turn them on.

**Printers, AMS and calibration:**

- **P2S printers show a P2S picture** (#3366, requested and contributed by @andy-sorge in #3369) - instead of the P1S picture.
- **A page for managing printer locations** (#2962, requested and contributed by @SVAN19 in #2995) - create, rename, colour and delete locations and move printers between them, one or many at a time. Renaming a location also updates queued jobs that target it.

**Notifications and camera:**

- **Notify! notification provider with iOS Live Activities and Lock Screen widgets** (#2919, requested and contributed by @simplytoast1 in #3265) - Bambuddy talks to Notify! directly, without the companion container: alerts with photos, a Lock Screen tile per running print with a countdown and problems, and Lock Screen widgets per printer. See **Before you update**.
- **Gotify notification provider** (#2743, requested by @MrCrunshy) - notifications to your own Gotify server, with a priority per event, and the camera photo when External URL is set.
- **Printer cameras from any page** (#2737, requested by @apizz) - camera overlays stay open as you move around Bambuddy. A camera button in the sidebar, or the **C** key, opens one, and an overlay can pop out into its own window and back.
- **USB cameras are picked from a list** - with **USB Camera (V4L2)**, **Settings > Camera** lists the video devices the server sees by name and path, with a refresh button. **Enter path manually** still works.
- **Resize Cam Wall tiles with S / M / L / XL** (#2735, requested by @cmerkle) - from one tile across the full width to five per row, remembered separately from the card size. A kiosk `/camwall` URL can set it with `size=`.
- **Chamber light on whenever the camera is used** (#1655, requested by @dadino) - Settings → Camera can turn the light on for the live view, camera wall, overlay and notification photos, for all or selected printers, and off again 15 seconds after the last use. Off by default; a light you switched on yourself is left alone.

**Failure detection:**

- **OctoEverywhere AI failure detection** (#3169, requested and contributed by @QuinnDamerell in #3428) - a second provider next to self-hosted Obico, using OctoEverywhere's cloud AI with a free Gadget API key and no ML container to run. Pick it under **Settings > Failure Detection > Provider**; it notifies, pauses or powers off as Obico does.
- **Failure detection can send Obico a different Bambuddy address** (#3235, requested and contributed by @chrisbennight in #3255) - an optional **Bambuddy Internal URL** under **Settings > Failure Detection**, used only for the ML server's snapshots, for when External URL sits behind a login. Empty keeps using External URL.

**Streaming overlay:**

- **Your own logo and progress colours, and a see-through Version 2 background** (#3104, requested and contributed by @adamspicedev in #3208) - upload a PNG or WebP logo, set a from/to colour for the progress bar, and fade Version 2's backgrounds so another OBS source shows through.
- **Create an overlay token in the URL builder, or import an existing overlay URL** (#3193, requested and contributed by @adamspicedev in #3207) - **Create overlay token** puts a new token straight into the URL, and **Existing overlay URL** fills in every setting from a URL already in OBS.
- **Portrait overlay URLs for Classic and Version 2** (#3185, requested and contributed by @adamspicedev in #3209) - a new **Layout** choice of Landscape, Portrait or Both, each with its own URL, OBS size and preview. See **Before you update**.

**Accounts, permissions and integrations:**

- **A count on an app's sidebar link** - an app in the sidebar can show a number on its link, as Bambuddy Orders does for its To do, using an API key with **Send notifications**. A count not updated for ten minutes disappears.
- **The MQTT relay publishes printer offline/online and low filament** - new topics `printers/{serial}/offline`, `printers/{serial}/online` and `printers/{serial}/filament/low` for Home Assistant, Node-RED and other subscribers. A short disconnect sends neither offline nor online.
- **Groups can limit their members to some printers** (#1727, requested by @McGagnor) - under Settings → Authentication → Printer access you limit a group to single printers or whole locations, and its members see and control only those printers everywhere in Bambuddy. Nothing changes until a group is limited; admins always see every printer. See **Before you update**.
- **Electricity price from a Home Assistant sensor, costed hour by hour** (#1251, requested by @Roving-Ronin) - for tariffs that change during the day, each hour's energy is now costed at that hour's price, and a new price no longer re-costs past prints or months. See **Before you update**.

**Interface and languages:**

- **Polish** (#3303, requested by @lukg20) - the interface is available in Polish, under **Settings > General > Language**.
- **Remaining time instead of percentage in the browser tab** (#3266, requested and contributed by @bgrr74 in #3283) - **Tab display** under **Settings > Appearance** switches the tab title to the time remaining, e.g. `1h 26m - Bambuddy`. Percentage stays the default.
- **Dominican Peso (DOP) as a currency** (#3388, requested by @rogerabm) - under **Settings > General > Cost Tracking**.
- **Egyptian Pound (EGP) as a currency** (#3334, requested by @aammash) - under **Settings > General > Cost Tracking**.

**Install, platform and database:**

- **`DB_POOL_PRE_PING` to turn off the PostgreSQL connection check** (#3396, requested and contributed by @oh-forf in #3413) - `DB_POOL_PRE_PING=false` skips the check before each pooled session and roughly halves the rolled-back transactions monitoring alerts on. The default stays on; turn it off only for a database on the same host or LAN.

**Changed**

- **A file printed again adds a run to its archive instead of a duplicate** - printing a File Manager or project file from the queue again now joins its existing archive as a new run in the Print Log, with a **Plate** column for multi-plate files. Prints from the slicer, the printer or uploads are unchanged.
- **The archive export has one row per print** - the CSV and Excel export now lists each print as a row, with new **Print ID**, **Printer**, **Plate**, **Outcome** and **Duration (s)** columns. See **Before you update**.
- **Unused API routes and settings removed** - `POST /printers/test`, two printer debug routes, `POST /spoolbuddy/devices/{id}/update-status` and the old global virtual printer settings are gone. See **Before you update**.
- **A grouping that ended in failures is marked failed** - a batch whose jobs all failed or were cancelled, with nothing printed, now shows as **Failed** with its own filter instead of staying active forever. Orders are unchanged.
- **Deleting a custom maintenance type says it applies to every printer** - the confirmation now warns that the type and its history are removed from all printers.
- **The web interface downloads about 70% less on each new version** (#3175, requested by @mason9468) - scripts and styles are now sent compressed and cached until an update brings new ones; the main script drops from 11 MB to 3 MB.
- **The web interface opens faster: pages and languages load when needed** (#3175, requested by @mason9468) - each page and each language now loads on first use, so the login page needs 1.2 MB instead of 11 MB. A tab left open across an update reloads once onto the new version.
- **Notification providers are listed alphabetically** - in the Add Notification Provider dialog, in the app's language.
- **The Streaming Overlay builder remembers your choices** - fields, printer, layout, artwork, colours and the rest are kept in the browser across reloads, with a **Reset choices** link. The token is still never stored.
- **The camera settings stay short on large farms** - **External Cameras** now lists only printers that have one, with **Add external camera** to pick a printer and a bin icon to switch back to the built-in camera.
- **Camera settings have their own tab, and the settings menu is sorted alphabetically** - a new **Camera** tab holds External Cameras, Camera API Tokens and the Streaming Overlay builder; old links open it. **General** stays first, the other tabs follow alphabetically.

**Fixes**

**AMS slots, K profiles and drying:**

- A spool loaded into a slot after a runout and assigned mid-print stayed invisible on the printer card until the print ended (#3434, reported by @0xCyberFortress). The card now shows it with an amber marker until the printer takes it.
- AMS Filament Backup did not pool two rolls of the same filament when one was added by RFID and the other from the preset picker, so Start refused prints the printer could finish with Auto Refill (#1824, reported by @lightmaster). Pooling now follows what the printer reports for each tray.
- A printer that did not answer the K-profile read looked like a printer with no K profiles, and a Git restore then sent every backed-up profile again as a duplicate (#3404, reported by @Spegeli). The page now says the profiles could not be read, and a restore skips that nozzle.
- SUNLU filaments and about thirty P1/X1 presets put the wrong filament into an AMS slot, a Bambu PLA Silk spool went in as PLA Matte when signed in to Bambu Cloud, and Orca Cloud profiles based on SUNLU went in as Generic PLA (#3387, reported by @Spegeli). SUNLU and seven newer Bambu filaments, such as PLA Tough+ and PETG Matte, are now in the catalogue.
- Configuring a slot through the API with a setting id such as `GFSL99` left the slot without colour or type, and auto-unlink dropped its spool (#3444, reported by @Novastorm4332).
- A colour set on the printer's touchscreen got a general name such as "Blue" when its hex was a step off Bambu's, as with Blue Grey (#3279, reported by @Lioncat55). It is now named after the closest Bambu Lab colour in the slot's own range.
- Reset Slot cleared an AMS slot on a single click, even mid-print. It now asks first and is unavailable while a print runs.
- On an H2D, a manual filament load the printer refused could later show the wrong active tray. The refused target is now forgotten after two minutes.
- A printer refusing the K profile re-applied after an RFID read was not logged, which made wrong pressure-advance prints hard to diagnose.
- Opening several pages at once could fetch the Bambu Cloud filament names for K profiles many times over. They now share one fetch.
- Configure Slot reported a typed K value as applied when it could not be sent. It now warns that the filament was set but the K value was not.
- An H2D could keep pointing at the old slot after a filament change, on the printer card and in usage tracking.
- With "Wait for drying to complete" on, queue drying never dried an AMS ahead of a scheduled print. A printer whose job is scheduled for later is now dried ahead of it.
- A Silk spool without a slicer preset could take the Matte preset already in the slot. The finish must now match too, otherwise the slot gets the generic preset for that material and finish.
- Some Bambu Cloud presets showed their raw id instead of their name in the K profile list.
- A K profile picked in Bambu Studio or on the printer's screen was set back to the stored one at the next AMS report. See **Before you update**.
- Saving, deleting or selecting a K profile could change the nozzle size shown for the printer.
- A K profile save, batch save or delete the printer never acknowledged was reported as done. It now says the profile was sent but not confirmed.
- Stopping drying said "Drying stopped" as soon as the command went out. It now says "Stop command sent".
- The "P1 / P2" print labels on AMS slots rarely appeared during a print. They now show, also after a Bambuddy restart.
- "Rotate spool during drying" stayed available on some firmware with filament in the feed tube, and the printer refused the drying start.
- On two-nozzle printers with several AMS, the run-out hint often could not name the slot.
- A regular AMS reporting a single tray was shown as an AMS-HT.
- Load and Unload on an AMS-HT slot were refused or aimed at a unit that does not exist. Loading from the A2L's AMS Lite now also sends the target Bambu Studio sends.
- The K profile picker offered and pre-selected profiles measured on the other nozzle flow type on H2-series printers. They now sit under "Other", and every profile shows [HF] or [S].
- With "Wait for drying to complete" on, an "Any <model>" job could start on a printer whose AMS was drying, and queue auto-drying never dried for such a job.
- The AMS drying button showed as available on a printer that cannot dry until the next live update after a page load.
- A PP, PE, PCTG, PLA Silk or PLA High Speed spool without a slicer preset was sent to its AMS slot with no filament ID, and PVA, HIPS and Bambu support filaments got the wrong setting ID (#3273, reported by @sgiffhorn).
- AMS Filament Backup showed "?" on the P1S, P1P, A1 and A1 Mini (#3259, reported by @HopNotch). The badge now shows the printer's real setting and follows changes made in the slicer.
- H2D, H2D Pro and H2C with several AMS on one extruder briefly marked the wrong tray as loaded on every filament change (#3242, reported by @Thomansky). No wrong spool charge was found, but the wrong tray no longer enters the usage split.

**Spools, inventory, Spoolman and SpoolBuddy:**

- In Spoolman mode, slot assignments kept from the built-in inventory put printer slots on unrelated Spoolman spools that could not be cleared (#3430, reported by @sean3318). Spoolman mode now uses only Spoolman's assignments; the built-in ones are kept for when you switch back.
- A spool whose tag was read in one printer's slot stayed assigned to another printer's slot too, and that printer's prints were charged to it (#3427, reported by @Spegeli).
- In Spoolman mode, a Bambu spool created by AMS sync lost its filament preset, so on the external spool a Bambu PLA Basic became Generic PLA (#3433, reported by @ojimpo).
- Spoolman mode had no per-print usage history, so the spool dialog listed no prints and the stock forecast never reached its Trend rate (#3407, reported by @ojimpo). The forecast's fallback rate in both modes also counted shelf time as use, and now counts only the time a spool was in use.
- In Spoolman mode, the stock forecast used a built-in spool's history when the ids matched, so a spool using 2.5 g a day could show 46 g a day (#3406, reported by @ojimpo).
- The stock forecast left out used-up spools once they were archived, so a SKU in weekly use could show 0.2 g a day and the Reorder and Stock Break alerts came late or never (#3399, reported by @ojimpo). Spools archived in the last 90 days now count towards the rate.
- The stock forecast named a SKU's colour twice, as in "PLA Basic Black Black" (#3400, reported by @ojimpo).
- In Spoolman mode, an external-spool print on an X2D or H2D was charged to the wrong holder, or not at all from the right holder (#3405, reported by @juandelarossa).
- A Spoolman slot change made from another tab or client, such as a filament scale using the API, did not reach open pages without a reload (#3402, reported by @Eifel-Joe).
- SpoolBuddy sometimes read a Bambu tag on the first scan without its tray UUID (#3377, reported and contributed by @hibikipr in #3378).
- SpoolBuddy with a PN5180 reader misread NTAG tags with 7-byte UIDs (#3258, contributed by @Thomansky in #3346). Spools linked with the short form are still found and upgraded on the next scan.
- Bambu PLA Silk+ spools added by RFID arrived without a colour name, in both inventory modes. The update files the seeded Silk+ colours correctly and names the affected spools.
- A spool replaced mid-print after a runout was charged for the whole print (#3325, reported by @HarryCoff2). The filament is now split between the old and new spool at the layer of the swap.
- A print that failed or was cancelled could be charged for more filament than it used, sometimes more than the finished print (#3317, reported by @ojimpo; thanks to @Andrewdlec for the files).
- With Spoolman, writing a SpoolBuddy tag that another spool already carried left it on both spools (#3312, reported by @sgiffhorn).
- A SpoolBuddy kiosk got stuck on the login page until the next reboot when it started while the database was unavailable (#3360, reported by @sargonas). Pages now show "Can't reach Bambuddy. Retrying..." and continue once it answers.
- Searching "#123" in Assign Spool found nothing when that spool's material did not match the slot (#3301). It now shows the spool, or says why it cannot be offered.
- After a printer was deleted, spools still showed as loaded in it for up to 30 seconds.
- In Spoolman mode, writing a SpoolBuddy sticker to a Bambu spool created from the AMS broke its AMS link, so the next sync created a duplicate spool. Such spools are no longer listed as untagged. See **Before you update**.
- SpoolBuddy's Write Tag warned "color_name not set" for every Spoolman spool. It now warns only when the colour name is really missing.
- A SpoolBuddy update reported success when installing its dependencies timed out. A timeout now stops the update with an error, and the time allowed went from 2 to 5 minutes.
- A failed Unassign on the printer card showed no message, and the spool simply stayed assigned.
- The Inventory "Full" column always read "-" and has been removed.
- In Spoolman mode, a print whose 3MF arrived late was charged from the AMS remaining percentage instead of the 3MF.
- The Spoolman AMS syncs could replace a spool assigned by hand, and the Sync buttons in Spoolman settings did not update other open pages or the slot's filament preset.
- Several Spoolman requests misbehaved: a storage location could be deleted while Spoolman was unreachable, labels for an archived spool failed the batch, an archived spool could be assigned to a slot, and resetting "Total Consumed" reported success when it failed.
- A finished print's filament could be charged twice when an AMS update arrived right as the print ended.
- Spool weights and slot assignments written at the same moment could undo each other or fail with a server error on PostgreSQL.
- Inventory views and printer cards did not show deleted spools and new weights until a refresh, and deleting a printer on SQLite left its spools assigned to it.
- Saving the SpoolBuddy backend config said only "Config queued." although it needs a daemon restart. It now says so and offers "Restart Daemon".
- A used weight entered for a new spool was not locked, so the first AMS sync could overwrite it. See **Before you update**.
- Every AMS sync and scale weighing stamped "last used" on Spoolman spools. Only print usage sets it now.
- On the A2L, filament used from the AMS Lite was never charged to its spool, or went to a regular AMS slot of the same number.
- A spool Spoolman created from the AMS for a 250 g refill matched to a 1000 g filament was created as a 1000 g spool showing 75% used.
- An unregistered SpoolBuddy that was still running did not register again on its next heartbeat.
- In Spoolman mode, a failed print that switched to the backup spool charged everything to the spool that ran out, a second copy of the same archive printing at once was never deducted, and unused filament slots could be charged to the external spool.
- Spoolman slot assignments were not checked against what the printer reported back, and Configure AMS Slot opened without the Spoolman spool's slicer preset.
- Archiving or deleting a spool left it in its AMS slots, where it was charged the next print's usage, and "Restore" in an expanded group row did nothing. See **Before you update**.
- "Sync Weights from AMS" counted a running print twice when used mid-print and skipped locked spools for good, and SpoolBuddy scale weights could be overwritten by the AMS sync. See **Before you update**.
- SpoolBuddy's Write Tag page could stay on "Writing tag...", scanned tags failed silently when Spoolman was unavailable, and lookups fell back to the built-in inventory when the Spoolman URL was refused.
- The filament of a print spanning a Bambuddy restart could be counted twice.
- In Spoolman mode, SpoolBuddy could write a hidden built-in spool with the same id to the tag while reporting success, so the Spoolman spool never got its tag. Writing a tag now uses only the inventory in use.
- Deleting a spool deleted its usage history on PostgreSQL, and on SQLite the next new spool could take over a deleted spool's number with its prints and consumption. Usage history now stays, prints keep their spool cost, and spool numbers are never reused.
- On PostgreSQL databases created before v0.2.4.8, reporting usage to Spoolman failed and archives never got the colour, material and cost of their Spoolman spools. The affected columns are converted on the next start.
- SpoolBuddy knew a Bambu Lab spool only by the tag that was scanned, so scanning its other side offered to add it again, and kiosk and AMS spools were not matched to each other (#984, block 9 read from @Keybored02's #1200, measured by @Thomansky and @Sawtaytoes). Update the SpoolBuddy daemon to get the fix; existing spools pick up their spool ID on the next scan.
- In Spoolman mode, lifting a spool off SpoolBuddy while Assign Spool or Add to Inventory was open lost its tag.
- Inventory deducted only the first filament when two filaments of a print came from the same spool (#3230, reported by @Andrewdlec).

**Queue and dispatch:**

- A printer whose SD card or USB drive refused the file, or that rejected the access code, failed one queued job after another: an H2D with a bad USB stick failed seven in an hour (#3409, reported by @simonkreuchauff-oss). The jobs now stay queued, the printer is retried with a growing wait, "Any <model>" jobs can move to another printer, and a file name no card accepts still fails with a request to rename it.
- "Force color match" was never saved on a job for a specific printer, so a part set to black could print in green (#3445, reported by @frnzzle).
- A job with "Force color match" printed on any spool of the same material when the colour was missing (#3280, reported by @Lioncat55). It now waits with "Needs PLA #A4AAAC" until that exact colour, and variant, is loaded.
- A job queued for "Any" model kept returning to a dual-nozzle printer whose filament sat on the wrong nozzle, or to a printer with one spool for two slots of the same material. Such printers are now passed over, and the job waits with the filament it needs.
- Queue prints to a printer without an AMS, such as a P1S on its spool holder, failed with "Failed to get AMS mapping table" (#3356, reported by @dogod3814t). An unplugged AMS also now disappears from the printer card without a restart.
- Opening the Queue page on a large print farm could stall Bambuddy for over a minute. History now loads 50 runs at a time, and Clear History is a single request.
- The queue's "View in File Manager" icon opened an empty page (#3336, reported by @Magical-Penguin). It now opens the file's folder with the file marked, and the archive icon marks its archive.
- A late slicer update through a virtual printer could rewrite a queue job already being sent, so the job and the spool charged could differ from what was printed.
- Saving an edited queue item showed "Queue item updated" twice and closed the dialog twice.
- The queue timeline and history rows showed plate 1 for every plate of a multi-plate file.
- An "Any model" job with a filament override could wait forever for an equivalent filament under another name, preheat heated the bed for the hottest filament of the whole project instead of the plate, and the queue page showed generic colour names.
- Cancelling a pipeline run, or stopping a job on an offline printer, could leave a printer preheating or cost centre budget reserved, and a print start the printer refused was taken as accepted for three minutes. A refused start now goes back to the queue at once.
- Queue jobs added through the API or webhook could miss their printers: a model in lower case was refused, a webhook reprint ignored the slicer's AMS slots, and a multi-plate file queued without a plate waited for every plate's filament. See **Before you update**.
- A queue job edited while the scheduler was working through the queue could lose the edit or be sent to the wrong printer. It now waits for the next round.
- An "Any model" job waiting for a drying printer read "Busy" as if that printer were printing. It now says "(drying)".
- The queue bulk edit showed a neutral, English-only message even when nothing was updated. It now shows an error or a warning.
- A plate printed only from the external spool turned the AMS back on when another plate of the same file used it, and the printer refused the job (also #3087).
- A virtual printer for any printer of a model stored the slicer's AMS on/off with the job, although the slicer could not see the real printer's AMS.
- A nozzle mapping on an "Any model" job was sent unchanged to whichever H2C picked it up instead of that printer's rack positions.
- Jobs added from the File Manager, webhook, virtual printer, pipeline or an order sent no "Job added" notification or MQTT event, and some ignored the default print options. See **Before you update**.
- The filament override picker merged identical left and right external spools on an H2D or X2D without an AMS.
- Prints queued through a virtual printer lost the slicer's nozzle offset calibration, rack nozzles and AMS choices, or took the old printer's nozzle positions to another printer.
- The print dialog had several gaps: **Print Anyway** in a multi-printer print waved through printers that were never checked, an untouched plate dropdown with printer alternatives queued the job with no plate, H2D and H2C matched nozzles from every plate instead of the chosen one, and a sliced 3MF named without `.gcode` could not be dropped on a printer card or picked in **Upload for Print**.
- Orders and groupings whose last job was cancelled, stopped, cleaned up or failed before reaching the printer stayed **active** until the next restart.
- Cancelling a queued job while it was being sent was overwritten when the upload then failed, sending a failure notification and skipping the next dependent job. A stopped job could also be put back in the queue or leave its file on the printer.
- "Only if the previous print succeeded" checked the wrong printer for jobs queued to any printer of a model, so it nearly always let the job through. It now checks the printer it is about to use, takes another of the model if needed, and shows a **Resume after failure** banner when every free one failed last.
- A queued job moved to another printer kept the old printer's AMS mapping, **Print Anyway** and nozzle choice, so it could print the wrong colour or skip filament checks, and **Dispatch remaining** of an order pinned every copy to one printer. These are now worked out again for the new printer.
- A slicer pipeline run stayed **dispatching** forever once a copy's queue entry was deleted, and **Retry failed** could not tell what to retry. Each copy now keeps its final state.
- A printing queue job showed the slot AMS Filament Backup had switched away from as **Empty**. The card now shows the backup spool in use.
- A queue job whose file name started or ended with a space stayed "printing" for five minutes after it finished, holding up the next job (#3241, reported by @Thomansky).
- An "Any model" job waited for filament on one printer while another idle printer of that model had enough (#3137, reported by @RambachTJ). The queue now picks a printer with enough filament.
- A job moved to a location or model could print from an empty external spool on a printer that had the filament in its AMS, stopping with "External filament is missing" (#3239, reported by @hyyz17200).
- A job moved to a location or model could print in the wrong colour from the tray number it had on its original printer (#3239, reported by @hyyz17200). Trays are now matched by material and colour on the printer that runs it, and the job is held when nothing fits.
- A virtual printer with Save AMS mapping lost the slicer's external-spool pick, so the print could stop with 0700-8012 "Failed to get AMS mapping table" (#3237, reported by @erabti).
- One model queued to several printers used the first printer's AMS mapping for all of them and could print in the wrong filament (#2799, reported and contributed by @grolmus in #2803). Each printer is now mapped against its own AMS.
- A job whose filament was not loaded was sent anyway and refused by the printer after the whole upload (#2799, reported and contributed by @grolmus in #2803). It now waits for a manual start and the queue row says which filament it needs.

**Archives, print log and statistics:**

- A print that finished while Bambuddy was restarting was archived as aborted and its queue job cancelled (#3310, reported by @Thomansky). It is now closed as completed, or as failed with the printer's error.
- Printing one plate of a multi-plate file was measured against the whole file, so the card showed +200 % (#3375, reported by @sargonas). Each print is now compared with its own plate's estimate, on the card, in the archive, in statistics and in notifications.
- Reprinting a plate of a multi-plate file from an archive, the outcome dialog or the queue history opened on plate 1 and could print the wrong part (#3395, reported by @Thomansky).
- An old video still on the printer's card could be attached to a new print, deleted from the printer and used for its finish photo, when the card could not be read at print start (#3412, reported by @ojimpo).
- The second plate or a reprint of an archive lost its finish photo. Photos lost this way are restored once at startup if the file is still on disk.
- A late timelapse left two finish photos on an archive.
- An archive printed more than once played only the latest print's video. The button now shows how many videos there are and opens the archive when there is more than one.
- "Change outcome" in an archive's print list opened a dialog with nothing to choose.
- A print started over LAN, from Handy or on the printer was not linked to its archive while it ran, and its print time came out short (#3327, reported by @Andrewdlec).
- "Print again" on the printer's screen produced an empty archive when the project's title differed from its file name (#3307, reported by @elizabethburg).
- A large 3MF that timed out at print start was downloaded a second time, holding the printer's file connection for up to 5 more minutes (#3308, reported by @elizabethburg).
- Statistics' Today, This Week and the other ranges used UTC days, so near midnight they showed the wrong day's prints.
- "Purge old archives" could delete by an age that had not been previewed when you clicked straight after changing it.
- A stray Stop on an idle printer could relabel a later failed print as cancelled by the user.
- A timelapse could go to the wrong print, or none, when the next plate or a reprint started soon after, or when Scan for Timelapse matched another archive's video.
- The Archives page showed a timelapse picked, uploaded, converted or deleted by hand only after a reload.
- Editing an archive while it was being reprinted rewrote the previous print's result in the print log.
- Archives made without their 3MF showed blank colour swatches and got no filament from an AMS-HT. Existing archives are repaired on startup.
- An archive showed the nozzle temperature of the project's first filament instead of the one its plate prints.
- A print stopped while its printer was offline was missing from the print log, and a printer reporting the same end twice logged the run twice.
- A print Bambuddy did not start could be matched to an archive whose name only contained its name, so finishing "Clip" could complete and bill "Cable Clip".
- When the 3MF could not be downloaded directly, print start could archive another file whose name merely contained the print's name.
- The built-in calibration on A1, A1 Mini, P1 and A2L was archived and announced as a print. See **Before you update**.
- The recovery download for a print's 3MF gave up after 90 seconds, so a large file on a slow SD card never made it. The deadline now follows the file size, up to 15 minutes.
- A print stopped by the printer kill switch was archived as failed with no reason. It is now cancelled, with "Stopped by the printer kill switch".
- A queue job stopped while its printer was offline stored an untranslated sentence as its failure reason. It now stores "User cancelled", and existing archives are converted on startup.
- A re-sliced archive showed as printed and counted as a finished print. It now shows as "Not printed" with an estimated cost.
- A reprint of a single-plate archive left the old timelapse behind as an unlinked file.
- Edit Archive saved "Cancelled" as "aborted", opened a cancelled archive as "Completed", and allowed setting "Printing" by hand.
- An archive left on "printing" after a missed end took over the next print of the same file for two hours.
- Print History, usage and thumbnails could describe the wrong plate of a multi-plate file, also after a restart or for prints started outside Bambuddy.
- Reprints had several archive problems: a completed reprint still showed the previous run's failure reason, a queued job whose archive was deleted printed with no archive, notification or usage tracking, and a timelapse deleted during MP4 conversion came back afterwards.
- Deleting an archive for good failed with a server error on PostgreSQL when an inventory spool had been used for it, and **Recalculate costs** could add the spool cost of other prints with the same name to an archive.
- Archiving a virtual-printer upload into a project that does not exist failed after archiving on PostgreSQL or saved a broken link on SQLite. It is now refused up front with "Project not found".
- Deleting a printer could delete the archive of a print starting on it at that moment on PostgreSQL, even with "keep archives" on.
- A printer deleted while its print was starting still got an archive, which showed "printing" forever on SQLite, plus a timelapse and tracking.
- Starting the next plate of a Send All deleted the previous plate's timelapse (#3275, reported by @dovmesiz). A queued print running over 2 hours also lost its plate, AMS mapping and cost centre data.
- A large timelapse could not be attached when it took more than 5 minutes to download (#3272, reported by @dovmesiz).
- Statistics counted a multi-material print priced from spools as one combined material, and the Archives material filter missed such prints (#3262, reported by @ojimpo).
- A print started from the printer's screen took about six seconds longer to archive, and could not be reattached after a Bambuddy restart (#3009, reported by @bdwilson).

**Costs, energy and finance:**

- "Recalculate Costs" ignored changed spool prices and updated nothing (#3414, reported by @BurgerKerman). It now reprices past usage from the spools' current prices, and the Print Log, statistics, projects and orders follow.
- A personal cost centre's budget could not be changed or set to 0 (#3358, reported by @airforceixi).
- A failed or cancelled print's card showed the whole file's cost and grams with no hint (#3318, reported by @ojimpo). A tooltip now says it is one full print's cost and points to the Print Log.
- A print that failed on its first run left the archive costing only the part it printed, so reprints were estimated and billed from that fraction.
- In Spoolman mode, a very short print could end with its estimated cost instead of the real one.
- Editing a transaction to "No cost center" kept the old cost centre.
- In Spoolman mode, the print dialog's cost estimate, budget check and "g left" came from the built-in inventory instead of the Spoolman spool in the slot.
- A Spoolman spool priced only through its filament showed no Cost/kg in the Inventory or on SpoolBuddy, although prints were charged that price. The column now shows it, marked "(filament price)".
- A print's filament and cost could be charged with another plate's or another run's AMS mapping.
- An archive cost set through the API or by Recalculate costs did not reach the statistics, project totals or batch sums. See **Before you update**.
- An archive whose 3MF arrived during the print kept the default filament price instead of being priced from its spools.
- In Spoolman mode, failed or stopped prints were logged with no cost, and queued library files were priced at the default filament cost.
- Recalculate Costs and Rescan gave an archive printed three times the cost of all three, editing an archive's weight did not reprice it, and a spool without a price was charged nothing at completion.
- A print whose dispatch failed left its cost centre behind, and the next print of that archive was charged to it.
- A print without a user kept its budget reservation for good, so the cost centre showed less budget left. Leftover reservations are released at the next start; booked charges don't change.
- Editing a smart plug could save values that broke the smart plug list and the plug with server errors until fixed in the database, and could give a printer a second Tasmota plug. Edits are now checked the same way as adding a plug.
- A print whose user was deleted while it ran was never charged to its cost centre and its budget reservation stayed active. It is now charged and settled, and ledger entries without a user read "No user".
- A running print's cost used the default rate instead of its spools, then changed without notice at completion (#3261, reported by @ojimpo). It is now priced from its spools at start, marked with `~` while it runs.
- A smart plug that reports energy in watt-seconds, such as a myStrom Switch, could not be saved because the multiplier was too small (reported by @CLKRUN in #1251).
- The Statistics energy total stayed at 0 for Shelly and other REST plugs with only a lifetime counter (#3232, reported and contributed by @rojosinalma in #3233).

**Slicing, profiles and 3MF:**

- An unsliced project saved as `.gcode.3mf` was treated as a printable file, and printing it to a cost centre failed with an unrelated error (#3435, reported by @Mounting9612). It is now stored as a project and offered for slicing.
- Orca Cloud processes were hidden for every printer, and a process made for a user printer preset was refused by the slicer (#3380; contributed by @pleasantone in #3381).
- With OrcaSlicer preferred, the Slice dialog could preselect the wrong material for H2C filaments, such as Generic PETG for a PETG-CF slot (#3403, reported by @pleasantone).
- Slicing for the Cool Plate SuperTack, or for the Smooth PEI Plate, failed with "Filaments are not compatible with the plate type" (#3359, reported by @Akaviiii). Saved pipelines with either plate now work without being saved again.
- Slicing a model coloured with the paint tool could turn several filaments into one colour (#3355, reported by @cartman75).
- A slow progress answer from the slicer sidecar was reported as "Slicer sidecar unreachable". It now says the sidecar did not answer in time.
- The Slice dialog hid renamed printer profiles while "Only printers that are online" was on (#3172, reported by @gregspatrick).
- Slicing failed with a renamed printer preset, saying the printer was not compatible with the process preset (#3250, reported by @bobobo1618). Needs the updated slicer sidecar image.

**Printers, MQTT and connection:**

- When a dropped printer connection came back, commands still waiting for an acknowledgement were sent again, and a 4-hour print was cancelled seconds after the reconnect (#3454, reported and diagnosed by @SaadShaya7). A command sent in the seconds before a drop now has to be clicked again.
- On the X2D, the left and right auxiliary fans were swapped on the printer card (#3373, reported by @Spegeli).
- An H2D Pro reported no chamber temperature and refused chamber heating, and queued prints got no chamber preheat or airduct switch; the X1E's chamber heater was not offered either (#3335, reported by @Spegeli).
- An AMS Lite showed no serial number or firmware version on the A1, A1 Mini and A2L (#3348, reported by @Spegeli).
- Firmware could be uploaded to a printer in the middle of a print. Upload is now offered only while the printer is idle.
- The MQTT Debug log showed messages already changed by Bambuddy instead of what the printer sent.
- The cooling/heating mode switched on the printer showed the old mode on the printer card until the next refresh.
- An H2C rack slot's material label disappeared from the printer card until the next refresh.
- Time a printer spent offline mid-print was counted as print time, so hours-based maintenance came due early.
- Two pages could create the same maintenance item twice, and marking it performed could update the hidden copy. Existing duplicates are merged on upgrade.
- The maintenance API answered "performed" in hours for an item set to days.
- Skip Objects and the printer connection diagnostic held a database connection while waiting on the printer.
- An upload the printer never confirmed was not checked. Its size is now read back and a mismatch is logged.
- A skip the printer refused stayed marked as skipped. It is now taken back off the list and logged with the printer's reason.
- A nozzle change, firmware update or developer-mode switch reached the printer card only with the 30-second refresh.
- A print resumed after a Bambuddy restart could lose the plate height the plate restore uses.
- Saving any virtual printer restarted every proxy-mode virtual printer and dropped the slicer's connection. Changing a printer's IP and renaming a virtual printer now restart the right ones.
- With the internet down, every firmware check logged a full error for every Bambu Lab page it tried. The outage is now logged once per site as a warning, with a line when it is reachable again.
- When Bambu Cloud was unreachable, the cloud preset, device and firmware views answered with a server error, and a pasted token, password or email code was reported as wrong. These now say Bambu Cloud is unreachable.
- A printer added after deleting another could take over the deleted printer's running print and its owner, filament tracking, plate-clear prompt, pending notifications, camera picture, Obico verdict and plate detection references, and skipped its own first-connect steps.
- The SD-card cleanup could delete a file the printer was printing right then (#3009, reported by @bdwilson).

**Notifications and HMS:**

- Mail from Bambuddy could be quarantined by strict mail servers because it had no `Date` or `Message-ID` header, and a sender name with accents or a comma broke the From address (#3376, reported by @BenjaminCs).
- The same printer fault could be listed twice when the printer reported it both as a print error and in its HMS list.
- The daily digest was lost when it could not be sent, or when its time fell in quiet hours. See **Before you update**.
- A later print could be attributed to, and emailed to, the user of an earlier queued print of the same name.
- "Print Stopped" after a printer came back online showed the time it was offline as the print's duration. It now shows the sliced estimate.
- The template editor had no variables or preview for the print outcome confirmation, and seven other events had no names.
- A notification channel limited to one printer also received other printers' queue and waiting events. See **Before you update**.
- CallMeBot dropped the "+" from the phone number.
- A print started outside Bambuddy on a printer with auto-archive off could be reported to the owner of the previous queue job. A queue job whose archive couldn't be found now still reaches its owner with the right duration.
- Smart plug Power Alerts were never sent and broke the plug's status request on the printer card. They are now checked in the background, sent once per crossing as **Printer Error** events.
- A printer fault that kept coming and going was notified every time it came back (#3226, reported by @sgiffhorn). It is now notified again only after it has been gone for 10 minutes.

**Camera, stream overlay and failure detection:**

- Obico failure detection added whatever the camera always sees, such as plate texture, to its score, so it acted too early, and it paused a print again after you resumed it (#3459, reported by @jasoncronje). Scoring now matches Obico's, and the pause actions wait for Obico's stronger pause level.
- With "Restore plate for finish photo" on, a tall print could drive the plate past the bottom of its travel, as on a 238 mm print on an H2C. The plate now goes back to where the print's end G-code left it.
- A camera opened from a printer card or the Cam Wall followed you onto every page (#2737). Only cameras opened from the sidebar's Cameras button now show everywhere.
- In the iOS home-screen web app, a camera opened in window mode took over the app with no way back (#3419).
- A camera rotated 90 or 270 degrees was cut off or letterboxed in the floating camera window, on Cam Wall tiles and in the stream overlay (#3362, reported by @BurgerKerman).
- Stitching a layer timelapse could use gigabytes of memory, so a container with a 2 GiB limit was killed at the end of a long print and lost its timelapse (#3292, reported by @fma965). It now peaks around 300 MiB, and ffmpeg no longer keeps running after an error or cancel.
- The timelapse player pushed its controls off screen for a portrait video, and some of its labels were always in English (#3281, reported by @BurgerKerman).
- Saving in the timelapse editor could fail on long videos, and the saved music was always at full volume. Saving now runs in the background with progress and Cancel.
- Escape could be ignored right as the Build Plate Check result appeared, leaving the chamber light on.
- With the camera light setting on, the plate check could switch the chamber light off under a live view.
- A single-client external MJPEG camera made timelapse frames, finish photos and Obico checks fail while someone watched. They now use the live view's frame.
- The X1C live view could stop instead of reconnecting.
- Camera test, diagnose, plate check and plate calibration held a database connection for the whole capture, which could exhaust the pool on a large farm.
- The OBS stream overlay ignored live updates and moved only with its 2-second refresh.
- A plate check that could not run reported an empty plate. It now warns with the reason, and the print still goes ahead.
- A second Bambuddy on the same machine cut the other's live camera every minute.
- A restart with a camera view open logged an ERROR with a full traceback for each stream.
- A layer timelapse of a long print was stopped after 5 minutes and its frames deleted. Stitching now gets up to an hour, and frames are kept for about 24 hours if ffmpeg fails.
- A frozen external RTSP camera kept showing the same picture. It is now reconnected, like the printer's own camera.
- A USB camera could not be watched from several places at once; the second viewer got a blank picture. See **Before you update**.
- Camera snapshots and the camera Test button failed for a USB camera while someone watched the live view. See **Before you update**.
- USB camera finish photos, timelapse frames and snapshots were sometimes killed mid-capture, and other programs' ffmpeg on a USB camera were stopped too.
- The camera Test button read "Camera connected640x480".
- With an external RTSP or USB camera, closing one view of a printer stopped the stream for every other open view.
- A minimized camera window kept its stream open, and the camera window and Camera page left the current stream running after a reconnect, keeping the camera busy and the chamber light on.
- Cam Wall tiles froze on their last frame when the server ended a stream, and a long-running overlay browser source piled up camera connections. Live tiles now reconnect every minute and show "No signal" if the camera stays down.
- The finish photo never raised the plate back into view, on any printer (#3240, reported by @Thomansky). The plate now moves when **Restore plate for finish photo** is on, which is the default.

**Login, users and permissions:**

- API keys of Operators were refused with 403 on their owner's own queue jobs, archives and library files.
- LDAP and SMTP "Test" checked the saved settings instead of what the form held.
- A user's email address could not be removed.
- Clear all on the shopping list, Clear in the Git backup history and removing a linked SSO account acted without asking. An SSO-only account can no longer remove its last SSO link.
- After a 2FA or SSO login, LDAP and OIDC users were offered "Change Password" until the page was reloaded.
- Admins could not remove a locked-out user's 2FA from Edit user. Settings -> Users -> Edit user now has "Disable 2FA".
- Users without **settings:read** got default values for settings many pages need: with billing on, the print dialog hid the cost centre picker and the print was refused, and the queue order, Slice in the File Manager, filament warnings, Spoolman mode, theme and currency were wrong too. These settings now reach every signed-in user, without credentials.
- A project's bill of materials could show the names of archives from printers outside the user's groups.
- Adding archives or queue jobs to a project, or removing them, accepted archives and jobs from printers outside the user's groups and other users' prints. **Assign to project** now reports how many were actually added.
- Editing an archive could move it to a printer outside the user's groups, or link it to a printer or project that does not exist.
- The "Password Reset" email template was never used, and its default text still promised a new password. The reset email is now built from the template with the reset link added at the end.
- Removing live-update or camera permission from a group, a user or an API key did not end open dashboards, camera views, camera links or Cam Wall and Home Assistant camera tokens.
- An open camera view kept showing live video after a password change, deactivation, revoked token, group change or similar. Streams now re-check every 30 seconds that the viewer may still watch.
- Signing a user out everywhere (password change or reset, 2FA removal, deactivation, backup restore) left their open dashboards receiving live updates. Turning authentication on and disabling or expiring an API key now close the dashboards they opened too.
- A deleted API key's open dashboards and camera and WebSocket links passed to the next key created on SQLite.
- With login off, any unexpected error was reported as "Authentication service temporarily unavailable" (503), and with login on a brief database hiccup signed you out. Errors are now reported as 500 and logged, and a hiccup is retried.
- Permissions removed from a group, such as View and Import MakerWorld, came back after a restart (#3238, reported by @Minebuddy).

**File Manager:**

- A large tag catalogue pushed the file list off the page (#3411, reported by @iammenasco). Tags now take up to two rows, with Show all and a tag search.
- The File Manager's toolbar ran past the edge of a phone screen and pushed the page sideways (#3295, reported by @apizz).
- Changing a library file's folder through the API could move it into or out of an external folder without moving the file. See **Before you update**.
- A slow folder listing on the printer showed as "printer unavailable", and every refresh started another listing.
- Printer files written in the evening could be dated a year back on hosts behind UTC, so their camera recordings were not matched to their prints.
- Uploading two files with the same name showed the first file's result on both rows.
- Opening a printer's 3MF in the file browser downloaded the whole file again for the plate list and every plate thumbnail.
- Printer file downloads and previews reported every failure as "File not found", and large files hit a fixed 10-minute limit. They now get the time their size needs, up to 30 minutes.
- A timed-out printer download kept running beside its retry.
- Large STL files froze the web interface and printer updates while their thumbnail was made.
- The File Manager and Archives page loaded every thumbnail at once, slowing down the server. Thumbnails now load as they scroll into view.
- File names could vanish from the File Manager's list view when the folder sidebar left little room (#3105, reported and contributed by @akshayy29 in #3119).

**Interface:**

- The full-page G-code preview stayed black and the scrollbar flickered on wide screens with classic scrollbars (#3371, reported by @Underscan007).
- The print dialog's slot picker was too narrow to read on a phone held upright (#3294, reported by @apizz).
- The Printers page sent hundreds of requests on a print farm, about 550 with 59 printers, so cards took up to 20 seconds to fill. Requests are now combined.
- A bug report was filed as a public GitHub issue the moment debug logging stopped, also on the 5-minute auto-stop (#3363, reported by @choumarin). You now review it before submitting.
- Check Belt Tension on a P1 printer linked to a general maintenance page instead of Bambu Lab's belt guide (#3302).
- Removed the "Save thumbnails" and "Notification Language" settings, which did nothing. See **Before you update**.
- The printer temperature history retention had no setting, and a retention of 0 set through the API wiped all history. See **Before you update**.
- "Max copies per run" and the Docker compose directory were never saved, "Default printer" and "Auto-archive prints" were never used, and "Create Archive Entry When Printing" did nothing. See **Before you update**.
- Importing or creating a project could give it a priority or status the project editor refuses, making it uneditable, and failed on PostgreSQL. Such projects now get **normal** and **active**, also when restoring an older SQLite backup onto PostgreSQL.
- A tab whose live connection was closed by the server froze until reloaded. It now goes to the login page if the session ended, or reconnects.
- A project's timeline stayed empty while one of its jobs was queued or printing.
- Printer cards never showed the last print, nor the Good / Reject prompt for it. The cards now share one request instead of one per card.
- The Printer Locations page had no way back to the Printers page. It now has a "Back to Printers" link.

**Integrations (Home Assistant, MakerWorld, Spoolman, API):**

- Every MakerWorld import, Save and Save & Slice failed with "MakerWorld did not return a download URL" from 3 November on (#3447, reported by @waynem-bris; #3448, reported by @frederic3114).
- Home Assistant's connection test said "Could not connect" when the problem was an untrusted https certificate (#3300). It now names the certificate problem and what to do.
- An API client reading right after a successful write could miss it, and a save that failed at the end was hidden behind a success (#3320, reported by @MorganMLGman).
- The legacy `PUT /settings/virtual-printer` route answered 200 for settings it no longer applies. See **Before you update**.
- `GET /queue?status=pending,printing` with several statuses returned nothing.
- A request with `Infinity` or `NaN` in a field that failed validation ended in a server error instead of 422.
- Home Assistant "Run when printer turns off" scripts ran only when the plug was switched by hand, not on auto-off, schedules or failure detection. See **Before you update**.
- A truncated or broken MakerWorld download was stored as the model's 3MF.
- Unused API pieces were removed and the rest documented. See **Before you update**.
- The archive export's `date_to` left out the whole end day, and an invalid print speed answered 500 instead of 422.
- An id of `0` when queueing, creating a batch or creating a project skipped the existence check, causing a server error on PostgreSQL or a link to nothing on SQLite. `0` now means "none", and batches check their archive or library file.
- A smart plug or virtual printer could be linked to printer `0`. It is now refused with "Printer not found".
- `null` for a field that can't be empty caused a server error on about a dozen PATCH/PUT routes. It now leaves the field unchanged, as on other routes.
- The `/webhook/...` routes answered a database problem while checking the API key with a 500. They now answer 503, and 401 for an invalid key.
- The MQTT relay never sent `smart_plugs/energy`, sent `smart_plugs/on` and `/off` only for manual switching, and `maintenance/alert` always reported 0. All three now match the wiki.
- The webhook endpoints listed in **Settings → API Keys** didn't exist or had the wrong path.
- A valid API key could be refused as invalid during a brief database problem. It now answers 503, which a client can retry.
- Stopping or cancelling a print through the API failed with an error, and `cancel` left the print running. Both now work, count as stopped rather than failed, and `stop` also stops a paused print.

**Install, platform and database:**

- Restoring a Git backup brought back archives without their prints, so statistics and the Print Log were empty. Backups now include prints, and older backups restore as before.
- The connection diagnostic told containers on a Docker macvlan network to switch to host networking (#3417, reported by @ddavidebor). It now recognises macvlan and ipvlan, and warns about ipvlan L3.
- Deleting a local backup failed silently when the server refused it.
- A print running when a Git backup was made came back as a failed print after restore, with a failure notification. It is now restored as cancelled.
- Spools came back from a Git backup restore as never used.
- A Git backup restore brought every reset "Total Consumed" counter back to its full lifetime figure.
- The virtual printers kept running during a backup restore.
- Restoring an old SQLite backup onto PostgreSQL could put some timestamps hours off.
- A GitHub backup restore copied damaged or hand-edited numbers into the totals. Invalid values are now left empty and listed in the restore summary.
- Bug reports kept the reporter's email locally. Stored emails are cleared at startup.
- An in-app update that hung blocked every further update until a restart. Each step now has a time limit.
- The SpoolBuddy installer in full mode, installed under /home, wrote a Bambuddy service that failed with 203/EXEC (also #1685).
- SQLite gave a deleted user's, API key's, printer's, archive's or camera token's id to the next one created. These tables now never reuse an id; existing databases are converted once at start.
- On SQLite, deleting archives, library files, queue jobs, batches or projects left references behind, so a queue job could print a different file and a new project could show a deleted one's files. Deletes now clean up as on PostgreSQL, and each start repairs what earlier deletes left.
- PostgreSQL databases from older versions were missing links a new install has, so deleting an archive, user or library file left their id behind, and 51 columns differed from a new install. They are fixed on the next start.
- Each install or update took whatever Python dependency versions were newest that day. They are now pinned to tested versions.
- Native installs on Python 3.10 failed to start while the installer and docs said 3.10 was enough. Python 3.11 is now required, and the installer picks up `python3.11` on Ubuntu 22.04.
- Every PostgreSQL start logged about 320 errors from migrations that had already run. PostgreSQL 13 or newer is required.
- Shutting Bambuddy down logged an error with a traceback when nothing was wrong (#3243, reported by @Thomansky).
- Running out of file descriptors could corrupt a SQLite database (#2883, reported and contributed by @M2ABRAMSTANK in #2884). Bambuddy now raises its open-file limit at start and uses a smaller SQLite pool.

**Security (dependencies)**

- `dompurify` to 3.4.17, `zod` to 4.6.7 and Vitest to 4.1.12. Bambuddy did not use the affected DOMPurify options; zod and Vitest are development-only and not in the shipped image.
- `virtualenv` to 21.14.9, `filelock` to 4.2.1 and `setuptools` to 84.1.0, all development-only and not in the shipped image.
- `pyasn1` to 0.6.5 and `minimatch` to 11.0.4. pyasn1 ships with LDAP sign-in, where crafted input could cost excessive time or memory; minimatch is development-only.

**Merged community PRs in this release**

- #1606 by @samedyuksel - assign a spool by scanning its QR label.
- #2803 by @grolmus - map each printer against its own AMS, hold jobs whose filament isn't loaded.
- #2884 by @M2ABRAMSTANK - SQLite pool and open-file limit.
- #2995 by @SVAN19 - printer locations page.
- #3119 by @akshayy29 - File Manager list column widths.
- #3207, #3208, #3209 by @adamspicedev - overlay token creation and URL import, logo and progress colours, portrait layouts.
- #3233, #3306 by @rojosinalma - lifetime energy total for REST plugs, moving folders in the File Manager.
- #3252 by @Niko11111 - Spoolman native tags.
- #3255 by @chrisbennight - Bambuddy Internal URL for Obico.
- #3265 by @simplytoast1 - Notify! with Live Activities and Lock Screen widgets.
- #3283 by @bgrr74 - remaining time in the browser tab.
- #3346 by @Thomansky - full tag UIDs on the SpoolBuddy PN5180 reader.
- #3369 by @andy-sorge - P2S printer picture.
- #3378, #3389, #3390 by @hibikipr - reliable Bambu tag reads on SpoolBuddy, Bambu tag data with each scan, Quick Add from a scanned tag.
- #3381, #3393 by @pleasantone - Orca Cloud printer lists, sorted and searchable Slice dialog presets.
- #3385 by @koshilki - custom label sheets and saved templates.
- #3413 by @oh-forf - `DB_POOL_PRE_PING`.
- #3428 by @QuinnDamerell - OctoEverywhere AI failure detection (brought in by hand).

**Thanks**

To everyone who requested, reported, captured logs, or contributed: @0xCyberFortress, @aammash, @adamspicedev, @airforceixi, @Akaviiii, @akshayy29, @Andrewdlec, @andy-sorge, @apizz, @bdwilson, @BenBBSLA, @BenjaminCs, @bgrr74, @bobobo1618, @BurgerKerman, @cartman75, @choumarin, @chrisbennight, @CLKRUN, @cmerkle, @dadino, @ddavidebor, @dogod3814t, @dovmesiz, @Eifel-Joe, @elizabethburg, @erabti, @fma965, @frederic3114, @frnzzle, @gregspatrick, @grolmus, @HarryCoff2, @hibikipr, @HopNotch, @humplemann, @hyyz17200, @iammenasco, @ImNanou, @jasoncronje, @juandelarossa, @Keybored02, @koshilki, @lightmaster, @Lioncat55, @lonix, @lukg20, @M2ABRAMSTANK, @Magical-Penguin, @mason9468, @McGagnor, @Minebuddy, @mithkr, @MorganMLGman, @Mounting9612, @MrCrunshy, @Niko11111, @Novastorm4332, @nvx, @oh-forf, @ojimpo, @pleasantone, @QuinnDamerell, @RambachTJ, @rogerabm, @rojosinalma, @Roving-Ronin, @SaadShaya7, @samedyuksel, @sargonas, @Sawtaytoes, @sean3318, @sgiffhorn, @simonkreuchauff-oss, @simplytoast1, @Spegeli, @SVAN19, @Thomansky, @Underscan007, @USER19464727, @waynem-bris, @wolfrage76, @wreuel.

---
**Sponsors**

Bambuddy is sustainable thanks to people who put their money where their use is. If this release saved you time or kept your farm running, the project runs on recurring contributions - there's no paid tier, no telemetry, no upsell, just sustainable maintenance.

- GitHub Sponsors (recurring, 5 tiers from $5/mo to $300/mo) - https://github.com/sponsors/maziggy
- Ko-fi (one-time or recurring) - https://ko-fi.com/maziggy
MartinNYHC 1 day ago
parent
commit
bb8e3a6291
100 changed files with 16982 additions and 3476 deletions
  1. 4664 0
      .basedpyright/baseline.json
  2. 1 0
      .dockerignore
  3. 1 0
      .github/ISSUE_TEMPLATE/bug_report.yml
  4. 2 0
      .github/MAINTAINERS.md
  5. 97 3
      .github/workflows/ci.yml
  6. 3 1
      .github/workflows/security.yml
  7. 3 0
      .gitignore
  8. 3 1
      .pre-commit-config.yaml
  9. 128 0
      CHANGELOG.md
  10. 71 7
      CONTRIBUTING.md
  11. 3 2
      Dockerfile
  12. 11 6
      Dockerfile.test
  13. 7 7
      README.md
  14. 7 0
      SECURITY.md
  15. 1 1
      UPDATING.md
  16. 9 3
      backend/app/api/routes/_oidc_helpers.py
  17. 47 0
      backend/app/api/routes/_spoolman_helpers.py
  18. 85 15
      backend/app/api/routes/_url_safety.py
  19. 3 36
      backend/app/api/routes/ams_history.py
  20. 2 2
      backend/app/api/routes/announcements.py
  21. 50 6
      backend/app/api/routes/api_keys.py
  22. 4 4
      backend/app/api/routes/archive_purge.py
  23. 610 110
      backend/app/api/routes/archives.py
  24. 163 67
      backend/app/api/routes/auth.py
  25. 1 1
      backend/app/api/routes/bug_report.py
  26. 275 0
      backend/app/api/routes/bulk.py
  27. 520 188
      backend/app/api/routes/camera.py
  28. 9 4
      backend/app/api/routes/camwall.py
  29. 181 68
      backend/app/api/routes/cloud.py
  30. 35 27
      backend/app/api/routes/connected_apps.py
  31. 88 13
      backend/app/api/routes/external_links.py
  32. 8 8
      backend/app/api/routes/filaments.py
  33. 34 23
      backend/app/api/routes/finance.py
  34. 24 19
      backend/app/api/routes/firmware.py
  35. 30 20
      backend/app/api/routes/github_backup.py
  36. 145 37
      backend/app/api/routes/groups.py
  37. 58 40
      backend/app/api/routes/ha_sensors.py
  38. 383 81
      backend/app/api/routes/inventory.py
  39. 71 21
      backend/app/api/routes/kprofiles.py
  40. 129 0
      backend/app/api/routes/label_templates.py
  41. 55 15
      backend/app/api/routes/labels.py
  42. 352 146
      backend/app/api/routes/library.py
  43. 8 8
      backend/app/api/routes/library_tags.py
  44. 14 14
      backend/app/api/routes/library_trash.py
  45. 16 16
      backend/app/api/routes/library_variants.py
  46. 3 1
      backend/app/api/routes/local_backup.py
  47. 9 9
      backend/app/api/routes/local_presets.py
  48. 7 7
      backend/app/api/routes/location_ha_sensors.py
  49. 98 39
      backend/app/api/routes/maintenance.py
  50. 60 44
      backend/app/api/routes/makerworld.py
  51. 382 0
      backend/app/api/routes/manyfold.py
  52. 1 1
      backend/app/api/routes/metrics.py
  53. 111 80
      backend/app/api/routes/mfa.py
  54. 13 4
      backend/app/api/routes/notification_templates.py
  55. 288 57
      backend/app/api/routes/notifications.py
  56. 23 9
      backend/app/api/routes/obico.py
  57. 97 0
      backend/app/api/routes/octoeverywhere.py
  58. 7 7
      backend/app/api/routes/orca_cloud.py
  59. 86 0
      backend/app/api/routes/overlay_branding.py
  60. 20 14
      backend/app/api/routes/pending_uploads.py
  61. 276 84
      backend/app/api/routes/pipeline_runs.py
  62. 55 13
      backend/app/api/routes/print_log.py
  63. 521 142
      backend/app/api/routes/print_queue.py
  64. 348 0
      backend/app/api/routes/printer_locations.py
  65. 3 36
      backend/app/api/routes/printer_sensor_history.py
  66. 289 240
      backend/app/api/routes/printers.py
  67. 277 383
      backend/app/api/routes/projects.py
  68. 12 5
      backend/app/api/routes/scheduled_dryings.py
  69. 348 213
      backend/app/api/routes/settings.py
  70. 6 6
      backend/app/api/routes/slice_jobs.py
  71. 7 5
      backend/app/api/routes/slicer_pipelines.py
  72. 303 40
      backend/app/api/routes/slicer_presets.py
  73. 245 190
      backend/app/api/routes/smart_plugs.py
  74. 2 2
      backend/app/api/routes/sponsor_prompt.py
  75. 408 117
      backend/app/api/routes/spoolbuddy.py
  76. 194 88
      backend/app/api/routes/spoolman.py
  77. 509 102
      backend/app/api/routes/spoolman_inventory.py
  78. 82 31
      backend/app/api/routes/support.py
  79. 20 5
      backend/app/api/routes/system.py
  80. 110 31
      backend/app/api/routes/updates.py
  81. 2 2
      backend/app/api/routes/user_notifications.py
  82. 239 20
      backend/app/api/routes/users.py
  83. 95 55
      backend/app/api/routes/virtual_printers.py
  84. 116 44
      backend/app/api/routes/webhook.py
  85. 142 19
      backend/app/api/routes/websocket.py
  86. 13 4
      backend/app/cli.py
  87. 716 217
      backend/app/core/auth.py
  88. 16 14
      backend/app/core/catalog_defaults.py
  89. 6 8
      backend/app/core/compat.py
  90. 12 2
      backend/app/core/config.py
  91. 707 58
      backend/app/core/database.py
  92. 89 0
      backend/app/core/fd_limit.py
  93. 1 2
      backend/app/core/local_config.py
  94. 214 6
      backend/app/core/logging_filters.py
  95. 19 0
      backend/app/core/permissions.py
  96. 280 0
      backend/app/core/printer_scope.py
  97. 84 0
      backend/app/core/response_redaction.py
  98. 122 0
      backend/app/core/row_visibility.py
  99. 325 0
      backend/app/core/sqlite_fk.py
  100. 153 0
      backend/app/core/static_assets.py

+ 4664 - 0
.basedpyright/baseline.json

@@ -0,0 +1,4664 @@
+{
+    "files": {
+        "./backend/app/api/routes/_spoolman_helpers.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 87,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/archives.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 8,
+                    "endColumn": 30,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 67,
+                    "endColumn": 69,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalOperand",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/auth.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/bug_report.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/camera.py": [
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 58,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 14,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 15,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 14,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportUnusedExcept",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/cloud.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/connected_apps.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/finance.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/github_backup.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/inventory.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 8,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 8,
+                    "endColumn": 9,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 8,
+                    "endColumn": 21,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 25,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/library.py": [
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 43,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 51,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 58,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 62,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalCall",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 14,
+                    "endColumn": 22,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/library_tags.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 54,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 33,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/library_trash.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/makerworld.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 98,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 82,
+                    "endColumn": 87,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 98,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/manyfold.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 82,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 84,
+                    "endColumn": 88,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/mfa.py": [
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 17,
+                    "endColumn": 22,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/notifications.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 82,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 78,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/orca_cloud.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 52,
+                    "endColumn": 68,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/pending_uploads.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/pipeline_runs.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 93,
+                    "endColumn": 100,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 95,
+                    "endColumn": 102,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/print_log.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/print_queue.py": [
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 76,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/printer_locations.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 17,
+                    "endColumn": 25,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 87,
+                    "endColumn": 95,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 52,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/printers.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 17,
+                    "endColumn": 104,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 78,
+                    "endColumn": 85,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/projects.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 20,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/settings.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 54,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 64,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 20,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 17,
+                    "endColumn": 21,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/slicer_pipelines.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/slicer_presets.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/spoolbuddy.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 13,
+                    "endColumn": 106,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/spoolman.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 81,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 85,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/spoolman_inventory.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 4,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 51,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 13,
+                    "lineCount": 6
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 92,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 13,
+                    "lineCount": 6
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 8,
+                    "endColumn": 9,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 13,
+                    "lineCount": 6
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 52,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 43,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 17,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/support.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 14,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/system.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/api/routes/webhook.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/core/auth.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/core/database.py": [
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 10,
+                    "endColumn": 18,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportInvalidTypeForm",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 25,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 67,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 54,
+                    "endColumn": 96,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 80,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/core/encryption.py": [
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 83,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/main.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 100,
+                    "endColumn": 110,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 65,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 74,
+                    "endColumn": 76,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 52,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 43,
+                    "endColumn": 45,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 43,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 45,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 78,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 82,
+                    "endColumn": 99,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 30,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 107,
+                    "endColumn": 109,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 30,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 47,
+                    "endColumn": 71,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 81,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 53,
+                    "endColumn": 75,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 69,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 61,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 69,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 53,
+                    "endColumn": 75,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/models/library.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 58,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/schemas/external_link.py": [
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 4,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/announcements.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 14,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/archive.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 102,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 58,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/archive_cost_estimate.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/bambu_ftp.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 45,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 52,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/bambu_mqtt.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 33,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 33,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalCall",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 13,
+                    "lineCount": 15
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/camera.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/discovery.py": [
+            {
+                "code": "reportOperatorIssue",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/email_service.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/energy_price.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/export.py": [
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 16,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 16,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 30,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/external_camera.py": [
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 76,
+                    "endColumn": 112,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 93,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 83,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 75,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 62,
+                    "endColumn": 77,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 59,
+                    "endColumn": 86,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 78,
+                    "endColumn": 105,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 83,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalSubscript",
+                "range": {
+                    "startColumn": 55,
+                    "endColumn": 82,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 62,
+                    "endColumn": 77,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 62,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 62,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/failure_analysis.py": [
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/finance_budget.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/git_providers/github.py": [
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/github_backup.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 54,
+                    "endColumn": 69,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/ha_sensor_manager.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/label_renderer.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/library_folder_access.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/library_trash.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 47,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 33,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/location_ha_sensor_manager.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/mesh_combine.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 65,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 92,
+                    "endColumn": 97,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 28,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 61,
+                    "endColumn": 71,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/model_providers/makerworld/service.py": [
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 47,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 51,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 78,
+                    "endColumn": 86,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 11,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 77,
+                    "endColumn": 85,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/model_providers/manyfold/service.py": [
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalIterable",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/mqtt_relay.py": [
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/mqtt_smart_plug.py": [
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPrivateImportUsage",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/notification_service.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 73,
+                    "endColumn": 80,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 91,
+                    "endColumn": 98,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 87,
+                    "endColumn": 94,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 58,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/notify_live_activities.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/notify_widgets.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/notify_worker.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 52,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 59,
+                    "endColumn": 61,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 68,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/opentag3d.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/pipeline_eligibility.py": [
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/plate_detection.py": [
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 21,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 69,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 53,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 18,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 18,
+                    "endColumn": 21,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 74,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 58,
+                    "endColumn": 61,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 22,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 40,
+                    "endColumn": 43,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 69,
+                    "endColumn": 72,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 22,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 51,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 30,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 33,
+                    "endColumn": 35,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 71,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 64,
+                    "endColumn": 67,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 19,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 33,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/plate_thumbnail.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 21,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/print_scheduler.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 73,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalOperand",
+                "range": {
+                    "startColumn": 36,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 55,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 100,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 41,
+                    "endColumn": 58,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalOperand",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 86,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 47,
+                    "endColumn": 80,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 47,
+                    "endColumn": 80,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 32,
+                    "endColumn": 65,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 83,
+                    "endColumn": 116,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 83,
+                    "endColumn": 116,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 81,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 51,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 44,
+                    "endColumn": 59,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 64,
+                    "endColumn": 79,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAssignmentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 55,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 16,
+                    "endColumn": 31,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 15,
+                    "endColumn": 23,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 49,
+                    "endColumn": 64,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 59,
+                    "endColumn": 74,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 48,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 78,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 31,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 71,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 27,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 49,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportCallIssue",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 60,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 35,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 39,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 69,
+                    "endColumn": 84,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/printer_media.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 25,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/rest_smart_plug.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 24,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 53,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/slice_preview.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/smart_plug_manager.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 54,
+                    "endColumn": 63,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 45,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 66,
+                    "endColumn": 75,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOperatorIssue",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 55,
+                    "endColumn": 70,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 63,
+                    "endColumn": 78,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 60,
+                    "endColumn": 75,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 69,
+                    "endColumn": 84,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/spoolman.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 34,
+                    "endColumn": 44,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 78,
+                    "endColumn": 88,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/spoolman_tracking.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 37,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 40,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/stl_thumbnail.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 69,
+                    "endColumn": 77,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 20,
+                    "endColumn": 28,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 70,
+                    "endColumn": 78,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 53,
+                    "endColumn": 61,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 28,
+                    "endColumn": 55,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/tasmota.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 57,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/certificate.py": [
+            {
+                "code": "reportReturnType",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 34,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/ftp_server.py": [
+            {
+                "code": "reportOptionalOperand",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalOperand",
+                "range": {
+                    "startColumn": 22,
+                    "endColumn": 36,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/manager.py": [
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 29,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 74,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 57,
+                    "endColumn": 74,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 29,
+                    "endColumn": 50,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalCall",
+                "range": {
+                    "startColumn": 19,
+                    "endColumn": 42,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalCall",
+                "range": {
+                    "startColumn": 23,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/mqtt_bridge.py": [
+            {
+                "code": "reportOptionalIterable",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 52,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalIterable",
+                "range": {
+                    "startColumn": 56,
+                    "endColumn": 66,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/mqtt_server.py": [
+            {
+                "code": "reportGeneralTypeIssues",
+                "range": {
+                    "startColumn": 26,
+                    "endColumn": 32,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/services/virtual_printer/tcp_proxy.py": [
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 41,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 24,
+                    "endColumn": 38,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 43,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOperatorIssue",
+                "range": {
+                    "startColumn": 27,
+                    "endColumn": 52,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportOptionalMemberAccess",
+                "range": {
+                    "startColumn": 50,
+                    "endColumn": 56,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportRedeclaration",
+                "range": {
+                    "startColumn": 13,
+                    "endColumn": 30,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 42,
+                    "endColumn": 47,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 37,
+                    "endColumn": 54,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/utils/archive_paths.py": [
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportAttributeAccessIssue",
+                "range": {
+                    "startColumn": 46,
+                    "endColumn": 48,
+                    "lineCount": 1
+                }
+            }
+        ],
+        "./backend/app/utils/threemf_tools.py": [
+            {
+                "code": "reportRedeclaration",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 26,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportArgumentType",
+                "range": {
+                    "startColumn": 39,
+                    "endColumn": 62,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 38,
+                    "endColumn": 46,
+                    "lineCount": 1
+                }
+            },
+            {
+                "code": "reportPossiblyUnboundVariable",
+                "range": {
+                    "startColumn": 12,
+                    "endColumn": 20,
+                    "lineCount": 1
+                }
+            }
+        ]
+    }
+}

+ 1 - 0
.dockerignore

@@ -17,6 +17,7 @@ __pycache__
 .Python
 .Python
 venv/
 venv/
 .venv/
 .venv/
+.typecheck-venv/
 ENV/
 ENV/
 env/
 env/
 .env
 .env

+ 1 - 0
.github/ISSUE_TEMPLATE/bug_report.yml

@@ -111,6 +111,7 @@ body:
         - P2S
         - P2S
         - A1
         - A1
         - A1 Mini
         - A1 Mini
+        - A2L
         - H2D
         - H2D
         - H2D Pro
         - H2D Pro
         - H2C
         - H2C

+ 2 - 0
.github/MAINTAINERS.md

@@ -38,6 +38,7 @@ Enable these rules:
 - [x] Require branches to be up to date before merging
 - [x] Require branches to be up to date before merging
 - Add these status checks (they appear after CI runs once):
 - Add these status checks (they appear after CI runs once):
   - `Backend Lint`
   - `Backend Lint`
+  - `Backend Type Check`
   - `Backend Tests`
   - `Backend Tests`
   - `Frontend Lint`
   - `Frontend Lint`
   - `Frontend Type Check`
   - `Frontend Type Check`
@@ -64,6 +65,7 @@ The CI workflow (`.github/workflows/ci.yml`) runs on:
 | Job | Purpose | Required for PR |
 | Job | Purpose | Required for PR |
 |-----|---------|-----------------|
 |-----|---------|-----------------|
 | `backend-lint` | Ruff linting + format check | Yes |
 | `backend-lint` | Ruff linting + format check | Yes |
+| `backend-typecheck` | basedpyright against the baseline (pinned env, `requirements-typecheck.lock`) | Yes |
 | `backend-tests` | Unit tests | Yes |
 | `backend-tests` | Unit tests | Yes |
 | `frontend-lint` | ESLint | Yes |
 | `frontend-lint` | ESLint | Yes |
 | `frontend-typecheck` | TypeScript compilation | Yes |
 | `frontend-typecheck` | TypeScript compilation | Yes |

+ 97 - 3
.github/workflows/ci.yml

@@ -53,6 +53,43 @@ jobs:
       - name: Run ruff format check
       - name: Run ruff format check
         run: ruff format --check backend/
         run: ruff format --check backend/
 
 
+  backend-typecheck:
+    name: Backend Type Check
+    runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Set up Python
+        uses: actions/setup-python@v6
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Install the pinned type-check environment
+        # requirements-typecheck.lock, not requirements.txt: the baseline only
+        # holds for these exact library versions (see requirements-typecheck.txt).
+        run: pip install -r requirements-typecheck.lock
+
+      - name: Check the lock covers requirements.txt
+        # A dependency added to requirements.txt but not to the lock would show
+        # up below as an unresolved import instead of saying what is wrong.
+        run: |
+          if ! out=$(pip install --dry-run -r requirements.txt 2>&1); then
+            echo "$out"
+            echo "::error::pip could not resolve requirements.txt against the type-check environment."
+            exit 1
+          fi
+          if echo "$out" | grep '^Would install'; then
+            echo "::error::requirements-typecheck.lock is missing packages from requirements.txt. Regenerate it as described in requirements-typecheck.txt."
+            exit 1
+          fi
+
+      - name: Run basedpyright
+        # Errors recorded in .basedpyright/baseline.json pass; any new one fails.
+        # Lock mode also fails when a baselined error was fixed but the smaller
+        # baseline was not committed.
+        run: basedpyright --pythonpath "$(which python)" --baselinemode=lock
+
   backend-security:
   backend-security:
     name: Backend Security
     name: Backend Security
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -72,7 +109,7 @@ jobs:
           # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
           # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
           # A fix exists, so we upgrade rather than --ignore-vuln.
           # A fix exists, so we upgrade rather than --ignore-vuln.
           python -m pip install --upgrade pip setuptools
           python -m pip install --upgrade pip setuptools
-          pip install -r requirements.txt
+          pip install -r requirements.lock
           pip install pip-audit
           pip install pip-audit
 
 
       - name: Run pip-audit
       - name: Run pip-audit
@@ -113,14 +150,15 @@ jobs:
         uses: actions/cache@v5
         uses: actions/cache@v5
         with:
         with:
           path: ~/.cache/pip
           path: ~/.cache/pip
-          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
+          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.lock', 'requirements-dev.txt') }}
           restore-keys: |
           restore-keys: |
             ${{ runner.os }}-pip-
             ${{ runner.os }}-pip-
 
 
       - name: Install dependencies
       - name: Install dependencies
         run: |
         run: |
           python -m pip install --upgrade pip
           python -m pip install --upgrade pip
-          pip install -r requirements.txt
+          # The pinned versions that ship (Docker, installers), not whatever is newest.
+          pip install -r requirements.lock
           pip install -r requirements-dev.txt
           pip install -r requirements-dev.txt
 
 
       - name: Run tests (shard ${{ matrix.shard }}/4)
       - name: Run tests (shard ${{ matrix.shard }}/4)
@@ -154,6 +192,58 @@ jobs:
             --splits 4 --group ${{ matrix.shard }} \
             --splits 4 --group ${{ matrix.shard }} \
             --durations-path tests/.test_durations
             --durations-path tests/.test_durations
 
 
+  backend-postgres-upgrade:
+    # The test suite runs on SQLite only. This builds PostgreSQL databases with
+    # older releases' code, upgrades them and compares each with a fresh
+    # install (see scripts/check_postgres_upgrade.py).
+    name: PostgreSQL Upgrade Check
+    runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
+    needs: backend-lint
+    services:
+      postgres:
+        # The version the docker-compose example and the wiki use.
+        image: postgres:16-alpine
+        env:
+          POSTGRES_PASSWORD: ci
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd pg_isready
+          --health-interval 5s
+          --health-timeout 5s
+          --health-retries 20
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          # The release tags: older releases' code builds the databases to upgrade.
+          fetch-depth: 0
+
+      - name: Set up Python
+        uses: actions/setup-python@v6
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Cache pip
+        uses: actions/cache@v5
+        with:
+          path: ~/.cache/pip
+          # Its own key: this job installs the lock only, and finishes long
+          # before the test shards, so sharing theirs would save a cache
+          # without the dev packages under their key.
+          key: ${{ runner.os }}-pip-lock-${{ hashFiles('requirements.lock') }}
+          restore-keys: |
+            ${{ runner.os }}-pip-lock-
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.lock
+
+      - name: Upgrade from older releases and compare with a fresh install
+        timeout-minutes: 10
+        run: python scripts/check_postgres_upgrade.py --url postgresql://postgres:ci@localhost:5432/postgres
+
   # ============================================================================
   # ============================================================================
   # Frontend Checks
   # Frontend Checks
   # ============================================================================
   # ============================================================================
@@ -180,6 +270,10 @@ jobs:
         working-directory: frontend
         working-directory: frontend
         run: npm run lint
         run: npm run lint
 
 
+      - name: Unused files and dependencies (knip)
+        working-directory: frontend
+        run: npm run check:knip
+
   frontend-security:
   frontend-security:
     name: Frontend Security
     name: Frontend Security
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest

+ 3 - 1
.github/workflows/security.yml

@@ -12,6 +12,7 @@ on:
       - 'Dockerfile'
       - 'Dockerfile'
       - 'docker-compose*.yml'
       - 'docker-compose*.yml'
       - 'requirements.txt'
       - 'requirements.txt'
+      - 'requirements.lock'
       - 'frontend/package*.json'
       - 'frontend/package*.json'
       - '.github/workflows/security.yml'
       - '.github/workflows/security.yml'
   pull_request:
   pull_request:
@@ -22,6 +23,7 @@ on:
       - 'Dockerfile'
       - 'Dockerfile'
       - 'docker-compose*.yml'
       - 'docker-compose*.yml'
       - 'requirements.txt'
       - 'requirements.txt'
+      - 'requirements.lock'
       - 'frontend/package*.json'
       - 'frontend/package*.json'
       - '.github/workflows/security.yml'
       - '.github/workflows/security.yml'
   workflow_dispatch:
   workflow_dispatch:
@@ -141,7 +143,7 @@ jobs:
           # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
           # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
           # A fix exists, so we upgrade rather than --ignore-vuln.
           # A fix exists, so we upgrade rather than --ignore-vuln.
           python -m pip install --upgrade pip setuptools
           python -m pip install --upgrade pip setuptools
-          pip install -r requirements.txt
+          pip install -r requirements.lock
           pip install pip-audit
           pip install pip-audit
 
 
       - name: Run pip-audit
       - name: Run pip-audit

+ 3 - 0
.gitignore

@@ -1,6 +1,8 @@
 # Claude
 # Claude
 .claude/
 .claude/
 CLAUDE.md
 CLAUDE.md
+CLAUDE.local.md
+.mcp.json
 
 
 # macOS
 # macOS
 .DS_Store
 .DS_Store
@@ -14,6 +16,7 @@ __pycache__/
 *.so
 *.so
 venv/
 venv/
 .venv/
 .venv/
+.typecheck-venv/
 env/
 env/
 .env
 .env
 docker-compose.override.yml
 docker-compose.override.yml

+ 3 - 1
.pre-commit-config.yaml

@@ -23,7 +23,9 @@ repos:
         # doesn't drift the files away from upstream.
         # doesn't drift the files away from upstream.
         exclude: ^static/
         exclude: ^static/
       - id: end-of-file-fixer
       - id: end-of-file-fixer
-        exclude: ^static/
+        # .basedpyright/baseline.json is written by basedpyright without a final
+        # newline, and rewritten by it whenever an error is fixed.
+        exclude: ^(static/|\.basedpyright/)
       - id: check-yaml
       - id: check-yaml
       - id: check-json
       - id: check-json
         exclude: ^(static/|frontend/tsconfig\.)
         exclude: ^(static/|frontend/tsconfig\.)

File diff suppressed because it is too large
+ 128 - 0
CHANGELOG.md


+ 71 - 7
CONTRIBUTING.md

@@ -109,8 +109,8 @@ Both docs repos can be edited directly in the browser, no `git clone` required:
 python3 -m venv venv
 python3 -m venv venv
 source venv/bin/activate  # On Windows: venv\Scripts\activate
 source venv/bin/activate  # On Windows: venv\Scripts\activate
 
 
-# Install dependencies
-pip install -r requirements.txt
+# Install dependencies: the pinned versions that ship (see "Dependencies" below)
+pip install -r requirements.lock
 pip install -r requirements-dev.txt  # Dev/test dependencies (pytest, ruff, bandit, etc.)
 pip install -r requirements-dev.txt  # Dev/test dependencies (pytest, ruff, bandit, etc.)
 
 
 # Install pre-commit hooks
 # Install pre-commit hooks
@@ -195,9 +195,38 @@ ruff format backend/
 ruff format --check backend/
 ruff format --check backend/
 ```
 ```
 
 
+Types are checked with [basedpyright](https://docs.basedpyright.com/) (configuration in `pyproject.toml`). Errors that existed when the check was introduced are listed in `.basedpyright/baseline.json` and pass; a new one fails the check. The check runs against the pinned library versions in `requirements-typecheck.lock`, because other versions report different errors. It needs Python 3.11 or newer on Linux, macOS on Apple Silicon, or WSL (the lock is built for Linux; on Intel Macs some pinned packages have no wheels). `./test_backend.sh` runs it for you: it creates `.typecheck-venv` on first use when Python 3.11 is available (through `uv`, or as `python3.11`), and rebuilds it whenever the lock changes. Running the commands by hand, re-run the `pip install` after a lock change:
+
+```bash
+python3 -m venv .typecheck-venv          # Python 3.11 or newer
+.typecheck-venv/bin/pip install -r requirements-typecheck.lock
+.typecheck-venv/bin/basedpyright --pythonpath .typecheck-venv/bin/python
+```
+
+- **Fixed an error from the baseline?** A local run without new errors removes it from `baseline.json`; commit that change with your fix. (With a new error in the same run, the baseline is left as it is until that error is fixed.) CI never rewrites the baseline and fails if it still lists an error that is gone.
+- **Added or changed a dependency in `requirements.txt`?** Regenerate `requirements.lock` (see [Dependencies](#dependencies)) and then the type-check lock, with the command in `requirements-typecheck.txt`, in the same PR. If that moves the pin of a package already in the lock and the check then reports errors in code you did not touch, regenerate the baseline in the same commit (see `requirements-typecheck.txt`).
+- **Moved or edited code that has a baselined error?** The baseline matches each file's errors in order by rule and column span, not by line number. Editing such a line, re-indenting it (wrapping it in `if`/`try`/`with`) or reordering functions that hold baselined errors can bring them back as new ones. Fix them while you are there.
+
+### Dependencies
+
+`requirements.txt` lists what Bambuddy needs, with lower bounds. `requirements.lock` pins the exact versions, for every supported Python (3.11+) and platform, and it is what Docker, the installers, the update scripts and CI install: everyone runs the versions that were tested. Both files change together, and a test (`test_requirements_lock.py`) fails if the lock doesn't cover `requirements.txt`.
+
+Regenerating needs [uv](https://docs.astral.sh/uv/) (`pip install uv`):
+
+```bash
+# After adding or changing a dependency (keeps every other pin)
+uv pip compile requirements.txt --universal --python-version 3.11 -o requirements.lock
+
+# Moving every package to its newest version, on purpose (e.g. before a release)
+uv pip compile requirements.txt --universal --python-version 3.11 --upgrade -o requirements.lock
+```
+
+Then regenerate the type-check lock (command in `requirements-typecheck.txt`), install the new lock into your venv, and run the full test suite before committing.
+
 ### Frontend (TypeScript/React)
 ### Frontend (TypeScript/React)
 
 
-We use ESLint for linting and TypeScript for type checking:
+We use ESLint for linting, TypeScript for type checking and knip for unused
+files and dependencies:
 
 
 ```bash
 ```bash
 cd frontend
 cd frontend
@@ -207,8 +236,17 @@ npm run lint
 
 
 # Type check
 # Type check
 npm run typecheck
 npm run typecheck
+
+# Unused files and dependencies
+npm run check:knip
 ```
 ```
 
 
+knip follows imports from the entry points (`index.html`, the Vite and Vitest
+configs, and `public/sw.js`, which the browser loads by URL). A file nothing
+imports, or a package nothing uses, fails the check: delete it, or add it to
+`frontend/knip.jsonc` with a comment saying why if something loads it in a way
+knip can't see.
+
 ### Pre-commit Hooks
 ### Pre-commit Hooks
 
 
 Pre-commit hooks run automatically on `git commit` and include Ruff linting/formatting, trailing whitespace fixes, YAML/JSON validation, and import shadowing checks. To run manually:
 Pre-commit hooks run automatically on `git commit` and include Ruff linting/formatting, trailing whitespace fixes, YAML/JSON validation, and import shadowing checks. To run manually:
@@ -319,8 +357,8 @@ reprint eligibility. Archive reprint still needs `queue:create` before it can en
 The easiest way to run tests is with the provided scripts in the project root:
 The easiest way to run tests is with the provided scripts in the project root:
 
 
 ```bash
 ```bash
-./test_frontend.sh    # TypeScript check + ESLint + Vitest
-./test_backend.sh     # Ruff lint/format + pytest (parallel)
+./test_frontend.sh    # TypeScript check + ESLint + knip + Vitest
+./test_backend.sh     # Ruff lint/format + type check (if .typecheck-venv exists) + PostgreSQL upgrade check (if Docker is available) + pytest (parallel)
 ./test_docker.sh      # Full Docker build, unit tests, and integration tests
 ./test_docker.sh      # Full Docker build, unit tests, and integration tests
 ./test_all.sh         # All of the above (frontend → backend → docker)
 ./test_all.sh         # All of the above (frontend → backend → docker)
 ./test_security.sh    # Security scans (bandit, pip-audit, npm-audit)
 ./test_security.sh    # Security scans (bandit, pip-audit, npm-audit)
@@ -347,6 +385,32 @@ against — it exercises code paths that open their own sessions, and some of th
 write. Don't undo that override to "test against real data": point `DATABASE_URL`
 write. Don't undo that override to "test against real data": point `DATABASE_URL`
 at a copy instead.
 at a copy instead.
 
 
+### Database migrations (PostgreSQL)
+
+The test suite runs on SQLite only. `scripts/check_postgres_upgrade.py` covers
+PostgreSQL: it builds a database with older releases' code (the first release
+with PostgreSQL support and the newest stable one), upgrades it with the current
+code twice, and compares its schema with a fresh install. `test_backend.sh`
+runs it in a throwaway `postgres:16-alpine` container when Docker is available
+and the clone has the release tags (`git fetch --tags`); CI runs it too.
+
+A fresh install gets every table and column straight from the models, while an
+existing database only gets what the migrations in `backend/app/core/database.py`
+add. So a new column, constraint or index needs a migration that builds it
+exactly as the model does, including nullability and default; the check fails
+when an upgraded database ends up different.
+
+A migration that only fails with data in the database, such as a constraint
+that existing rows violate, gets a scenario in `SCENARIOS` in the same script:
+a fixed chain of old releases to build the database, rows to add, and a query
+that must find nothing wrong after the upgrade.
+
+`scripts/postgres_schema_drift.txt` can list known drift, each line an object
+and exactly how it differs; a listed object that starts to differ in another
+way fails like new drift, and so does a line that no longer drifts. The drift
+found when the check was introduced has been fixed and the list is empty: fix
+new drift with a migration rather than listing it.
+
 **Frontend** — tests use [Vitest](https://vitest.dev/) and are in `frontend/src/__tests__/`:
 **Frontend** — tests use [Vitest](https://vitest.dev/) and are in `frontend/src/__tests__/`:
 
 
 ```bash
 ```bash
@@ -360,8 +424,8 @@ npm run test:coverage  # With coverage
 
 
 Pull requests trigger automated CI checks via GitHub Actions (`.github/workflows/ci.yml`):
 Pull requests trigger automated CI checks via GitHub Actions (`.github/workflows/ci.yml`):
 
 
-- **Backend**: Ruff lint + format check, unit/integration tests, pip-audit
-- **Frontend**: ESLint, TypeScript type check, Vitest tests, production build
+- **Backend**: Ruff lint + format check, basedpyright type check (against the baseline), unit/integration tests, PostgreSQL upgrade check, pip-audit
+- **Frontend**: ESLint, knip (unused files and dependencies), TypeScript type check, Vitest tests, production build
 - **Docker**: Full image build, backend/frontend tests in Docker, integration health checks
 - **Docker**: Full image build, backend/frontend tests in Docker, integration health checks
 - **Security**: CodeQL analysis, dependency audits
 - **Security**: CodeQL analysis, dependency audits
 
 

+ 3 - 2
Dockerfile

@@ -52,10 +52,11 @@ RUN setcap cap_net_bind_service=+ep "$(readlink -f /usr/local/bin/python3)"
 # pip is upgraded to >=26.1 first to close CVE-2026-6357 — the python:3.13-slim
 # pip is upgraded to >=26.1 first to close CVE-2026-6357 — the python:3.13-slim
 # base image ships pip 26.0.1, which runs its self-update check after installing
 # base image ships pip 26.0.1, which runs its self-update check after installing
 # wheels (so a hostile wheel could hijack stdlib imports during install).
 # wheels (so a hostile wheel could hijack stdlib imports during install).
-COPY requirements.txt ./
+# requirements.lock pins the exact versions that were tested (see CONTRIBUTING.md).
+COPY requirements.txt requirements.lock ./
 RUN --mount=type=cache,target=/root/.cache/pip \
 RUN --mount=type=cache,target=/root/.cache/pip \
     pip install --root-user-action=ignore --upgrade 'pip>=26.1.2' \
     pip install --root-user-action=ignore --upgrade 'pip>=26.1.2' \
- && pip install --root-user-action=ignore -r requirements.txt
+ && pip install --root-user-action=ignore -r requirements.lock
 
 
 # Copy backend
 # Copy backend
 COPY backend/ ./backend/
 COPY backend/ ./backend/

+ 11 - 6
Dockerfile.test

@@ -5,8 +5,10 @@ FROM python:3.13-slim AS backend-test
 WORKDIR /app
 WORKDIR /app
 
 
 # Install system dependencies for testing
 # Install system dependencies for testing
+# procps: pkill, which a camera test uses to clean up its fake ffmpeg.
 RUN apt-get update && apt-get install -y --no-install-recommends \
 RUN apt-get update && apt-get install -y --no-install-recommends \
     curl \
     curl \
+    procps \
     && rm -rf /var/lib/apt/lists/*
     && rm -rf /var/lib/apt/lists/*
 
 
 # Install Python dependencies including test dependencies.
 # Install Python dependencies including test dependencies.
@@ -14,10 +16,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
 # cache so the second build only does install work — the slow ~60-90s
 # cache so the second build only does install work — the slow ~60-90s
 # fetch step from the first build becomes ~5s. Requires DOCKER_BUILDKIT=1
 # fetch step from the first build becomes ~5s. Requires DOCKER_BUILDKIT=1
 # (already set in test_docker.sh).
 # (already set in test_docker.sh).
-COPY requirements.txt ./
+COPY requirements.txt requirements.lock ./
 COPY requirements-dev.txt ./
 COPY requirements-dev.txt ./
+# Two installs, as in CI: the lock pins the newest ruff (requirements.txt only
+# has a floor) and requirements-dev.txt pins the one CI enforces, so a single
+# install would have two exact pins for ruff and fail. The second one wins.
 RUN --mount=type=cache,target=/root/.cache/pip \
 RUN --mount=type=cache,target=/root/.cache/pip \
-    pip install -r requirements.txt -r requirements-dev.txt
+    pip install -r requirements.lock && pip install -r requirements-dev.txt
 
 
 # Copy backend code
 # Copy backend code
 COPY backend/ ./backend/
 COPY backend/ ./backend/
@@ -41,10 +46,10 @@ HEALTHCHECK NONE
 # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat noticeable
 # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat noticeable
 # stdout I/O time and clutter test_docker.sh output. --tb=short still
 # stdout I/O time and clutter test_docker.sh output. --tb=short still
 # gives full failure tracebacks when something breaks.
 # gives full failure tracebacks when something breaks.
-# -n auto adapts to the host's vCPU count instead of hard-coding 30 —
-# on a 2-vCPU CI / VM runner, -n 30 spawns 30 Python processes fighting
-# for 2 cores, which is mostly IPC + import-thrash overhead.
-CMD ["pytest", "backend/tests/", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"]
+# -n 30: the test host's worker count. Not -n auto (with psutil installed
+# that counts physical cores only, 16) nor -n logical (the Docker host's 32
+# CPUs, more than this machine is meant to use).
+CMD ["pytest", "backend/tests/", "--tb=short", "-p", "no:cacheprovider", "-n", "30"]
 
 
 # -------------------------------------------
 # -------------------------------------------
 # Frontend test stage
 # Frontend test stage

+ 7 - 7
README.md

@@ -242,12 +242,12 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - **Per-filament humidity threshold** — Set a different humidity trigger per filament type (e.g. Nylon at 20%, PLA at 60%, ASA at 30%) instead of one global value. Mixed-material AMS units use the most-restrictive threshold across the loaded spools so a single PLA + Nylon unit triggers at Nylon's level. Drives both the auto-drying scheduler and the hourly humidity alarm so the two can never disagree on whether a unit is "too humid"
 - **Per-filament humidity threshold** — Set a different humidity trigger per filament type (e.g. Nylon at 20%, PLA at 60%, ASA at 30%) instead of one global value. Mixed-material AMS units use the most-restrictive threshold across the loaded spools so a single PLA + Nylon unit triggers at Nylon's level. Drives both the auto-drying scheduler and the hourly humidity alarm so the two can never disagree on whether a unit is "too humid"
 - Dual external spool support for H2D (Ext-L / Ext-R)
 - Dual external spool support for H2D (Ext-L / Ext-R)
 - **Filament Track Switch inlet binding** — Shows which AMS feeds which FTS inlet, and K-profiles follow the inlet
 - **Filament Track Switch inlet binding** — Shows which AMS feeds which FTS inlet, and K-profiles follow the inlet
-- **HMS error monitoring with one-click actions** — Live HMS error log with history and the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me action buttons BambuStudio shows. Click and the matching MQTT command goes back to the printer — no more walking to the device just to dismiss a paused-print dialog. Catalog covers every Bambu model (X1 / P1 / A1 / H2 series); buttons are translated in all 15 supported languages
+- **HMS error monitoring with one-click actions** — Live HMS error log with history and the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me action buttons BambuStudio shows. Click and the matching MQTT command goes back to the printer — no more walking to the device just to dismiss a paused-print dialog. Catalog covers every Bambu model (X1 / P1 / A1 / H2 series); buttons are translated in all 16 supported languages
 - **Heater history charts** — Bambuddy logs nozzle, bed, and chamber readings every minute and surfaces them via a tiny chart icon on each heater tile in the printer card. Click for a per-heater modal with current / average / min / max stats, target overlay, and a 6h / 24h / 48h / 7d time range — works on read-only chamber sensors (X1C / P2S) too. AMS humidity and temperature get the same treatment (already shipped).
 - **Heater history charts** — Bambuddy logs nozzle, bed, and chamber readings every minute and surfaces them via a tiny chart icon on each heater tile in the printer card. Click for a per-heater modal with current / average / min / max stats, target overlay, and a 6h / 24h / 48h / 7d time range — works on read-only chamber sensors (X1C / P2S) too. AMS humidity and temperature get the same treatment (already shipped).
 - Print success rates & trends
 - Print success rates & trends
 - Filament usage tracking
 - Filament usage tracking
 - Cost analytics & failure analysis
 - Cost analytics & failure analysis
-- **AI print-failure detection** — Optional integration with a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) ML API: watches each running print's camera feed, smooths scores over time (30-frame warmup + EWM + rolling means), and fires a configurable action once per print (notify / pause / pause-and-off)
+- **AI print-failure detection** — Choose a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) ML API or [OctoEverywhere cloud detection](https://docs.octoeverywhere.com/ai-failure-detection-apis/overview/) with a Gadget API key. Watches running prints through their cameras and supports notifications, pausing, or pausing and turning off linked smart plugs. Obico smooths scores locally; OctoEverywhere provides print quality and warning/pause decisions.
 - Per-user statistics filtering (admin permission gated)
 - Per-user statistics filtering (admin permission gated)
 - Statistics grouped by your own material number
 - Statistics grouped by your own material number
 - CSV/Excel export
 - CSV/Excel export
@@ -409,7 +409,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Debug logging toggle with live indicator
 - Debug logging toggle with live indicator
 - Live application log viewer with filtering
 - Live application log viewer with filtering
 - Support bundle generator with comprehensive diagnostics (privacy-filtered)
 - Support bundle generator with comprehensive diagnostics (privacy-filtered)
-- **In-app bug reporting** — Submit bug reports directly from the UI with optional screenshot (upload, paste, or drag & drop), interactive debug log capture (start logging, reproduce at your own pace, stop & submit), and system info. Reports create GitHub issues via a secure relay. Privacy-first: all logs are sanitized and sensitive data (IPs, serials, credentials) is never included.
+- **In-app bug reporting** — Submit bug reports directly from the UI with optional screenshot (upload, paste, or drag & drop), interactive debug log capture (start logging, reproduce at your own pace, stop, then review and submit or discard), and system info. Reports create GitHub issues via a secure relay. Privacy-first: all logs are sanitized and sensitive data (IPs, serials, credentials) is never included.
 
 
 ### 🔒 Optional Authentication
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time
 - Enable/disable authentication any time
@@ -438,7 +438,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 </tr>
 </tr>
 </table>
 </table>
 
 
-**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • 15 languages (EN/DE/ES/FR/IT/JA/KO/NL/PT-BR/RU/SV/TR/UK/ZH-CN/ZH-TW) • Auto updates • Database backup/restore • System info dashboard
+**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • 16 languages (EN/DE/ES/FR/IT/JA/KO/NL/PL/PT-BR/RU/SV/TR/UK/ZH-CN/ZH-TW) • Auto updates • Database backup/restore • System info dashboard
 
 
 ---
 ---
 
 
@@ -599,7 +599,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 ## 🚀 Quick Start
 ## 🚀 Quick Start
 
 
 ### Requirements
 ### Requirements
-- Python 3.10+ (3.11/3.12 recommended)
+- Python 3.11+ (3.12 or 3.13 recommended)
 - Bambu Lab printer with **Developer Mode** enabled (see below)
 - Bambu Lab printer with **Developer Mode** enabled (see below)
 - **"Store sent files on external storage"** enabled in Bambu Studio/OrcaSlicer
 - **"Store sent files on external storage"** enabled in Bambu Studio/OrcaSlicer
 - Same local network as printer
 - Same local network as printer
@@ -757,7 +757,7 @@ git clone https://github.com/maziggy/bambuddy.git
 cd bambuddy
 cd bambuddy
 python3 -m venv venv
 python3 -m venv venv
 source venv/bin/activate
 source venv/bin/activate
-pip install -r requirements.txt
+pip install -r requirements.lock
 
 
 # Run (--loop asyncio avoids a uvloop TLS bug that can truncate VP FTP uploads)
 # Run (--loop asyncio avoids a uvloop TLS bug that can truncate VP FTP uploads)
 uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --loop asyncio
 uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --loop asyncio
@@ -831,7 +831,7 @@ Full documentation available at **[wiki.bambuddy.cool](http://wiki.bambuddy.cool
 |-----------|------------|
 |-----------|------------|
 | Backend | Python, FastAPI, SQLAlchemy |
 | Backend | Python, FastAPI, SQLAlchemy |
 | Frontend | React, TypeScript, Tailwind CSS |
 | Frontend | React, TypeScript, Tailwind CSS |
-| Database | SQLite (default) or PostgreSQL |
+| Database | SQLite (default) or PostgreSQL 13+ |
 | 3D Viewer | Three.js (models), libvgcode (G-code preview) |
 | 3D Viewer | Three.js (models), libvgcode (G-code preview) |
 | Communication | MQTT (TLS), FTPS |
 | Communication | MQTT (TLS), FTPS |
 
 

+ 7 - 0
SECURITY.md

@@ -123,6 +123,13 @@ line; CI fails otherwise. (We use a standalone marker rather than
 `# noqa: ...` because ruff reserves the latter syntax for its own
 `# noqa: ...` because ruff reserves the latter syntax for its own
 error codes.)
 error codes.)
 
 
+One bounded exception: an open camera stream re-checks its viewer 30
+seconds after the last check that allowed it. Once a check is due, no
+frame goes out until it has run. If it cannot be read (it raises),
+frames continue for at most 5 seconds from when it began; then a second
+check runs (at once, if the first took longer), and the stream ends
+unless that one allows it.
+
 ### 3. No hardcoded fallback secrets
 ### 3. No hardcoded fallback secrets
 
 
 Production secrets (JWT signing keys, encryption keys, OAuth client
 Production secrets (JWT signing keys, encryption keys, OAuth client

+ 1 - 1
UPDATING.md

@@ -58,7 +58,7 @@ cd /opt/bambuddy
 sudo systemctl stop bambuddy
 sudo systemctl stop bambuddy
 sudo -u bambuddy git fetch origin
 sudo -u bambuddy git fetch origin
 sudo -u bambuddy git reset --hard origin/main
 sudo -u bambuddy git reset --hard origin/main
-sudo -u bambuddy venv/bin/pip install -r requirements.txt
+sudo -u bambuddy venv/bin/pip install -r requirements.lock
 sudo systemctl start bambuddy
 sudo systemctl start bambuddy
 ```
 ```
 
 

+ 9 - 3
backend/app/api/routes/_oidc_helpers.py

@@ -16,8 +16,9 @@ from urllib.parse import urlparse
 from backend.app.api.routes._url_safety import (
 from backend.app.api.routes._url_safety import (
     CLOUD_METADATA_HOSTNAMES,
     CLOUD_METADATA_HOSTNAMES,
     CLOUD_METADATA_IPS,
     CLOUD_METADATA_IPS,
-    NUMERIC_IP_RE,
+    is_numeric_encoded_ip,
     unwrap_ipv4_mapped,
     unwrap_ipv4_mapped,
+    url_hostname,
 )
 )
 
 
 
 
@@ -54,17 +55,22 @@ def assert_safe_public_https_url(url: str) -> None:
     if parsed.scheme.lower() != "https":
     if parsed.scheme.lower() != "https":
         raise ValueError("icon URL must use https://")
         raise ValueError("icon URL must use https://")
 
 
-    hostname = (parsed.hostname or "").lower()
+    hostname = url_hostname(parsed)
 
 
     # "https:///path" parses to an empty hostname; without this it reaches the
     # "https:///path" parses to an empty hostname; without this it reaches the
     # ip_address() ValueError branch and is accepted as a symbolic hostname.
     # ip_address() ValueError branch and is accepted as a symbolic hostname.
     if not hostname:
     if not hostname:
         raise ValueError("icon URL must include a hostname")
         raise ValueError("icon URL must include a hostname")
 
 
+    # HTTP clients map other dot characters and fullwidth forms to ASCII
+    # before they connect: a host the checks below cannot read is refused.
+    if not hostname.isascii():
+        raise ValueError("icon URL must use an ASCII host name (punycode, xn--..., for international names)")
+
     if hostname in CLOUD_METADATA_HOSTNAMES:
     if hostname in CLOUD_METADATA_HOSTNAMES:
         raise ValueError("icon URL must not point to a cloud metadata endpoint")
         raise ValueError("icon URL must not point to a cloud metadata endpoint")
 
 
-    if NUMERIC_IP_RE.match(hostname):
+    if is_numeric_encoded_ip(hostname):
         raise ValueError("icon URL must not use numeric-encoded IP addresses")
         raise ValueError("icon URL must not use numeric-encoded IP addresses")
 
 
     try:
     try:

+ 47 - 0
backend/app/api/routes/_spoolman_helpers.py

@@ -46,6 +46,10 @@ class MappedSpoolFields(TypedDict):
     note: str | None
     note: str | None
     added_full: None
     added_full: None
     last_used: str | None
     last_used: str | None
+    # Last drying (#2863), stored under the bambu_last_dried_* extra keys.
+    last_dried_at: str | None
+    last_dried_temp: int | None
+    last_dried_hours: float | None
     encode_time: str | None
     encode_time: str | None
     tag_uid: str | None
     tag_uid: str | None
     tray_uuid: str | None
     tray_uuid: str | None
@@ -55,6 +59,10 @@ class MappedSpoolFields(TypedDict):
     created_at: str | None  # None when Spoolman spool has no registered timestamp
     created_at: str | None  # None when Spoolman spool has no registered timestamp
     updated_at: str | None
     updated_at: str | None
     cost_per_kg: float | None
     cost_per_kg: float | None
+    # What prints are charged per kg: the spool's own price, else its
+    # filament's catalogue price (SM-C8). Display only; the form edits
+    # cost_per_kg, so the catalogue price is never written onto the spool.
+    effective_cost_per_kg: float | None
     # Spoolman's native filament.article_number, surfaced as the internal
     # Spoolman's native filament.article_number, surfaced as the internal
     # material number (#2870). Read-only in Spoolman mode — the number is
     # material number (#2870). Read-only in Spoolman mode — the number is
     # filament-level there and maintained in Spoolman itself.
     # filament-level there and maintained in Spoolman itself.
@@ -236,6 +244,15 @@ def spoolman_cost_per_kg(spool: dict) -> float | None:
     return spoolman_price_to_cost_per_kg(spool.get("price"), spoolman_price_weight(spool))
     return spoolman_price_to_cost_per_kg(spool.get("price"), spoolman_price_weight(spool))
 
 
 
 
+def _charged_cost_per_kg(spool: dict) -> float | None:
+    """The per-kg rate a print off this spool is charged at, from the same
+    function the charge uses, so the Inventory shows what prints cost."""
+    from backend.app.services.spoolman_tracking import _spool_cost_per_gram
+
+    per_gram = _spool_cost_per_gram(spool)
+    return round(per_gram * 1000.0, 4) if per_gram is not None else None
+
+
 def _extract_extra_str(extra: dict, key: str) -> str:
 def _extract_extra_str(extra: dict, key: str) -> str:
     """Extract a JSON-encoded string from a Spoolman extra dict.
     """Extract a JSON-encoded string from a Spoolman extra dict.
 
 
@@ -262,6 +279,14 @@ def _extract_extra_str(extra: dict, key: str) -> str:
 BAMBU_WEIGHT_USED_BASELINE_KEY = "bambu_weight_used_baseline"
 BAMBU_WEIGHT_USED_BASELINE_KEY = "bambu_weight_used_baseline"
 
 
 
 
+# Spool.extra keys holding when the spool was last dried, the target °C and the
+# hours the cycle ran (#2863). Written by services/spool_drying.py and the
+# spool update route; an empty string means unknown or cleared.
+BAMBU_LAST_DRIED_AT_KEY = "bambu_last_dried_at"
+BAMBU_LAST_DRIED_TEMP_KEY = "bambu_last_dried_temp"
+BAMBU_LAST_DRIED_HOURS_KEY = "bambu_last_dried_hours"
+
+
 def _extract_extra_float(extra: dict, key: str) -> float | None:
 def _extract_extra_float(extra: dict, key: str) -> float | None:
     """Extract a JSON-encoded number from a Spoolman extra dict.
     """Extract a JSON-encoded number from a Spoolman extra dict.
 
 
@@ -324,6 +349,11 @@ def parse_spoolman_multi_colors(filament: dict) -> list[str]:
     return [cleaned for token in tokens if (cleaned := token.strip().lstrip("#"))]
     return [cleaned for token in tokens if (cleaned := token.strip().lstrip("#"))]
 
 
 
 
+def _last_dried_temp(extra: dict) -> int | None:
+    value = _extract_extra_float(extra, BAMBU_LAST_DRIED_TEMP_KEY)
+    return int(round(value)) if value is not None and value > 0 else None
+
+
 def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
 def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
     """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
 
 
@@ -359,6 +389,16 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     _raw_is_hex = bool(_TAG_HEX_RE.match(raw_tag))
     _raw_is_hex = bool(_TAG_HEX_RE.match(raw_tag))
     tag_uid = raw_tag if _raw_is_hex and 8 <= len(raw_tag) <= 30 else None
     tag_uid = raw_tag if _raw_is_hex and 8 <= len(raw_tag) <= 30 else None
     tray_uuid = raw_tag if _raw_is_hex and len(raw_tag) == 32 else None
     tray_uuid = raw_tag if _raw_is_hex and len(raw_tag) == 32 else None
+    # Spoolman 0.27+ native tags fill whichever of the two extra.tag left empty,
+    # so a spool linked there shows as tagged here as well.
+    for native in spool.get("tags") or []:
+        uid = str(native.get("uid") or "").upper()
+        if not _TAG_HEX_RE.match(uid):
+            continue
+        if len(uid) == 32:
+            tray_uuid = tray_uuid or uid
+        elif 8 <= len(uid) <= 30:
+            tag_uid = tag_uid or uid
 
 
     # Subtype = filament name with material prefix stripped
     # Subtype = filament name with material prefix stripped
     material: str = (filament.get("material") or "").strip()
     material: str = (filament.get("material") or "").strip()
@@ -496,6 +536,12 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "note": spool.get("comment") or None,
         "note": spool.get("comment") or None,
         "added_full": None,
         "added_full": None,
         "last_used": spool.get("last_used"),
         "last_used": spool.get("last_used"),
+        # When the spool was first printed with; the stock forecast spreads
+        # its consumption from here (#3407).
+        "first_used": spool.get("first_used"),
+        "last_dried_at": _extract_extra_str(extra, BAMBU_LAST_DRIED_AT_KEY) or None,
+        "last_dried_temp": _last_dried_temp(extra),
+        "last_dried_hours": _extract_extra_float(extra, BAMBU_LAST_DRIED_HOURS_KEY),
         # encode_time semantics differ: local records NFC write time; Spoolman first_used
         # encode_time semantics differ: local records NFC write time; Spoolman first_used
         # records first print use — different events; using first_used as best available proxy.
         # records first print use — different events; using first_used as best available proxy.
         "encode_time": spool.get("first_used"),
         "encode_time": spool.get("first_used"),
@@ -509,6 +555,7 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "updated_at": created_at,
         "updated_at": created_at,
         # Spoolman's spool price is what the whole spool cost (#3194).
         # Spoolman's spool price is what the whole spool cost (#3194).
         "cost_per_kg": spoolman_cost_per_kg(spool),
         "cost_per_kg": spoolman_cost_per_kg(spool),
+        "effective_cost_per_kg": _charged_cost_per_kg(spool),
         # Spoolman's filament.article_number maps 1:1 onto the internal
         # Spoolman's filament.article_number maps 1:1 onto the internal
         # material number (#2870): both identify the purchasable product.
         # material number (#2870): both identify the purchasable product.
         # Trimmed for the same reason the schema validator trims the internal
         # Trimmed for the same reason the schema validator trims the internal

+ 85 - 15
backend/app/api/routes/_url_safety.py

@@ -25,10 +25,13 @@ from __future__ import annotations
 
 
 import ipaddress
 import ipaddress
 import re
 import re
-from urllib.parse import urlparse
+from urllib.parse import ParseResult, urlparse
 
 
 # Cloud-provider metadata endpoints — the classic SSRF credential-exfil
 # Cloud-provider metadata endpoints — the classic SSRF credential-exfil
-# targets. Both guards reject these unconditionally.
+# targets. Both guards reject these unconditionally. The documented endpoints
+# of the major providers, so best effort by nature. Those inside
+# 169.254.0.0/16 are also covered by the LAN guard's link-local refusal;
+# 100.100.100.200 and the IPv6 ones are refused only through this list.
 CLOUD_METADATA_IPS = frozenset(
 CLOUD_METADATA_IPS = frozenset(
     {
     {
         # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
         # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
@@ -37,6 +40,21 @@ CLOUD_METADATA_IPS = frozenset(
         ipaddress.ip_address("100.100.100.200"),
         ipaddress.ip_address("100.100.100.200"),
         # AWS IMDS IPv6
         # AWS IMDS IPv6
         ipaddress.ip_address("fd00:ec2::254"),
         ipaddress.ip_address("fd00:ec2::254"),
+        # Tencent Cloud
+        ipaddress.ip_address("169.254.0.23"),
+        # Scaleway
+        ipaddress.ip_address("169.254.42.42"),
+        ipaddress.ip_address("fd00:42::42"),
+        # Oracle Cloud IMDS IPv6
+        ipaddress.ip_address("fd00:c1::a9fe:a9fe"),
+        # GCP metadata server on IPv6 VMs
+        ipaddress.ip_address("fd20:ce::254"),
+        # Akamai / Linode metadata service IPv6
+        ipaddress.ip_address("fd00:a9fe:a9fe::1"),
+        # AWS EKS Pod Identity Agent IPv6 (its IPv4 address is link-local)
+        ipaddress.ip_address("fd00:ec2::23"),
+        # OpenStack metadata service on IPv6 networks
+        ipaddress.ip_address("fe80::a9fe:a9fe"),
     }
     }
 )
 )
 
 
@@ -49,21 +67,53 @@ CLOUD_METADATA_HOSTNAMES = frozenset(
     {
     {
         "metadata.google.internal",  # GCP
         "metadata.google.internal",  # GCP
         "metadata.goog",  # GCP short form
         "metadata.goog",  # GCP short form
+        "metadata.google",  # GCP
+        # A bare "metadata" is completed to metadata.google.internal by the
+        # resolver's search domain on a GCP VM.
+        "metadata",
+        "instance-data",  # AWS, through the search domain
+        "instance-data.ec2.internal",  # AWS
+        "metadata.tencentyun.com",  # Tencent Cloud
+        "api.metadata.cloud.ibm.com",  # IBM Cloud VPC
     }
     }
 )
 )
 
 
 
 
-# libc and browsers parse numeric-encoded IP forms (decimal ``2130706433``
-# for 127.0.0.1, hex ``0x7f000001``) but Python's ``ipaddress.ip_address``
-# raises ValueError on these, so they slip past the IP-class checks if
-# not caught first. Used by both guards to reject up-front.
-NUMERIC_IP_RE = re.compile(r"^(0x[0-9a-f]+|[0-9]+)$", re.I)
+# libc and browsers parse numeric-encoded IP forms (decimal ``2130706433`` for
+# 127.0.0.1, hex ``0x7f000001``, dotted hex or octal, short forms such as
+# ``127.1``) but Python's ``ipaddress`` raises ValueError on them, so they slip
+# past the IP-class checks if not caught first. Used by both guards to reject
+# them up front, see ``is_numeric_encoded_ip``. One label of such an address
+# is decimal, octal or hex.
+_NUMERIC_LABEL_RE = re.compile(r"^(0x[0-9a-f]*|[0-9]+)$", re.I)
+
+
+def url_hostname(parsed: ParseResult) -> str:
+    """The URL's hostname as resolvers see it: lower case, no trailing dot."""
+    return (parsed.hostname or "").lower().rstrip(".")
+
+
+def is_numeric_encoded_ip(hostname: str) -> bool:
+    """Whether *hostname* is an IPv4 address in a notation other than plain
+    dotted decimal: one number, dotted hex or octal, a leading zero, or fewer
+    than four parts. Resolvers read all of them as an address; the checks on
+    addresses only see standard notation.
+    """
+    labels = hostname.split(".")
+    if not all(_NUMERIC_LABEL_RE.match(label) for label in labels):
+        return False
+    try:
+        ipaddress.IPv4Address(hostname)
+    except ValueError:
+        return True
+    return False
 
 
 
 
 def unwrap_ipv4_mapped(
 def unwrap_ipv4_mapped(
     addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
     addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
 ) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
 ) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
-    """Return the underlying IPv4 for an IPv4-mapped IPv6 address, else return *addr*.
+    """Return the underlying IPv4 for an IPv4-mapped IPv6 address, *addr* without
+    its zone id, else *addr*.
 
 
     ``::ffff:127.0.0.1`` and similar mapped forms must be unwrapped before
     ``::ffff:127.0.0.1`` and similar mapped forms must be unwrapped before
     the per-class checks (``is_private``, ``is_loopback``, …) — otherwise
     the per-class checks (``is_private``, ``is_loopback``, …) — otherwise
@@ -72,6 +122,10 @@ def unwrap_ipv4_mapped(
     """
     """
     if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
     if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
         return addr.ipv4_mapped
         return addr.ipv4_mapped
+    # A zone id is ignored on connect to an address that is not link-local,
+    # but it makes the address compare unequal to the same one without it.
+    if isinstance(addr, ipaddress.IPv6Address) and addr.scope_id is not None:
+        return ipaddress.IPv6Address(str(addr).split("%", 1)[0])
     return addr
     return addr
 
 
 
 
@@ -93,14 +147,17 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
       ``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
       ``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
       about returning a clear validation error at configuration time rather
       about returning a clear validation error at configuration time rather
       than an opaque failure at delivery time.
       than an opaque failure at delivery time.
-    - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) —
-      libc and browsers resolve these, but Python's ``ipaddress`` raises
-      ValueError on them, so they would slip past the checks below.
+    - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``,
+      dotted hex or octal, short forms) — libc and browsers resolve these,
+      but Python's ``ipaddress`` raises ValueError on them, so they would
+      slip past the checks below. Host names not written in ASCII, which
+      HTTP clients map to plain ones before connecting, likewise.
     - Cloud-provider metadata endpoints — the high-value SSRF target, and
     - Cloud-provider metadata endpoints — the high-value SSRF target, and
-      never a legitimate destination for any of these services.
+      never a legitimate destination for any of these services — and the
+      whole IPv4 link-local range they live in.
     - Multicast and unspecified addresses — pointless as a destination and
     - Multicast and unspecified addresses — pointless as a destination and
       indicative of misuse.
       indicative of misuse.
-    - IPv4-mapped IPv6 encodings of any of the above.
+    - IPv4-mapped IPv6 encodings and zone ids of any of the above.
 
 
     Symbolic hostnames are otherwise accepted without DNS resolution, matching
     Symbolic hostnames are otherwise accepted without DNS resolution, matching
     the public-internet guard: resolution here would be both a TOCTOU (DNS can
     the public-internet guard: resolution here would be both a TOCTOU (DNS can
@@ -112,7 +169,7 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
     if parsed.scheme.lower() not in ("http", "https"):
     if parsed.scheme.lower() not in ("http", "https"):
         raise ValueError(f"{label} must use http or https")
         raise ValueError(f"{label} must use http or https")
 
 
-    hostname = (parsed.hostname or "").lower()
+    hostname = url_hostname(parsed)
 
 
     # "http:///path" parses to an empty hostname. Never a valid destination,
     # "http:///path" parses to an empty hostname. Never a valid destination,
     # and without this it falls through the ip_address() ValueError branch
     # and without this it falls through the ip_address() ValueError branch
@@ -120,10 +177,16 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
     if not hostname:
     if not hostname:
         raise ValueError(f"{label} must include a hostname")
         raise ValueError(f"{label} must include a hostname")
 
 
+    # HTTP clients map other dot characters, fullwidth digits and similar
+    # forms to plain ASCII before they connect, so a host the checks below
+    # cannot read is refused rather than let through as a name.
+    if not hostname.isascii():
+        raise ValueError(f"{label} must use an ASCII host name (punycode, xn--..., for international names)")
+
     if hostname in CLOUD_METADATA_HOSTNAMES:
     if hostname in CLOUD_METADATA_HOSTNAMES:
         raise ValueError(f"{label} must not point to a cloud metadata endpoint")
         raise ValueError(f"{label} must not point to a cloud metadata endpoint")
 
 
-    if NUMERIC_IP_RE.match(hostname):
+    if is_numeric_encoded_ip(hostname):
         raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
         raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
 
 
     try:
     try:
@@ -138,3 +201,10 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
 
 
     if effective.is_multicast or effective.is_unspecified:
     if effective.is_multicast or effective.is_unspecified:
         raise ValueError(f"{label} must not point to a multicast or unspecified address")
         raise ValueError(f"{label} must not point to a multicast or unspecified address")
+
+    # IPv4 link-local is where cloud providers put their metadata endpoints,
+    # listed above or not; a LAN service has no reason to live there. IPv6
+    # link-local and ULA stay reachable, as real LAN devices use them: the
+    # metadata addresses there are refused one by one through the list.
+    if isinstance(effective, ipaddress.IPv4Address) and effective.is_link_local:
+        raise ValueError(f"{label} must not point to a link-local address (169.254.x.x)")

+ 3 - 36
backend/app/api/routes/ams_history.py

@@ -7,7 +7,7 @@ from pydantic import BaseModel
 from sqlalchemy import and_, func, select
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePrinterPermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.ams_history import AMSSensorHistory
@@ -40,8 +40,8 @@ async def get_ams_history(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
     hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
 ):
     """Get AMS sensor history for a specific printer and AMS unit."""
     """Get AMS sensor history for a specific printer and AMS unit."""
     since = datetime.now(timezone.utc) - timedelta(hours=hours)
     since = datetime.now(timezone.utc) - timedelta(hours=hours)
@@ -102,36 +102,3 @@ async def get_ams_history(
         max_temperature=stats.max_temp,
         max_temperature=stats.max_temp,
         avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp is not None else None,
         avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp is not None else None,
     )
     )
-
-
-@router.delete("/{printer_id}")
-async def delete_old_history(
-    printer_id: int,
-    days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
-):
-    """Delete old AMS history data for a printer."""
-    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
-
-    result = await db.execute(
-        select(func.count(AMSSensorHistory.id)).where(
-            and_(
-                AMSSensorHistory.printer_id == printer_id,
-                AMSSensorHistory.recorded_at < cutoff,
-            )
-        )
-    )
-    count = result.scalar()
-
-    await db.execute(
-        AMSSensorHistory.__table__.delete().where(
-            and_(
-                AMSSensorHistory.printer_id == printer_id,
-                AMSSensorHistory.recorded_at < cutoff,
-            )
-        )
-    )
-    await db.commit()
-
-    return {"deleted": count, "message": f"Deleted {count} records older than {days} days"}

+ 2 - 2
backend/app/api/routes/announcements.py

@@ -39,7 +39,7 @@ async def _may_see(db: AsyncSession, user: User | None) -> bool:
 
 
 @router.get("")
 @router.get("")
 async def list_announcements(
 async def list_announcements(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_auth_if_enabled),
     current_user: User | None = Depends(require_auth_if_enabled),
 ) -> dict:
 ) -> dict:
     """Whether this user may see announcements, and the live ones newest first,
     """Whether this user may see announcements, and the live ones newest first,
@@ -55,7 +55,7 @@ async def list_announcements(
 @router.post("/{public_id}/read", status_code=status.HTTP_204_NO_CONTENT)
 @router.post("/{public_id}/read", status_code=status.HTTP_204_NO_CONTENT)
 async def mark_announcement_read(
 async def mark_announcement_read(
     public_id: str,
     public_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_auth_if_enabled),
     current_user: User | None = Depends(require_auth_if_enabled),
 ) -> None:
 ) -> None:
     if not await _may_see(db, current_user):
     if not await _may_see(db, current_user):

+ 50 - 6
backend/app/api/routes/api_keys.py

@@ -1,13 +1,18 @@
 import logging
 import logging
+from datetime import datetime, timezone
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select
+from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.api.routes.notifications import forget_app_senders
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import ensure_printers_exist
+from backend.app.core.websocket import ws_manager
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
+from backend.app.models.auth_ephemeral import AuthEphemeralToken
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.api_key import (
 from backend.app.schemas.api_key import (
     APIKeyCreate,
     APIKeyCreate,
@@ -23,7 +28,7 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"])
 
 
 @router.get("/", response_model=list[APIKeyResponse])
 @router.get("/", response_model=list[APIKeyResponse])
 async def list_api_keys(
 async def list_api_keys(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
 ):
 ):
     """List all API keys (without full key values)."""
     """List all API keys (without full key values)."""
@@ -34,7 +39,7 @@ async def list_api_keys(
 @router.post("/", response_model=APIKeyCreateResponse)
 @router.post("/", response_model=APIKeyCreateResponse)
 async def create_api_key(
 async def create_api_key(
     data: APIKeyCreate,
     data: APIKeyCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
 ):
 ):
     """Create a new API key.
     """Create a new API key.
@@ -52,6 +57,8 @@ async def create_api_key(
             detail="can_access_cloud requires authentication to be enabled (per-user cloud tokens)",
             detail="can_access_cloud requires authentication to be enabled (per-user cloud tokens)",
         )
         )
 
 
+    await ensure_printers_exist(db, data.printer_ids)
+
     # Generate the key
     # Generate the key
     full_key, key_hash, key_prefix = generate_api_key()
     full_key, key_hash, key_prefix = generate_api_key()
 
 
@@ -107,7 +114,7 @@ async def create_api_key(
 @router.get("/{key_id}", response_model=APIKeyResponse)
 @router.get("/{key_id}", response_model=APIKeyResponse)
 async def get_api_key(
 async def get_api_key(
     key_id: int,
     key_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
 ):
 ):
     """Get an API key by ID."""
     """Get an API key by ID."""
@@ -124,7 +131,7 @@ async def get_api_key(
 async def update_api_key(
 async def update_api_key(
     key_id: int,
     key_id: int,
     data: APIKeyUpdate,
     data: APIKeyUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
 ):
 ):
     """Update an API key."""
     """Update an API key."""
@@ -167,6 +174,7 @@ async def update_api_key(
     if data.can_send_notifications is not None:
     if data.can_send_notifications is not None:
         api_key.can_send_notifications = data.can_send_notifications
         api_key.can_send_notifications = data.can_send_notifications
     if data.printer_ids is not None:
     if data.printer_ids is not None:
+        await ensure_printers_exist(db, data.printer_ids)
         api_key.printer_ids = data.printer_ids
         api_key.printer_ids = data.printer_ids
     if data.enabled is not None:
     if data.enabled is not None:
         api_key.enabled = data.enabled
         api_key.enabled = data.enabled
@@ -175,14 +183,44 @@ async def update_api_key(
 
 
     await db.flush()
     await db.flush()
     await db.refresh(api_key)
     await db.refresh(api_key)
+    if not api_key.enabled or (
+        api_key.expires_at is not None
+        and api_key.expires_at.replace(tzinfo=api_key.expires_at.tzinfo or timezone.utc) < datetime.now(timezone.utc)
+    ):
+        # Its camera and WebSocket tokens go with it; the scope refresh below
+        # closes its open dashboards.
+        await drop_api_key_tokens(db, [api_key.id])
+    if (
+        data.printer_ids is not None
+        or data.enabled is not None
+        or data.expires_at is not None
+        or data.can_read_status is not None
+    ):
+        # Sockets opened with this key may now see fewer printers, or none
+        # (#1727), or not connect at all (can_read_status grants
+        # websocket:connect). Committed first so the refresh reads the new row.
+        await db.commit()
+        await ws_manager.refresh_printer_scopes()
 
 
     return api_key
     return api_key
 
 
 
 
+async def drop_api_key_tokens(db: AsyncSession, key_ids) -> None:
+    """Delete the camera and WebSocket tokens deleted keys minted (media tokens
+    never record the key).
+
+    They name the key by id, which SQLite gives to the next key created: a
+    token of a key limited to one printer would get the new key's printers.
+    """
+    key_ids = list(key_ids)
+    if key_ids:
+        await db.execute(delete(AuthEphemeralToken).where(AuthEphemeralToken.api_key_id.in_(key_ids)))
+
+
 @router.delete("/{key_id}")
 @router.delete("/{key_id}")
 async def delete_api_key(
 async def delete_api_key(
     key_id: int,
     key_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
 ):
 ):
     """Delete (revoke) an API key."""
     """Delete (revoke) an API key."""
@@ -193,5 +231,11 @@ async def delete_api_key(
         raise HTTPException(status_code=404, detail="API key not found")
         raise HTTPException(status_code=404, detail="API key not found")
 
 
     await db.delete(api_key)
     await db.delete(api_key)
+    await drop_api_key_tokens(db, [key_id])
+    await db.commit()
+    # Its sockets are closed, not just given no printers: their scope is looked
+    # up by key id, which SQLite gives to the next key created.
+    await ws_manager.close_api_keys([key_id])
+    forget_app_senders([key_id])
 
 
     return {"message": "API key deleted"}
     return {"message": "API key deleted"}

+ 4 - 4
backend/app/api/routes/archive_purge.py

@@ -48,7 +48,7 @@ async def preview_archive_purge(
             "hard-delete). #1390."
             "hard-delete). #1390."
         ),
         ),
     ),
     ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
 ):
     """Count + size of archives eligible for purge. Read-only."""
     """Count + size of archives eligible for purge. Read-only."""
@@ -59,7 +59,7 @@ async def preview_archive_purge(
 @router.post("/purge", response_model=ArchivePurgeResponse)
 @router.post("/purge", response_model=ArchivePurgeResponse)
 async def execute_archive_purge(
 async def execute_archive_purge(
     body: ArchivePurgeRequest,
     body: ArchivePurgeRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
 ):
     """Bulk-delete archives older than the threshold.
     """Bulk-delete archives older than the threshold.
@@ -78,7 +78,7 @@ async def execute_archive_purge(
 
 
 @router.get("/purge/settings", response_model=ArchivePurgeSettings)
 @router.get("/purge/settings", response_model=ArchivePurgeSettings)
 async def get_archive_purge_settings(
 async def get_archive_purge_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
 ):
     cfg = await archive_purge_service.get_settings(db)
     cfg = await archive_purge_service.get_settings(db)
@@ -88,7 +88,7 @@ async def get_archive_purge_settings(
 @router.put("/purge/settings", response_model=ArchivePurgeSettings)
 @router.put("/purge/settings", response_model=ArchivePurgeSettings)
 async def update_archive_purge_settings(
 async def update_archive_purge_settings(
     body: ArchivePurgeSettings,
     body: ArchivePurgeSettings,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
 ):
     if body.days < MIN_AUTO_PURGE_DAYS or body.days > MAX_AUTO_PURGE_DAYS:
     if body.days < MIN_AUTO_PURGE_DAYS or body.days > MAX_AUTO_PURGE_DAYS:

File diff suppressed because it is too large
+ 610 - 110
backend/app/api/routes/archives.py


+ 163 - 67
backend/app/api/routes/auth.py

@@ -8,7 +8,7 @@ import jwt as _jwt
 from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, Response, status
 from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, Response, status
 from fastapi.security import HTTPAuthorizationCredentials
 from fastapi.security import HTTPAuthorizationCredentials
 from jwt.exceptions import PyJWTError
 from jwt.exceptions import PyJWTError
-from sqlalchemy import delete, select
+from sqlalchemy import delete, or_, select
 from sqlalchemy.exc import SQLAlchemyError
 from sqlalchemy.exc import SQLAlchemyError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
@@ -18,6 +18,7 @@ from backend.app.core.auth import (
     ALGORITHM,
     ALGORITHM,
     SECRET_KEY,
     SECRET_KEY,
     Permission,
     Permission,
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     _is_token_fresh,
     _is_token_fresh,
     _validate_api_key,
     _validate_api_key,
@@ -27,6 +28,7 @@ from backend.app.core.auth import (
     create_access_token,
     create_access_token,
     create_media_token,
     create_media_token,
     create_websocket_token,
     create_websocket_token,
+    current_api_key_if_present,
     get_current_active_user,
     get_current_active_user,
     get_password_hash,
     get_password_hash,
     get_user_by_email,
     get_user_by_email,
@@ -37,9 +39,11 @@ from backend.app.core.auth import (
     resolve_session_max_minutes,
     resolve_session_max_minutes,
     revoke_jti,
     revoke_jti,
     security,
     security,
+    token_outlived_its_session,
 )
 )
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
 from backend.app.core.oidc_env import env_bool
 from backend.app.core.oidc_env import env_bool
+from backend.app.models.api_key import APIKey
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.group import Group
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -53,6 +57,7 @@ from backend.app.schemas.auth import (
     GroupBrief,
     GroupBrief,
     LDAPProvisionRequest,
     LDAPProvisionRequest,
     LDAPSearchResultResponse,
     LDAPSearchResultResponse,
+    LDAPTestRequest,
     LoginRequest,
     LoginRequest,
     LoginResponse,
     LoginResponse,
     ResetPasswordRequest,
     ResetPasswordRequest,
@@ -256,7 +261,7 @@ async def set_setup_completed(db: AsyncSession, completed: bool) -> None:
 
 
 
 
 @router.post("/setup", response_model=SetupResponse)
 @router.post("/setup", response_model=SetupResponse)
-async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
+async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db, scope="function")):
     """First-time setup: enable/disable authentication and create admin user."""
     """First-time setup: enable/disable authentication and create admin user."""
     import logging
     import logging
 
 
@@ -373,7 +378,23 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
         # Set auth enabled and mark setup as completed
         # Set auth enabled and mark setup as completed
         await set_auth_enabled(db, request.auth_enabled)
         await set_auth_enabled(db, request.auth_enabled)
         await set_setup_completed(db, True)
         await set_setup_completed(db, True)
+        if request.auth_enabled:
+            # Tokens minted while it was off name nobody (every tab mints a
+            # WebSocket one): they would reconnect the dashboards closed below.
+            await db.execute(
+                delete(AuthEphemeralToken).where(
+                    or_(AuthEphemeralToken.username.is_(None), AuthEphemeralToken.username == ""),
+                    AuthEphemeralToken.api_key_id.is_(None),
+                    AuthEphemeralToken.token_type != "revoked_jti",
+                )
+            )
         await db.commit()
         await db.commit()
+        if request.auth_enabled:
+            # Dashboards opened while it was off have every printer and would
+            # keep them. Closed, such a tab re-checks auth and goes to the login.
+            from backend.app.core.websocket import ws_manager
+
+            await ws_manager.close_anonymous()
 
 
         if admin_created:
         if admin_created:
             await db.refresh(admin_user)
             await db.refresh(admin_user)
@@ -393,7 +414,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.get("/status")
 @router.get("/status")
-async def get_auth_status(db: AsyncSession = Depends(get_db)):
+async def get_auth_status(db: AsyncSession = Depends(get_db, scope="function")):
     """Get authentication status (public endpoint)."""
     """Get authentication status (public endpoint)."""
     auth_enabled = await is_auth_enabled(db)
     auth_enabled = await is_auth_enabled(db)
     setup_completed = await is_setup_completed(db)
     setup_completed = await is_setup_completed(db)
@@ -405,7 +426,7 @@ async def get_auth_status(db: AsyncSession = Depends(get_db)):
 @router.post("/disable", response_model=dict)
 @router.post("/disable", response_model=dict)
 async def disable_auth(
 async def disable_auth(
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Disable authentication (admin only)."""
     """Disable authentication (admin only)."""
     import logging
     import logging
@@ -446,7 +467,12 @@ async def disable_auth(
 
 
 
 
 @router.post("/login", response_model=LoginResponse)
 @router.post("/login", response_model=LoginResponse)
-async def login(raw_request: Request, request: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
+async def login(
+    raw_request: Request,
+    request: LoginRequest,
+    response: Response,
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
     """Login and get access token.
     """Login and get access token.
 
 
     Supports username or email-based login. Username lookup is case-insensitive.
     Supports username or email-based login. Username lookup is case-insensitive.
@@ -644,6 +670,7 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
 @router.post("/ws-token")
 @router.post("/ws-token")
 async def mint_websocket_token(
 async def mint_websocket_token(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.WEBSOCKET_CONNECT),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.WEBSOCKET_CONNECT),
+    api_key: APIKey | None = Depends(current_api_key_if_present),
 ):
 ):
     """Mint a short-lived token for ``/api/v1/ws`` connections (GHSA-r2qv follow-up).
     """Mint a short-lived token for ``/api/v1/ws`` connections (GHSA-r2qv follow-up).
 
 
@@ -661,7 +688,11 @@ async def mint_websocket_token(
     passes via the standard allowlist (``can_read_status`` covers it).
     passes via the standard allowlist (``can_read_status`` covers it).
     """
     """
     username = current_user.username if current_user is not None else None
     username = current_user.username if current_user is not None else None
-    return {"token": await create_websocket_token(username)}
+    # The socket only carries the printers its minter may see (#1727)
+    api_key_id = api_key.id if api_key is not None else None
+    # The key's prefix binds the token to this key (SQLite reuses a deleted key's id)
+    api_key_prefix = api_key.key_prefix if api_key is not None else None
+    return {"token": await create_websocket_token(username, api_key_id, api_key_prefix)}
 
 
 
 
 @router.post("/media-token")
 @router.post("/media-token")
@@ -692,7 +723,7 @@ async def mint_media_token(
 async def get_current_user_info(
 async def get_current_user_info(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Get current user information.
     """Get current user information.
 
 
@@ -824,18 +855,34 @@ async def logout(
 @router.post("/smtp/test", response_model=TestSMTPResponse)
 @router.post("/smtp/test", response_model=TestSMTPResponse)
 async def test_smtp_connection(
 async def test_smtp_connection(
     test_request: TestSMTPRequest,
     test_request: TestSMTPRequest,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
-    """Test SMTP connection using saved settings (admin only when auth enabled)."""
+    """Test SMTP connection using the form's settings, else the saved ones (admin only when auth enabled).
+
+    An empty password keeps the saved one, which only goes to the server it
+    was entered for.
+    """
     import logging
     import logging
 
 
+    from backend.app.services.secret_fields import SMTP_DESTINATIONS, same_value
+
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
 
 
     try:
     try:
-        smtp_settings = await get_smtp_settings(db)
+        stored = await get_smtp_settings(db)
+        smtp_settings = test_request.smtp or stored
         if not smtp_settings:
         if not smtp_settings:
             return TestSMTPResponse(success=False, message="SMTP settings not configured. Save SMTP settings first.")
             return TestSMTPResponse(success=False, message="SMTP settings not configured. Save SMTP settings first.")
+        if test_request.smtp and smtp_settings.smtp_auth_enabled and not smtp_settings.smtp_password:
+            if stored and stored.smtp_password:
+                moved = [f for f in SMTP_DESTINATIONS if not same_value(getattr(smtp_settings, f), getattr(stored, f))]
+                if moved:
+                    return TestSMTPResponse(
+                        success=False, message=f"Enter the SMTP password again: {', '.join(moved)} changed"
+                    )
+                smtp_settings = smtp_settings.model_copy(update={"smtp_password": stored.smtp_password})
 
 
         # Send test email
         # Send test email
         send_email(
         send_email(
@@ -856,7 +903,7 @@ async def test_smtp_connection(
 @router.get("/smtp", response_model=SMTPSettings | None)
 @router.get("/smtp", response_model=SMTPSettings | None)
 async def get_smtp_config(
 async def get_smtp_config(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Get SMTP settings (admin only when auth enabled). Password is not returned."""
     """Get SMTP settings (admin only when auth enabled). Password is not returned."""
     smtp_settings = await get_smtp_settings(db)
     smtp_settings = await get_smtp_settings(db)
@@ -869,16 +916,40 @@ async def get_smtp_config(
 @router.post("/smtp", response_model=dict)
 @router.post("/smtp", response_model=dict)
 async def save_smtp_config(
 async def save_smtp_config(
     smtp_settings: SMTPSettings,
     smtp_settings: SMTPSettings,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
-    """Save SMTP settings (admin only when auth enabled)."""
+    """Save SMTP settings (admin only when auth enabled).
+
+    An empty password keeps the stored one, which only goes to the server it
+    was entered for: a changed host, port or security needs it entered again
+    (without authentication, the stored password is dropped instead).
+    """
     import logging
     import logging
 
 
+    from backend.app.services.secret_fields import SMTP_DESTINATIONS, same_value
+
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
 
 
+    drop_password = False
+    stored = await get_smtp_settings(db)
+    if stored and stored.smtp_password and not smtp_settings.smtp_password:
+        moved = [f for f in SMTP_DESTINATIONS if not same_value(getattr(smtp_settings, f), getattr(stored, f))]
+        if moved and smtp_settings.smtp_auth_enabled:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=f"Enter the SMTP password again: {', '.join(moved)} changed",
+            )
+        drop_password = bool(moved)
+
     try:
     try:
         await save_smtp_settings(db, smtp_settings)
         await save_smtp_settings(db, smtp_settings)
+        if drop_password:
+            from backend.app.core.db_dialect import upsert_setting
+            from backend.app.models.settings import Settings
+
+            await upsert_setting(db, Settings, "smtp_password", "")
         await db.commit()
         await db.commit()
         logger.info(f"SMTP settings updated by admin user: {current_user.username if current_user else 'anonymous'}")
         logger.info(f"SMTP settings updated by admin user: {current_user.username if current_user else 'anonymous'}")
         return {"message": "SMTP settings saved successfully"}
         return {"message": "SMTP settings saved successfully"}
@@ -894,7 +965,7 @@ async def save_smtp_config(
 @router.post("/advanced-auth/enable", response_model=dict)
 @router.post("/advanced-auth/enable", response_model=dict)
 async def enable_advanced_auth(
 async def enable_advanced_auth(
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Enable advanced authentication (admin only).
     """Enable advanced authentication (admin only).
 
 
@@ -939,7 +1010,7 @@ async def enable_advanced_auth(
 @router.post("/advanced-auth/disable", response_model=dict)
 @router.post("/advanced-auth/disable", response_model=dict)
 async def disable_advanced_auth(
 async def disable_advanced_auth(
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Disable advanced authentication (admin only)."""
     """Disable advanced authentication (admin only)."""
     import logging
     import logging
@@ -971,7 +1042,7 @@ async def disable_advanced_auth(
 
 
 
 
 @router.get("/advanced-auth/status")
 @router.get("/advanced-auth/status")
-async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
+async def get_advanced_auth_status(db: AsyncSession = Depends(get_db, scope="function")):
     """Get advanced authentication status.
     """Get advanced authentication status.
 
 
     Surfaces ``local_login_enabled`` and ``autologin_provider_id`` (#1589)
     Surfaces ``local_login_enabled`` and ``autologin_provider_id`` (#1589)
@@ -1062,7 +1133,7 @@ async def forgot_password(
     request: ForgotPasswordRequest,
     request: ForgotPasswordRequest,
     background_tasks: BackgroundTasks,
     background_tasks: BackgroundTasks,
     raw_request: Request,
     raw_request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Request password reset via email (advanced auth only).
     """Request password reset via email (advanced auth only).
 
 
@@ -1203,7 +1274,9 @@ async def forgot_password(
 
 
 
 
 @router.post("/forgot-password/confirm", response_model=ForgotPasswordResponse)
 @router.post("/forgot-password/confirm", response_model=ForgotPasswordResponse)
-async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: AsyncSession = Depends(get_db)):
+async def forgot_password_confirm(
+    request: ForgotPasswordConfirmRequest, db: AsyncSession = Depends(get_db, scope="function")
+):
     """Complete a password reset by supplying the token from the reset email.
     """Complete a password reset by supplying the token from the reset email.
 
 
     H-6: Atomically consumes the single-use token (DELETE…RETURNING) and sets
     H-6: Atomically consumes the single-use token (DELETE…RETURNING) and sets
@@ -1217,15 +1290,17 @@ async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: Asy
             AuthEphemeralToken.token == request.token,
             AuthEphemeralToken.token == request.token,
             AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
             AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
         )
         )
-        .returning(AuthEphemeralToken.username, AuthEphemeralToken.expires_at)
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.expires_at, AuthEphemeralToken.created_at)
     )
     )
     row = result.one_or_none()
     row = result.one_or_none()
     await db.commit()
     await db.commit()
     if row is None:
     if row is None:
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
 
 
-    username, expires_at = row
-    # SQLite returns naive datetimes; treat them as UTC.
+    username, expires_at, minted_at = row
+    if await token_outlived_its_session(db, username, minted_at):
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
+    # Stored as naive UTC on both databases; treat it as UTC.
     if expires_at.tzinfo is None:
     if expires_at.tzinfo is None:
         expires_at = expires_at.replace(tzinfo=timezone.utc)
         expires_at = expires_at.replace(tzinfo=timezone.utc)
     if now > expires_at:
     if now > expires_at:
@@ -1238,7 +1313,17 @@ async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: Asy
 
 
     user.password_hash = get_password_hash(request.new_password)
     user.password_hash = get_password_hash(request.new_password)
     user.password_changed_at = now  # M-R7-B: invalidate all prior JWTs
     user.password_changed_at = now  # M-R7-B: invalidate all prior JWTs
+    # The WebSocket and camera tokens too: reusable for an hour, they would
+    # reconnect a closed dashboard straight away.
+    from backend.app.api.routes.users import drop_pending_tokens
+
+    await drop_pending_tokens(db, user.username)
     await db.commit()
     await db.commit()
+    # Open dashboards are never signed in again: one someone else had open on
+    # the account would keep receiving its printers.
+    from backend.app.core.websocket import ws_manager
+
+    await ws_manager.close_user(user.id)
     _logger.info("Password reset completed for user '%s'", username)
     _logger.info("Password reset completed for user '%s'", username)
 
 
     return ForgotPasswordResponse(message="Password has been reset successfully.")
     return ForgotPasswordResponse(message="Password has been reset successfully.")
@@ -1249,7 +1334,7 @@ async def reset_user_password(
     request: ResetPasswordRequest,
     request: ResetPasswordRequest,
     background_tasks: BackgroundTasks,
     background_tasks: BackgroundTasks,
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Reset a user's password and send them an email (admin only, advanced auth only)."""
     """Reset a user's password and send them an email (admin only, advanced auth only)."""
     # Reload user with groups for proper is_admin check
     # Reload user with groups for proper is_admin check
@@ -1352,22 +1437,24 @@ async def reset_user_password(
 # LDAP Authentication Helpers
 # LDAP Authentication Helpers
 
 
 
 
+LDAP_SETTING_KEYS = (
+    "ldap_enabled",
+    "ldap_server_url",
+    "ldap_bind_dn",
+    "ldap_bind_password",
+    "ldap_search_base",
+    "ldap_user_filter",
+    "ldap_security",
+    "ldap_group_mapping",
+    "ldap_auto_provision",
+    "ldap_ca_cert_path",
+    "ldap_default_group",
+)
+
+
 async def _get_ldap_settings(db: AsyncSession) -> dict[str, str] | None:
 async def _get_ldap_settings(db: AsyncSession) -> dict[str, str] | None:
     """Get LDAP settings from the database. Returns None if LDAP is not enabled."""
     """Get LDAP settings from the database. Returns None if LDAP is not enabled."""
-    ldap_keys = [
-        "ldap_enabled",
-        "ldap_server_url",
-        "ldap_bind_dn",
-        "ldap_bind_password",
-        "ldap_search_base",
-        "ldap_user_filter",
-        "ldap_security",
-        "ldap_group_mapping",
-        "ldap_auto_provision",
-        "ldap_ca_cert_path",
-        "ldap_default_group",
-    ]
-    result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
+    result = await db.execute(select(Settings).where(Settings.key.in_(LDAP_SETTING_KEYS)))
     settings = {s.key: s.value for s in result.scalars().all()}
     settings = {s.key: s.value for s in result.scalars().all()}
     if settings.get("ldap_enabled", "false").lower() != "true":
     if settings.get("ldap_enabled", "false").lower() != "true":
         return None
         return None
@@ -1466,47 +1553,52 @@ async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config)
 
 
     current_group_ids = {g.id for g in user.groups}
     current_group_ids = {g.id for g in user.groups}
     new_group_ids = {g.id for g in new_groups}
     new_group_ids = {g.id for g in new_groups}
-    if current_group_ids != new_group_ids:
+    groups_changed = current_group_ids != new_group_ids
+    if groups_changed:
         user.groups = new_groups
         user.groups = new_groups
         changed = True
         changed = True
 
 
     if changed:
     if changed:
         await db.commit()
         await db.commit()
         logger.info("Synced LDAP user attributes: %s", user.username)
         logger.info("Synced LDAP user attributes: %s", user.username)
+        if groups_changed:
+            # The user's open dashboards may now see other printers (#1727)
+            from backend.app.core.websocket import ws_manager
+
+            await ws_manager.refresh_printer_scopes()
 
 
 
 
 @router.post("/ldap/test")
 @router.post("/ldap/test")
 async def test_ldap(
 async def test_ldap(
+    body: LDAPTestRequest | None = None,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
-    """Test LDAP connection using saved settings (admin only when auth enabled)."""
+    """Test the LDAP connection with the form's settings over the saved ones (admin only when auth enabled).
+
+    Works while LDAP is still off. An empty bind password keeps the saved one,
+    which only goes to the server it was entered for.
+    """
     import logging
     import logging
 
 
     from backend.app.services.ldap_service import parse_ldap_config, test_ldap_connection
     from backend.app.services.ldap_service import parse_ldap_config, test_ldap_connection
+    from backend.app.services.secret_fields import SETTINGS_SECRETS, SecretConfigError, merge_partial
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
 
 
-    ldap_settings = await _get_ldap_settings(db)
-    if not ldap_settings:
-        # LDAP might not be enabled yet but settings might still exist — read all keys
-        ldap_keys = [
-            "ldap_enabled",
-            "ldap_server_url",
-            "ldap_bind_dn",
-            "ldap_bind_password",
-            "ldap_search_base",
-            "ldap_user_filter",
-            "ldap_security",
-            "ldap_group_mapping",
-            "ldap_auto_provision",
-        ]
-        result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
-        ldap_settings = {s.key: s.value for s in result.scalars().all()}
-        # Force enabled for test
-        ldap_settings["ldap_enabled"] = "true"
+    result = await db.execute(select(Settings).where(Settings.key.in_(LDAP_SETTING_KEYS)))
+    # Unset security is StartTLS, as when saving.
+    stored = {"ldap_security": "starttls", **{s.key: s.value for s in result.scalars().all()}}
+    form = body.model_dump(exclude_none=True) if body else {}
+    if not form.get("ldap_bind_password"):
+        form.pop("ldap_bind_password", None)
+    try:
+        form = merge_partial(stored, form, {"ldap_bind_password": SETTINGS_SECRETS["ldap_bind_password"]})
+    except SecretConfigError as e:
+        return {"success": False, "message": str(e)}
 
 
-    config = parse_ldap_config(ldap_settings)
+    config = parse_ldap_config({**stored, **form, "ldap_enabled": "true"})
     if not config:
     if not config:
         return {"success": False, "message": "LDAP server URL is not configured"}
         return {"success": False, "message": "LDAP server URL is not configured"}
 
 
@@ -1519,7 +1611,7 @@ async def test_ldap(
 
 
 
 
 @router.get("/ldap/status")
 @router.get("/ldap/status")
-async def get_ldap_status(db: AsyncSession = Depends(get_db)):
+async def get_ldap_status(db: AsyncSession = Depends(get_db, scope="function")):
     """Get LDAP authentication status."""
     """Get LDAP authentication status."""
     # Only fetch the minimum keys needed — never load secrets
     # Only fetch the minimum keys needed — never load secrets
     ldap_keys = ["ldap_enabled", "ldap_server_url"]
     ldap_keys = ["ldap_enabled", "ldap_server_url"]
@@ -1542,8 +1634,9 @@ async def get_ldap_status(db: AsyncSession = Depends(get_db)):
 @router.get("/ldap/search", response_model=list[LDAPSearchResultResponse])
 @router.get("/ldap/search", response_model=list[LDAPSearchResultResponse])
 async def search_ldap_directory(
 async def search_ldap_directory(
     q: str,
     q: str,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Search the LDAP directory for users matching `q`.
     """Search the LDAP directory for users matching `q`.
 
 
@@ -1615,8 +1708,9 @@ async def search_ldap_directory(
 @router.post("/ldap/provision", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/ldap/provision", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 async def provision_ldap_user(
 async def provision_ldap_user(
     payload: LDAPProvisionRequest,
     payload: LDAPProvisionRequest,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Provision a BamBuddy user from an existing LDAP directory entry.
     """Provision a BamBuddy user from an existing LDAP directory entry.
 
 
@@ -1626,7 +1720,7 @@ async def provision_ldap_user(
     login path (`_provision_ldap_user`), so behavior stays identical regardless
     login path (`_provision_ldap_user`), so behavior stays identical regardless
     of whether the user was created here or on first login.
     of whether the user was created here or on first login.
 
 
-    Requires USERS_CREATE.
+    Admin-only with USERS_CREATE, like users.create_user.
     """
     """
     from sqlalchemy import func as sa_func
     from sqlalchemy import func as sa_func
 
 
@@ -1728,8 +1822,9 @@ def _long_lived_token_to_response(record, *, plaintext: str | None = None) -> di
 @router.post("/tokens", response_model=dict, status_code=status.HTTP_201_CREATED)
 @router.post("/tokens", response_model=dict, status_code=status.HTTP_201_CREATED)
 async def create_long_lived_camera_token(
 async def create_long_lived_camera_token(
     payload: dict,
     payload: dict,
+    response: Response,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Mint a long-lived camera-stream token (#1108).
     """Mint a long-lived camera-stream token (#1108).
 
 
@@ -1786,6 +1881,7 @@ async def create_long_lived_camera_token(
         scope,
         scope,
         created.record.expires_at.isoformat(),
         created.record.expires_at.isoformat(),
     )
     )
+    response.headers["Cache-Control"] = "no-store"
     return _long_lived_token_to_response(created.record, plaintext=created.plaintext)
     return _long_lived_token_to_response(created.record, plaintext=created.plaintext)
 
 
 
 
@@ -1793,7 +1889,7 @@ async def create_long_lived_camera_token(
 async def list_long_lived_tokens(
 async def list_long_lived_tokens(
     user_id: int | None = None,
     user_id: int | None = None,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """List long-lived tokens.
     """List long-lived tokens.
 
 
@@ -1831,7 +1927,7 @@ async def list_long_lived_tokens(
 @router.get("/tokens/all", response_model=list[dict])
 @router.get("/tokens/all", response_model=list[dict])
 async def list_all_long_lived_tokens(
 async def list_all_long_lived_tokens(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Admin-only: every active long-lived token in the system, newest first.
     """Admin-only: every active long-lived token in the system, newest first.
     Used by the leak-triage view in admin settings.
     Used by the leak-triage view in admin settings.
@@ -1856,7 +1952,7 @@ async def list_all_long_lived_tokens(
 async def revoke_long_lived_token(
 async def revoke_long_lived_token(
     token_id: int,
     token_id: int,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Revoke a long-lived token. Owners can revoke their own; admins any."""
     """Revoke a long-lived token. Owners can revoke their own; admins any."""
     from backend.app.models.long_lived_token import LongLivedToken
     from backend.app.models.long_lived_token import LongLivedToken
@@ -1895,7 +1991,7 @@ async def revoke_long_lived_token(
 @router.get("/encryption-status", response_model=EncryptionStatusResponse)
 @router.get("/encryption-status", response_model=EncryptionStatusResponse)
 async def get_encryption_status(
 async def get_encryption_status(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> EncryptionStatusResponse:
 ) -> EncryptionStatusResponse:
     """Report at-rest encryption status for OIDC + TOTP secrets.
     """Report at-rest encryption status for OIDC + TOTP secrets.
 
 

+ 1 - 1
backend/app/api/routes/bug_report.py

@@ -73,7 +73,7 @@ async def start_logging(
 @router.post("/stop-logging", response_model=StopLoggingResponse)
 @router.post("/stop-logging", response_model=StopLoggingResponse)
 async def stop_logging(
 async def stop_logging(
     was_debug: bool = Query(default=False),
     was_debug: bool = Query(default=False),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Collect logs and restore previous log level."""
     """Collect logs and restore previous log level."""
     logs = await _get_recent_sanitized_logs()
     logs = await _get_recent_sanitized_logs()

+ 275 - 0
backend/app/api/routes/bulk.py

@@ -0,0 +1,275 @@
+"""Bulk reads for pages that show many printers (or archives) at once.
+
+The Printers page asked for each printer's status, slot presets, AMS labels,
+plugs, sensor readings, firmware and queue with one request per printer, and
+the Archives page for each card's folders with one per archive. On a print
+farm that was hundreds of requests per page load, queued one behind the other
+on the server. The frontend (api/batch.ts) coalesces those per-id calls into
+these.
+
+Each route answers exactly what its single-id route answers, through the same
+helper. A printer outside the caller's scope, or one that doesn't exist, is
+left out; the client then asks the single route for it, which gives its 404.
+
+They live under their own prefix so no ``/{id}`` route of another router can
+take them for an id.
+"""
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import and_, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.firmware import FirmwareUpdateInfo, _checks_enabled, _not_checked, _printer_update_info
+from backend.app.api.routes.ha_sensors import _card_sensors, _reading
+from backend.app.api.routes.library import _folders_by_archive
+from backend.app.api.routes.print_queue import _QUEUE_ITEM_LOADS, _enrich_response
+from backend.app.api.routes.printers import _ams_labels, _printer_status, _slot_presets_by_key
+from backend.app.api.routes.smart_plugs import (
+    CallerEditsPlugs,
+    PrinterCardPlugs,
+    _card_script_plugs,
+    _pick_main_plug,
+    _serialize_plug,
+)
+from backend.app.core.auth import (
+    ApiKeyActor,
+    RequestActor,
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    require_ownership_permission,
+)
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.printer_ha_sensor import PrinterHASensor
+from backend.app.models.slot_preset import SlotPresetMapping
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
+from backend.app.schemas.library import FolderResponse
+from backend.app.schemas.print_queue import PrintQueueItemResponse
+from backend.app.schemas.printer import PrinterStatus
+from backend.app.schemas.printer_ha_sensor import PrinterHASensorReading
+from backend.app.schemas.smart_plug import SmartPlugResponse
+from backend.app.services.firmware_check import get_firmware_service
+from backend.app.utils.id_list import parse_id_list
+
+router = APIRouter(prefix="/bulk", tags=["bulk"])
+
+
+@router.get("/printer-statuses", response_model=list[PrinterStatus])
+async def get_printer_statuses(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
+    """``GET /printers/{printer_id}/status`` for several printers. Unknown ids are left out."""
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    if not wanted:
+        return []
+    result = await db.execute(select(Printer).where(Printer.id.in_(wanted)))
+    printers = {p.id: p for p in result.scalars().all()}
+    return [await _printer_status(db, printers[i]) for i in wanted if i in printers]
+
+
+@router.get("/slot-presets")
+async def get_slot_presets_bulk(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
+    """``GET /printers/{printer_id}/slot-presets`` for several printers, keyed by printer id."""
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    by_printer: dict[int, list] = {i: [] for i in wanted}
+    if wanted:
+        result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id.in_(wanted)))
+        for mapping in result.scalars().all():
+            by_printer[mapping.printer_id].append(mapping)
+    return {i: _slot_presets_by_key(mappings) for i, mappings in by_printer.items()}
+
+
+@router.get("/ams-labels")
+async def get_ams_labels_bulk(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
+    """``GET /printers/{printer_id}/ams-labels`` for several printers, keyed by printer id."""
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    return {i: await _ams_labels(db, i) for i in wanted}
+
+
+@router.get("/card-plugs", response_model=None, responses={200: {"model": dict[int, PrinterCardPlugs]}})
+async def get_smart_plugs_by_printers(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    can_edit: bool = CallerEditsPlugs,
+):
+    """``GET /smart-plugs/by-printer/{printer_id}`` and its ``/scripts``, for several printers.
+
+    The Printers page asked both once per printer card. A printer outside the
+    caller's scope is left out, which the client reports as the single routes'
+    404.
+    """
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    by_printer: dict[int, list[SmartPlug]] = {i: [] for i in wanted}
+    if wanted:
+        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id.in_(wanted)).order_by(SmartPlug.id))
+        for plug in result.scalars().all():
+            if plug.printer_id is not None:
+                by_printer[plug.printer_id].append(plug)
+    # Shaped as the single routes shape them: the main plug in the editor's
+    # form for a caller who may edit plugs (see _serialize_plug), so no
+    # response_model here either.
+    cards = {}
+    for i, plugs in by_printer.items():
+        plug = _pick_main_plug(plugs)
+        cards[i] = {
+            "plug": _serialize_plug(plug, can_edit=can_edit) if plug else None,
+            "scripts": [SmartPlugResponse.model_validate(p) for p in _card_script_plugs(plugs)],
+        }
+    return cards
+
+
+@router.get("/ha-sensor-readings", response_model=dict[int, list[PrinterHASensorReading]])
+async def get_sensor_readings_by_printers(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """``GET /ha-sensors/by-printer/{printer_id}/readings`` for several printers, keyed by printer id.
+
+    The Printers page asked once per card every 15 seconds. A printer outside
+    the caller's scope is left out, which the client reports as the single
+    route's 404.
+    """
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    readings: dict[int, list[PrinterHASensorReading]] = {i: [] for i in wanted}
+    if wanted:
+        for sensor in await _card_sensors(db, PrinterHASensor.printer_id.in_(wanted)):
+            readings[sensor.printer_id].append(_reading(sensor))
+    return readings
+
+
+@router.get("/firmware-updates", response_model=list[FirmwareUpdateInfo])
+async def check_printers_firmware(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """``GET /firmware/updates/{printer_id}`` for several printers.
+
+    The Printers page asked once per card. Unknown printers, and printers
+    outside the caller's scope, are left out, which the client reports as the
+    single route's 404.
+    """
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    if not wanted:
+        return []
+    result = await db.execute(select(Printer).where(Printer.id.in_(wanted)))
+    printers = {p.id: p for p in result.scalars().all()}
+    ordered = [printers[i] for i in wanted if i in printers]
+    if not await _checks_enabled(db):
+        return [_not_checked(p) for p in ordered]
+    firmware_service = get_firmware_service()
+    return [await _printer_update_info(firmware_service, p) for p in ordered]
+
+
+@router.get("/printer-queues", response_model=dict[int, list[PrintQueueItemResponse]])
+async def list_queue_by_printers(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    status: str | None = Query(None, description="Filter by status; several may be given comma-separated"),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_READ_ALL,
+            Permission.QUEUE_READ_OWN,
+        )
+    ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """``GET /queue/?printer_id=N&status=...`` for several printers, keyed by printer id.
+
+    Every printer card asked for its own queue, twice (the card and its queue
+    widget). Each list is what the single call returns: the printer's own jobs
+    plus the "Any <model>" jobs for its model, in dispatch order. A job for a
+    model shows up under every printer of that model.
+    """
+    user, can_read_all = auth_result
+    wanted = parse_id_list(ids)
+    if not wanted:
+        return {}
+    models = {
+        printer_id: model.lower()
+        for printer_id, model in (
+            await db.execute(select(Printer.id, Printer.model).where(Printer.id.in_(wanted)))
+        ).all()
+        if model
+    }
+
+    query = (
+        select(PrintQueueItem)
+        .options(*_QUEUE_ITEM_LOADS)
+        .where(
+            or_(
+                PrintQueueItem.printer_id.in_(wanted),
+                and_(
+                    PrintQueueItem.printer_id.is_(None),
+                    func.lower(PrintQueueItem.target_model).in_(set(models.values())),
+                ),
+            )
+        )
+        # The order the scheduler dispatches in (#3200), as GET /queue/ returns it
+        .order_by(PrintQueueItem.position, PrintQueueItem.id)
+    )
+    if user is not None and not can_read_all:
+        query = query.where(PrintQueueItem.created_by_id == user.id)
+    # Items bound to printers the caller can't see stay out (#1727)
+    if (clause := printer_scope.where(PrintQueueItem.printer_id)) is not None:
+        query = query.where(clause)
+    if status:
+        query = query.where(PrintQueueItem.status.in_([s.strip() for s in status.split(",") if s.strip()]))
+
+    items = (await db.execute(query)).scalars().all()
+    responses = {item.id: _enrich_response(item) for item in items}
+
+    def belongs(item: PrintQueueItem, printer_id: int) -> bool:
+        if item.printer_id is not None:
+            return item.printer_id == printer_id
+        model = models.get(printer_id)
+        return bool(model and item.target_model and item.target_model.lower() == model)
+
+    return {printer_id: [responses[item.id] for item in items if belongs(item, printer_id)] for printer_id in wanted}
+
+
+@router.get("/archive-folders", response_model=dict[int, list[FolderResponse]])
+async def get_folders_by_archives(
+    ids: str = Query(..., description="Comma-separated archive ids"),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
+):
+    """``GET /library/folders/by-archive/{archive_id}`` for several archives, keyed by archive id.
+
+    The Archives page asked once per archive card, each time loading the whole
+    folder index to work out what the user may see.
+    """
+    user, _ = auth_result
+    archive_ids = parse_id_list(ids)
+    if not archive_ids:
+        return {}
+    return await _folders_by_archive(db, user, archive_ids, printer_scope, actor)

File diff suppressed because it is too large
+ 520 - 188
backend/app/api/routes/camera.py


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

@@ -24,6 +24,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import RequireCamWallTokenIfAuthEnabled
 from backend.app.core.auth import RequireCamWallTokenIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 
 
@@ -34,10 +35,11 @@ router = APIRouter(prefix="/camwall", tags=["camwall"])
 
 
 @router.get("/printers")
 @router.get("/printers")
 async def list_camwall_printers(
 async def list_camwall_printers(
-    _: None = RequireCamWallTokenIfAuthEnabled,
-    db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequireCamWallTokenIfAuthEnabled,
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> list[dict]:
 ) -> list[dict]:
-    """Every printer plus the handful of status fields a Cam Wall tile draws.
+    """Every printer the token's owner may see (#1727), plus the handful of
+    status fields a Cam Wall tile draws.
 
 
     One call for the whole wall rather than one per printer: a kiosk polls this
     One call for the whole wall rather than one per printer: a kiosk polls this
     on a fixed interval with no WebSocket to invalidate it, and N+1 requests
     on a fixed interval with no WebSocket to invalidate it, and N+1 requests
@@ -46,7 +48,10 @@ async def list_camwall_printers(
     Ordered by name so tile positions stay put across polls — a wall that
     Ordered by name so tile positions stay put across polls — a wall that
     reshuffles itself is unusable to watch.
     reshuffles itself is unusable to watch.
     """
     """
-    result = await db.execute(select(Printer).order_by(Printer.name))
+    query = select(Printer).order_by(Printer.name)
+    if (clause := printer_scope.where_strict(Printer.id)) is not None:
+        query = query.where(clause)
+    result = await db.execute(query)
     printers = list(result.scalars().all())
     printers = list(result.scalars().all())
 
 
     payload: list[dict] = []
     payload: list[dict] = []

+ 181 - 68
backend/app/api/routes/cloud.py

@@ -15,6 +15,7 @@ from fastapi.security import HTTPAuthorizationCredentials
 from sqlalchemy import select, update
 from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.core import database
 from backend.app.core.auth import (
 from backend.app.core.auth import (
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     _user_from_api_key,
     _user_from_api_key,
@@ -24,6 +25,7 @@ from backend.app.core.auth import (
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.tasks import spawn_background_task
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -74,7 +76,7 @@ async def _cloud_api_key_gate(
     request: Request,
     request: Request,
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> None:
 ) -> None:
     """Router-level dependency: enforce API-key cloud-access fences (#1182).
     """Router-level dependency: enforce API-key cloud-access fences (#1182).
 
 
@@ -146,7 +148,7 @@ def cloud_caller(*permissions: Permission):
 async def resolve_api_key_cloud_owner(
 async def resolve_api_key_cloud_owner(
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> User | None:
 ) -> User | None:
     """Route-level dep for non-/cloud/* endpoints that need to read the
     """Route-level dep for non-/cloud/* endpoints that need to read the
     caller's stored Bambu Cloud token (e.g. the slice path resolving cloud
     caller's stored Bambu Cloud token (e.g. the slice path resolving cloud
@@ -181,6 +183,22 @@ async def resolve_api_key_cloud_owner(
 router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
 router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
 
 
 
 
+def _forget_account_presets(account: int | None) -> None:
+    """Drop what was read with an account's cloud token, once that token changes."""
+    _filament_id_name_cache.pop(account, None)
+    # A build still running reads with the old token; it must not be joined.
+    _filament_id_map_inflight.pop(account, None)
+    for key in [key for key in _filament_cache if key[0] == account]:
+        del _filament_cache[key]
+
+
+def forget_all_cloud_presets() -> None:
+    """Drop every account's cached presets (the user table was replaced)."""
+    _filament_id_name_cache.clear()
+    _filament_id_map_inflight.clear()
+    _filament_cache.clear()
+
+
 async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
 async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
     """Store cloud token, email, and region.
     """Store cloud token, email, and region.
 
 
@@ -192,6 +210,8 @@ async def store_token(db: AsyncSession, token: str, email: str, region: str, use
     """
     """
     region = _normalise_region(region)
     region = _normalise_region(region)
     invalidate_validation_cache(token)
     invalidate_validation_cache(token)
+    # The account may be another one now.
+    _forget_account_presets(user.id if user is not None else None)
     if user is not None:
     if user is not None:
         # User object is from the auth dependency's session (detached),
         # User object is from the auth dependency's session (detached),
         # so use a direct UPDATE via the route's db session.
         # so use a direct UPDATE via the route's db session.
@@ -228,6 +248,7 @@ async def clear_token(db: AsyncSession, user: User | None = None) -> None:
     token, _email, _region = await get_stored_token(db, user)
     token, _email, _region = await get_stored_token(db, user)
     if token:
     if token:
         invalidate_validation_cache(token)
         invalidate_validation_cache(token)
+    _forget_account_presets(user.id if user is not None else None)
 
 
     if user is not None:
     if user is not None:
         await db.execute(
         await db.execute(
@@ -363,7 +384,7 @@ async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> Bamb
 
 
 @router.get("/status", response_model=CloudAuthStatus)
 @router.get("/status", response_model=CloudAuthStatus)
 async def get_auth_status(
 async def get_auth_status(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """Get current cloud authentication status.
     """Get current cloud authentication status.
@@ -421,7 +442,7 @@ async def get_auth_status(
 @router.post("/login", response_model=CloudLoginResponse)
 @router.post("/login", response_model=CloudLoginResponse)
 async def login(
 async def login(
     request: CloudLoginRequest,
     request: CloudLoginRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
@@ -454,7 +475,8 @@ async def login(
     except BambuCloudAuthError as e:
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
         raise HTTPException(status_code=401, detail=str(e))
     except BambuCloudError as e:
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
     finally:
         await cloud.close()
         await cloud.close()
 
 
@@ -462,7 +484,7 @@ async def login(
 @router.post("/verify", response_model=CloudLoginResponse)
 @router.post("/verify", response_model=CloudLoginResponse)
 async def verify_code(
 async def verify_code(
     request: CloudVerifyRequest,
     request: CloudVerifyRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
@@ -500,7 +522,8 @@ async def verify_code(
     except BambuCloudAuthError as e:
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
         raise HTTPException(status_code=401, detail=str(e))
     except BambuCloudError as e:
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
     finally:
         await cloud.close()
         await cloud.close()
 
 
@@ -508,7 +531,7 @@ async def verify_code(
 @router.post("/token", response_model=CloudAuthStatus)
 @router.post("/token", response_model=CloudAuthStatus)
 async def set_token(
 async def set_token(
     request: CloudTokenRequest,
     request: CloudTokenRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
@@ -518,23 +541,35 @@ async def set_token(
     selected ``region`` is persisted alongside the token so every subsequent
     selected ``region`` is persisted alongside the token so every subsequent
     request hits the right Bambu API endpoint, including after a restart.
     request hits the right Bambu API endpoint, including after a restart.
     """
     """
+    # A pasted token often carries a stray space or line break. One that still
+    # cannot be sent as a header (control characters, spaces, non-ASCII) is not
+    # a token Bambu could accept: refused here, without reaching Bambu, and
+    # without echoing it back in an HTTP client's error.
+    token = request.access_token.strip()
+    if not token or not token.isascii() or not token.isprintable() or " " in token:
+        raise HTTPException(status_code=401, detail="Invalid token")
+
     cloud = BambuCloudService(region=request.region)
     cloud = BambuCloudService(region=request.region)
-    cloud.set_token(request.access_token)
+    cloud.set_token(token)
 
 
     try:
     try:
         # Verify token works by trying to get profile
         # Verify token works by trying to get profile
         await cloud.get_user_profile()
         await cloud.get_user_profile()
-        await store_token(db, request.access_token, "token-auth", request.region, current_user)
+        await store_token(db, token, "token-auth", request.region, current_user)
         return CloudAuthStatus(is_authenticated=True, email="token-auth")
         return CloudAuthStatus(is_authenticated=True, email="token-auth")
-    except BambuCloudError:
-        raise HTTPException(status_code=401, detail="Invalid token")
+    except BambuCloudError as e:
+        # Invalid only when Bambu Cloud refused it; a cloud that could not be
+        # reached or failed says nothing about the token.
+        if isinstance(e, BambuCloudAuthError) or e.status_code in (401, 403):
+            raise HTTPException(status_code=401, detail="Invalid token")
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
     finally:
         await cloud.close()
         await cloud.close()
 
 
 
 
 @router.post("/logout")
 @router.post("/logout")
 async def logout(
 async def logout(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """Log out of Bambu Cloud."""
     """Log out of Bambu Cloud."""
@@ -545,7 +580,7 @@ async def logout(
 @router.get("/settings", response_model=SlicerSettingsResponse)
 @router.get("/settings", response_model=SlicerSettingsResponse)
 async def get_slicer_settings(
 async def get_slicer_settings(
     version: str = _SLICER_API_VERSION,
     version: str = _SLICER_API_VERSION,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
@@ -586,6 +621,7 @@ async def get_slicer_settings(
                         user_id=s.get("user_id"),
                         user_id=s.get("user_id"),
                         updated_time=s.get("updated_time"),
                         updated_time=s.get("updated_time"),
                         is_custom=True,
                         is_custom=True,
+                        filament_id=_listed_filament_id(s),
                     )
                     )
                 )
                 )
             # Public (default) presets
             # Public (default) presets
@@ -599,16 +635,19 @@ async def get_slicer_settings(
                         user_id=s.get("user_id"),
                         user_id=s.get("user_id"),
                         updated_time=s.get("updated_time"),
                         updated_time=s.get("updated_time"),
                         is_custom=False,
                         is_custom=False,
+                        filament_id=_listed_filament_id(s),
                     )
                     )
                 )
                 )
             setattr(result, our_type, parsed)
             setattr(result, our_type, parsed)
 
 
         return result
         return result
     except BambuCloudAuthError:
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
     finally:
         await cloud.close()
         await cloud.close()
 
 
@@ -616,7 +655,7 @@ async def get_slicer_settings(
 @router.get("/settings/{setting_id}")
 @router.get("/settings/{setting_id}")
 async def get_setting_detail(
 async def get_setting_detail(
     setting_id: str,
     setting_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
@@ -632,10 +671,12 @@ async def get_setting_detail(
         data = await cloud.get_setting_detail(setting_id)
         data = await cloud.get_setting_detail(setting_id)
         return data
         return data
     except BambuCloudAuthError:
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
     finally:
         await cloud.close()
         await cloud.close()
 
 
@@ -643,7 +684,7 @@ async def get_setting_detail(
 @router.get("/filaments", response_model=list[SlicerSetting])
 @router.get("/filaments", response_model=list[SlicerSetting])
 async def get_filament_presets(
 async def get_filament_presets(
     version: str = _SLICER_API_VERSION,
     version: str = _SLICER_API_VERSION,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
     current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
 ):
     """
     """
@@ -656,8 +697,10 @@ async def get_filament_presets(
     return settings.filament
     return settings.filament
 
 
 
 
-# Cache for filament preset info (setting_id -> {name, k})
-_filament_cache: dict[str, dict] = {}
+# Cache for filament preset info ((account, setting_id) -> {name, k}). The
+# account is the user whose cloud token resolved it, None for the global one:
+# a personal preset read with one account's token is not another account's.
+_filament_cache: dict[tuple[int | None, str], dict] = {}
 _filament_cache_time: float = 0
 _filament_cache_time: float = 0
 FILAMENT_CACHE_TTL = 300  # 5 minutes
 FILAMENT_CACHE_TTL = 300  # 5 minutes
 
 
@@ -668,7 +711,7 @@ FILAMENT_CACHE_TTL = 300  # 5 minutes
 # (a thundering herd against a rate-limited API). The first caller to miss a
 # (a thundering herd against a rate-limited API). The first caller to miss a
 # given id becomes the leader and resolves it; concurrent callers await its
 # given id becomes the leader and resolves it; concurrent callers await its
 # future and reuse the result instead of duplicating the call.
 # future and reuse the result instead of duplicating the call.
-_filament_inflight: dict[str, asyncio.Future] = {}
+_filament_inflight: dict[tuple[int | None, str], asyncio.Future] = {}
 
 
 
 
 async def _fetch_one_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
 async def _fetch_one_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
@@ -714,17 +757,18 @@ async def _fetch_one_cloud_filament(setting_id: str, cloud: BambuCloudService) -
         return None
         return None
 
 
 
 
-async def _resolve_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
+async def _resolve_cloud_filament(setting_id: str, cloud: BambuCloudService, account: int | None = None) -> dict | None:
     """Resolve one preset via Bambu Cloud, single-flighting concurrent misses (#2572).
     """Resolve one preset via Bambu Cloud, single-flighting concurrent misses (#2572).
 
 
     Concurrent callers for the same ``setting_id`` share one cloud round-trip:
     Concurrent callers for the same ``setting_id`` share one cloud round-trip:
     the first caller resolves it while the rest await the shared future. Returns
     the first caller resolves it while the rest await the shared future. Returns
     the info dict (also populating ``_filament_cache``) or ``None`` on failure.
     the info dict (also populating ``_filament_cache``) or ``None`` on failure.
     """
     """
-    if setting_id in _filament_cache:
-        return _filament_cache[setting_id]
+    key = (account, setting_id)
+    if key in _filament_cache:
+        return _filament_cache[key]
 
 
-    existing = _filament_inflight.get(setting_id)
+    existing = _filament_inflight.get(key)
     if existing is not None:
     if existing is not None:
         # Another request is already fetching this id — reuse its result.
         # Another request is already fetching this id — reuse its result.
         # shield() so our own cancellation can't cancel the shared leader.
         # shield() so our own cancellation can't cancel the shared leader.
@@ -734,17 +778,17 @@ async def _resolve_cloud_filament(setting_id: str, cloud: BambuCloudService) ->
             return None
             return None
 
 
     fut: asyncio.Future = asyncio.get_event_loop().create_future()
     fut: asyncio.Future = asyncio.get_event_loop().create_future()
-    _filament_inflight[setting_id] = fut
+    _filament_inflight[key] = fut
     info: dict | None = None
     info: dict | None = None
     try:
     try:
         info = await _fetch_one_cloud_filament(setting_id, cloud)
         info = await _fetch_one_cloud_filament(setting_id, cloud)
         return info
         return info
     finally:
     finally:
         if info is not None:
         if info is not None:
-            _filament_cache[setting_id] = info
+            _filament_cache[key] = info
         if not fut.done():
         if not fut.done():
             fut.set_result(info)
             fut.set_result(info)
-        _filament_inflight.pop(setting_id, None)
+        _filament_inflight.pop(key, None)
 
 
 
 
 # Built-in filament ID → name mapping (fallback when cloud API and local profiles
 # Built-in filament ID → name mapping (fallback when cloud API and local profiles
@@ -758,11 +802,15 @@ _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
     "GFA07": "Bambu PLA Marble",
     "GFA07": "Bambu PLA Marble",
     "GFA08": "Bambu PLA Sparkle",
     "GFA08": "Bambu PLA Sparkle",
     "GFA09": "Bambu PLA Tough",
     "GFA09": "Bambu PLA Tough",
+    "GFA10": "Bambu PLA Tough+",
     "GFA11": "Bambu PLA Aero",
     "GFA11": "Bambu PLA Aero",
     "GFA12": "Bambu PLA Glow",
     "GFA12": "Bambu PLA Glow",
     "GFA13": "Bambu PLA Dynamic",
     "GFA13": "Bambu PLA Dynamic",
     "GFA15": "Bambu PLA Galaxy",
     "GFA15": "Bambu PLA Galaxy",
     "GFA16": "Bambu PLA Wood",
     "GFA16": "Bambu PLA Wood",
+    "GFA17": "Bambu PLA Translucent",
+    "GFA18": "Bambu PLA Lite",
+    "GFA19": "Bambu PLA Pure",
     "GFA50": "Bambu PLA-CF",
     "GFA50": "Bambu PLA-CF",
     "GFB00": "Bambu ABS",
     "GFB00": "Bambu ABS",
     "GFB01": "Bambu ASA",
     "GFB01": "Bambu ASA",
@@ -779,6 +827,7 @@ _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
     "GFG00": "Bambu PETG Basic",
     "GFG00": "Bambu PETG Basic",
     "GFG01": "Bambu PETG Translucent",
     "GFG01": "Bambu PETG Translucent",
     "GFG02": "Bambu PETG HF",
     "GFG02": "Bambu PETG HF",
+    "GFG03": "Bambu PETG Matte",
     "GFG50": "Bambu PETG-CF",
     "GFG50": "Bambu PETG-CF",
     "GFG60": "PolyLite PETG",
     "GFG60": "PolyLite PETG",
     "GFG96": "Generic PETG HF",
     "GFG96": "Generic PETG HF",
@@ -827,6 +876,13 @@ _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
     "GFS97": "Generic BVOH",
     "GFS97": "Generic BVOH",
     "GFS98": "Generic HIPS",
     "GFS98": "Generic HIPS",
     "GFS99": "Generic PVA",
     "GFS99": "Generic PVA",
+    "GFSNL02": "SUNLU PLA Matte",
+    "GFSNL03": "SUNLU PLA+",
+    "GFSNL04": "SUNLU PLA+ 2.0",
+    "GFSNL05": "SUNLU Silk PLA+",
+    "GFSNL06": "SUNLU PLA Marble",
+    "GFSNL07": "SUNLU Wood PLA",
+    "GFSNL08": "SUNLU PETG",
     "GFT01": "Bambu PET-CF",
     "GFT01": "Bambu PET-CF",
     "GFT02": "Bambu PPS-CF",
     "GFT02": "Bambu PPS-CF",
     "GFT97": "Generic PPS",
     "GFT97": "Generic PPS",
@@ -834,6 +890,8 @@ _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
     "GFU00": "Bambu TPU 95A HF",
     "GFU00": "Bambu TPU 95A HF",
     "GFU01": "Bambu TPU 95A",
     "GFU01": "Bambu TPU 95A",
     "GFU02": "Bambu TPU for AMS",
     "GFU02": "Bambu TPU for AMS",
+    "GFU03": "Bambu TPU 90A",
+    "GFU04": "Bambu TPU 85A",
     "GFU98": "Generic TPU for AMS",
     "GFU98": "Generic TPU for AMS",
     "GFU99": "Generic TPU",
     "GFU99": "Generic TPU",
 }
 }
@@ -843,6 +901,7 @@ async def _enrich_from_local_presets(
     unresolved_ids: list[str],
     unresolved_ids: list[str],
     result: dict,
     result: dict,
     db: AsyncSession,
     db: AsyncSession,
+    account: int | None = None,
 ) -> dict:
 ) -> dict:
     """Fall back to local profiles for filament IDs not resolved by cloud.
     """Fall back to local profiles for filament IDs not resolved by cloud.
 
 
@@ -890,7 +949,7 @@ async def _enrich_from_local_presets(
                             info["k"] = k_val
                             info["k"] = k_val
                         except (ValueError, TypeError, IndexError):
                         except (ValueError, TypeError, IndexError):
                             pass
                             pass
-                    _filament_cache[original_id] = info
+                    _filament_cache[(account, original_id)] = info
                     result[original_id] = info
                     result[original_id] = info
             except Exception:
             except Exception:
                 continue
                 continue
@@ -905,18 +964,24 @@ async def _enrich_from_local_presets(
                 # Preserve K value from earlier phases if available
                 # Preserve K value from earlier phases if available
                 existing_k = result.get(fid, {}).get("k")
                 existing_k = result.get(fid, {}).get("k")
                 info = {"name": name, "k": existing_k}
                 info = {"name": name, "k": existing_k}
-                _filament_cache[fid] = info
+                _filament_cache[(account, fid)] = info
                 result[fid] = info
                 result[fid] = info
 
 
     # Fill remaining unresolved with empty entries
     # Fill remaining unresolved with empty entries
     for fid in unresolved_ids:
     for fid in unresolved_ids:
         if fid not in result:
         if fid not in result:
-            _filament_cache[fid] = {"name": "", "k": None}
+            _filament_cache[(account, fid)] = {"name": "", "k": None}
             result[fid] = {"name": "", "k": None}
             result[fid] = {"name": "", "k": None}
 
 
     return result
     return result
 
 
 
 
+def _listed_filament_id(entry: dict) -> str | None:
+    """The filament_id an entry of Bambu's preset list carries, if it is one."""
+    raw = entry.get("filament_id")
+    return (raw.strip() or None) if isinstance(raw, str) else None
+
+
 # _filament_id_to_setting_id is now imported from backend.app.utils.filament_ids
 # _filament_id_to_setting_id is now imported from backend.app.utils.filament_ids
 _filament_id_to_setting_id = filament_id_to_setting_id
 _filament_id_to_setting_id = filament_id_to_setting_id
 
 
@@ -924,7 +989,7 @@ _filament_id_to_setting_id = filament_id_to_setting_id
 @router.post("/filament-info")
 @router.post("/filament-info")
 async def get_filament_info(
 async def get_filament_info(
     setting_ids: list[str] = Body(...),
     setting_ids: list[str] = Body(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
     current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
 ):
     """
     """
@@ -946,13 +1011,14 @@ async def get_filament_info(
 
 
     result = {}
     result = {}
     unresolved_ids: list[str] = []
     unresolved_ids: list[str] = []
+    account = current_user.id if current_user is not None else None
 
 
     # Phase 1: Check cache
     # Phase 1: Check cache
     for setting_id in setting_ids:
     for setting_id in setting_ids:
         if not setting_id:
         if not setting_id:
             continue
             continue
-        if setting_id in _filament_cache:
-            result[setting_id] = _filament_cache[setting_id]
+        if (account, setting_id) in _filament_cache:
+            result[setting_id] = _filament_cache[(account, setting_id)]
         else:
         else:
             unresolved_ids.append(setting_id)
             unresolved_ids.append(setting_id)
 
 
@@ -971,7 +1037,7 @@ async def get_filament_info(
             try:
             try:
                 still_unresolved: list[str] = []
                 still_unresolved: list[str] = []
                 for setting_id in unresolved_ids:
                 for setting_id in unresolved_ids:
-                    info = await _resolve_cloud_filament(setting_id, cloud)
+                    info = await _resolve_cloud_filament(setting_id, cloud, account)
                     if info is not None:
                     if info is not None:
                         result[setting_id] = info
                         result[setting_id] = info
                     if info is None or not info.get("name"):
                     if info is None or not info.get("name"):
@@ -984,14 +1050,14 @@ async def get_filament_info(
 
 
     # Phase 3: Try local profiles for any IDs still without a name
     # Phase 3: Try local profiles for any IDs still without a name
     if unresolved_ids:
     if unresolved_ids:
-        result = await _enrich_from_local_presets(unresolved_ids, result, db)
+        result = await _enrich_from_local_presets(unresolved_ids, result, db, account)
 
 
     return result
     return result
 
 
 
 
 @router.get("/devices", response_model=list[CloudDevice])
 @router.get("/devices", response_model=list[CloudDevice])
 async def get_devices(
 async def get_devices(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.PRINTERS_READ),
     current_user: User | None = cloud_caller(Permission.PRINTERS_READ),
 ):
 ):
     """
     """
@@ -1018,17 +1084,19 @@ async def get_devices(
             for d in devices
             for d in devices
         ]
         ]
     except BambuCloudAuthError:
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
     finally:
         await cloud.close()
         await cloud.close()
 
 
 
 
 @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
 @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
 async def get_firmware_updates(
 async def get_firmware_updates(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.FIRMWARE_READ),
     current_user: User | None = cloud_caller(Permission.FIRMWARE_READ),
 ):
 ):
     """
     """
@@ -1076,6 +1144,8 @@ async def get_firmware_updates(
                         release_notes=firmware_info.get("release_notes"),
                         release_notes=firmware_info.get("release_notes"),
                     )
                     )
                 )
                 )
+            except BambuCloudAuthError:
+                raise  # the session expired: not an unknown version per device
             except BambuCloudError as e:
             except BambuCloudError as e:
                 logger.warning("Failed to get firmware info for %s: %s", device_name, e)
                 logger.warning("Failed to get firmware info for %s: %s", device_name, e)
                 # Still include device but with unknown firmware status
                 # Still include device but with unknown firmware status
@@ -1093,10 +1163,12 @@ async def get_firmware_updates(
         return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
         return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
 
 
     except BambuCloudAuthError:
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
     finally:
         await cloud.close()
         await cloud.close()
 
 
@@ -1104,7 +1176,7 @@ async def get_firmware_updates(
 @router.post("/settings")
 @router.post("/settings")
 async def create_setting(
 async def create_setting(
     request: SlicerSettingCreate,
     request: SlicerSettingCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
@@ -1129,10 +1201,12 @@ async def create_setting(
         )
         )
         return data
         return data
     except BambuCloudAuthError:
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
     finally:
         await cloud.close()
         await cloud.close()
 
 
@@ -1141,7 +1215,7 @@ async def create_setting(
 async def update_setting(
 async def update_setting(
     setting_id: str,
     setting_id: str,
     request: SlicerSettingUpdate,
     request: SlicerSettingUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
@@ -1161,10 +1235,12 @@ async def update_setting(
         )
         )
         return data
         return data
     except BambuCloudAuthError:
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
     finally:
         await cloud.close()
         await cloud.close()
 
 
@@ -1172,7 +1248,7 @@ async def update_setting(
 @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
 @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
 async def delete_setting(
 async def delete_setting(
     setting_id: str,
     setting_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
@@ -1191,10 +1267,12 @@ async def delete_setting(
             message=result.get("message", "Setting deleted"),
             message=result.get("message", "Setting deleted"),
         )
         )
     except BambuCloudAuthError:
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
     finally:
         await cloud.close()
         await cloud.close()
 
 
@@ -1248,14 +1326,16 @@ async def get_builtin_filaments(
     return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]
     return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]
 
 
 
 
-# Cache for filament_id → name mapping (resolved from cloud preset details)
-_filament_id_name_cache: dict[str, str] = {}
-_filament_id_name_cache_time: float = 0
+# filament_id → name mappings (resolved from cloud preset details), one per
+# cloud account: the user whose token read it, or None for the global token
+# used when auth is off. Value: (time read, mapping).
+_filament_id_name_cache: dict[int | None, tuple[float, dict[str, str]]] = {}
+# Builds still running, by account (RC-C11).
+_filament_id_map_inflight: dict[int | None, asyncio.Future] = {}
 
 
 
 
 @router.get("/filament-id-map")
 @router.get("/filament-id-map")
 async def get_filament_id_map(
 async def get_filament_id_map(
-    db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
     current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
 ):
     """
     """
@@ -1264,20 +1344,50 @@ async def get_filament_id_map(
     K-profiles store a filament_id (e.g., "P4d64437") which is different from
     K-profiles store a filament_id (e.g., "P4d64437") which is different from
     the cloud preset setting_id (e.g., "PFUS9ac902733670a9"). This endpoint
     the cloud preset setting_id (e.g., "PFUS9ac902733670a9"). This endpoint
     fetches details for all custom presets and returns the mapping.
     fetches details for all custom presets and returns the mapping.
-    Cached for 5 minutes.
+    Cached for 5 minutes per cloud account; a caller is only ever given the
+    mapping of their own account's presets.
     """
     """
     import time
     import time
 
 
-    global _filament_id_name_cache, _filament_id_name_cache_time
+    account = current_user.id if current_user is not None else None
+    cached = _filament_id_name_cache.get(account)
+    if cached and time.time() - cached[0] < FILAMENT_CACHE_TTL:
+        return cached[1]
 
 
-    if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL:
-        return _filament_id_name_cache
+    # One build per account at a time: when the cache expired, every K-profile
+    # view asked at once and each ran the full 1 + N fetch (RC-C11). Registered
+    # before anything is awaited, so callers arriving together all join it.
+    running = _filament_id_map_inflight.get(account)
+    if running is None or running.done():
+        running = spawn_background_task(_build_filament_id_map(current_user, account), name="cloud-filament-id-map")
+        _filament_id_map_inflight[account] = running
 
 
-    cloud = await build_authenticated_cloud(db, current_user)
+        def _forget(done: asyncio.Future, account: int | None = account) -> None:
+            if _filament_id_map_inflight.get(account) is done:
+                del _filament_id_map_inflight[account]
+
+        running.add_done_callback(_forget)
+    try:
+        # shield: a caller that goes away must not cancel the build others joined.
+        return dict(await asyncio.shield(running))
+    except Exception:
+        return cached[1] if cached else {}
+
+
+async def _build_filament_id_map(user: User | None, account: int | None) -> dict[str, str]:
+    """Fetch an account's custom presets and map their filament ids to names.
+
+    Reads the stored token in a session of its own, closed before the Bambu
+    Cloud round-trips: no request holds a database connection across them.
+    """
+    import time
+
+    async with database.async_session() as db:
+        cloud = await build_authenticated_cloud(db, user)
     if cloud is None or not cloud.is_authenticated:
     if cloud is None or not cloud.is_authenticated:
         if cloud is not None:
         if cloud is not None:
             await cloud.close()
             await cloud.close()
-        return _filament_id_name_cache or {}
+        return {}
 
 
     try:
     try:
         data = await cloud.get_slicer_settings()
         data = await cloud.get_slicer_settings()
@@ -1290,7 +1400,10 @@ async def get_filament_id_map(
                 continue
                 continue
             try:
             try:
                 detail = await cloud.get_setting_detail(setting_id)
                 detail = await cloud.get_setting_detail(setting_id)
-                fid = detail.get("filament_id", "")
+                # On the envelope normally; in the preset JSON under `setting`
+                # when not (the same fallback as slicer_filament_resolver).
+                nested = detail.get("setting")
+                fid = detail.get("filament_id") or (nested.get("filament_id") if isinstance(nested, dict) else "")
                 name = detail.get("name", "")
                 name = detail.get("name", "")
                 if fid and name:
                 if fid and name:
                     # Strip printer/nozzle suffix: "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" → "Devil Design PLA Basic"
                     # Strip printer/nozzle suffix: "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" → "Devil Design PLA Basic"
@@ -1299,11 +1412,11 @@ async def get_filament_id_map(
             except Exception:
             except Exception:
                 pass
                 pass
 
 
-        _filament_id_name_cache = result
-        _filament_id_name_cache_time = time.time()
+        # Not when the account signed out meanwhile: its build was dropped,
+        # and this map was read with the token that is gone.
+        if _filament_id_map_inflight.get(account) is asyncio.current_task():
+            _filament_id_name_cache[account] = (time.time(), result)
         return result
         return result
-    except Exception:
-        return _filament_id_name_cache or {}
     finally:
     finally:
         await cloud.close()
         await cloud.close()
 
 

+ 35 - 27
backend/app/api/routes/connected_apps.py

@@ -38,12 +38,15 @@ from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     get_current_user,
     get_current_user,
     get_password_hash,
     get_password_hash,
     get_user_by_username,
     get_user_by_username,
     is_auth_enabled,
     is_auth_enabled,
     security,
     security,
+    session_dated,
+    token_outlived_its_session,
     verify_password,
     verify_password,
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
@@ -118,7 +121,7 @@ async def _get_app_or_404(db: AsyncSession, app_id: int) -> ConnectedApp:
 
 
 @router.get("/apps", response_model=list[ConnectedAppResponse])
 @router.get("/apps", response_model=list[ConnectedAppResponse])
 async def list_connected_apps(
 async def list_connected_apps(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     result = await db.execute(select(ConnectedApp).order_by(ConnectedApp.created_at.desc()))
     result = await db.execute(select(ConnectedApp).order_by(ConnectedApp.created_at.desc()))
@@ -128,7 +131,8 @@ async def list_connected_apps(
 @router.post("/apps", response_model=ConnectedAppSecretResponse)
 @router.post("/apps", response_model=ConnectedAppSecretResponse)
 async def create_connected_app(
 async def create_connected_app(
     data: ConnectedAppCreate,
     data: ConnectedAppCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Register an app. The client secret is in this response and nowhere else."""
     """Register an app. The client secret is in this response and nowhere else."""
@@ -156,7 +160,8 @@ async def create_connected_app(
 async def update_connected_app(
 async def update_connected_app(
     app_id: int,
     app_id: int,
     data: ConnectedAppUpdate,
     data: ConnectedAppUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     app = await _get_app_or_404(db, app_id)
     app = await _get_app_or_404(db, app_id)
@@ -174,7 +179,8 @@ async def update_connected_app(
 @router.post("/apps/{app_id}/rotate-secret", response_model=ConnectedAppSecretResponse)
 @router.post("/apps/{app_id}/rotate-secret", response_model=ConnectedAppSecretResponse)
 async def rotate_connected_app_secret(
 async def rotate_connected_app_secret(
     app_id: int,
     app_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Replace the client secret. The old one stops working immediately."""
     """Replace the client secret. The old one stops working immediately."""
@@ -190,7 +196,8 @@ async def rotate_connected_app_secret(
 @router.delete("/apps/{app_id}")
 @router.delete("/apps/{app_id}")
 async def delete_connected_app(
 async def delete_connected_app(
     app_id: int,
     app_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     app = await _get_app_or_404(db, app_id)
     app = await _get_app_or_404(db, app_id)
@@ -215,7 +222,7 @@ async def delete_connected_app(
 
 
 async def require_signed_in_user(
 async def require_signed_in_user(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> User:
 ) -> User:
     """The user behind a Bambuddy login token. API keys are refused.
     """The user behind a Bambuddy login token. API keys are refused.
 
 
@@ -242,7 +249,7 @@ async def _app_for_authorize(db: AsyncSession, client_id: str, redirect_uri: str
 async def authorize_info(
 async def authorize_info(
     client_id: str = Query(..., max_length=64),
     client_id: str = Query(..., max_length=64),
     redirect_uri: str = Query(..., max_length=500),
     redirect_uri: str = Query(..., max_length=500),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     user: User = Depends(require_signed_in_user),
     user: User = Depends(require_signed_in_user),
 ):
 ):
     """What the consent screen shows. Validates the request before the page acts on it."""
     """What the consent screen shows. Validates the request before the page acts on it."""
@@ -258,7 +265,7 @@ async def authorize_info(
 @router.post("/authorize", response_model=ConnectAuthorizeResponse)
 @router.post("/authorize", response_model=ConnectAuthorizeResponse)
 async def authorize(
 async def authorize(
     data: ConnectAuthorizeRequest,
     data: ConnectAuthorizeRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     user: User = Depends(require_signed_in_user),
     user: User = Depends(require_signed_in_user),
 ):
 ):
     """Issue a code for the signed-in user and record their consent."""
     """Issue a code for the signed-in user and record their consent."""
@@ -280,33 +287,32 @@ async def authorize(
         db.add(ConnectedAppGrant(app_id=app.id, user_id=user.id))
         db.add(ConnectedAppGrant(app_id=app.id, user_id=user.id))
 
 
     code = secrets.token_urlsafe(32)
     code = secrets.token_urlsafe(32)
-    db.add(
-        AuthEphemeralToken.new_connect_code(
+    # Read before the commit: a rollback below expires what this session loaded,
+    # and reading it then raises in async SQLAlchemy.
+    username, user_id, app_id, redirect_uri = user.username, user.id, app.id, app.redirect_uri
+
+    def new_code() -> AuthEphemeralToken:
+        return AuthEphemeralToken.new_connect_code(
             code_hash=_hash_code(code),
             code_hash=_hash_code(code),
-            username=user.username,
-            app_id=app.id,
+            username=username,
+            app_id=app_id,
             code_challenge=data.code_challenge,
             code_challenge=data.code_challenge,
             expires_at=now + CODE_TTL,
             expires_at=now + CODE_TTL,
+            # When the session that consents began (core/auth.session_dated)
+            **session_dated(username),
         )
         )
-    )
+
+    db.add(new_code())
     try:
     try:
         await db.commit()
         await db.commit()
     except IntegrityError:
     except IntegrityError:
         # Two tabs consenting at once both inserted the grant; the other one won.
         # Two tabs consenting at once both inserted the grant; the other one won.
         await db.rollback()
         await db.rollback()
-        db.add(
-            AuthEphemeralToken.new_connect_code(
-                code_hash=_hash_code(code),
-                username=user.username,
-                app_id=app.id,
-                code_challenge=data.code_challenge,
-                expires_at=now + CODE_TTL,
-            )
-        )
+        db.add(new_code())
         await db.commit()
         await db.commit()
 
 
-    logger.info("Connected app %s: code issued for user %s", app.id, user.id)
-    return ConnectAuthorizeResponse(code=code, redirect_uri=app.redirect_uri)
+    logger.info("Connected app %s: code issued for user %s", app_id, user_id)
+    return ConnectAuthorizeResponse(code=code, redirect_uri=redirect_uri)
 
 
 
 
 # --------------------------------------------------------------------------
 # --------------------------------------------------------------------------
@@ -322,7 +328,7 @@ def _token_error(status_code: int, error: str) -> HTTPException:
 async def exchange_code(
 async def exchange_code(
     request: Request,
     request: Request,
     data: ConnectTokenRequest,
     data: ConnectTokenRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Swap a code for the user's identity and permissions.
     """Swap a code for the user's identity and permissions.
 
 
@@ -370,13 +376,15 @@ async def exchange_code(
             AuthEphemeralToken.provider_id == app.id,
             AuthEphemeralToken.provider_id == app.id,
             AuthEphemeralToken.expires_at > now,
             AuthEphemeralToken.expires_at > now,
         )
         )
-        .returning(AuthEphemeralToken.username, AuthEphemeralToken.nonce)
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.nonce, AuthEphemeralToken.created_at)
     )
     )
     row = consumed.one_or_none()
     row = consumed.one_or_none()
     await db.commit()
     await db.commit()
     if row is None:
     if row is None:
         raise await fail(400, "invalid_grant", "unknown, expired, reused or foreign code")
         raise await fail(400, "invalid_grant", "unknown, expired, reused or foreign code")
-    username, code_challenge = row
+    username, code_challenge, minted_at = row
+    if await token_outlived_its_session(db, username, minted_at):
+        raise await fail(400, "invalid_grant", "code from before the user's current session")
 
 
     if not hmac.compare_digest(app.redirect_uri, data.redirect_uri):
     if not hmac.compare_digest(app.redirect_uri, data.redirect_uri):
         raise await fail(400, "invalid_grant", "callback URL mismatch")
         raise await fail(400, "invalid_grant", "callback URL mismatch")

+ 88 - 13
backend/app/api/routes/external_links.py

@@ -2,25 +2,37 @@
 
 
 import logging
 import logging
 import uuid
 import uuid
+from datetime import timedelta
 from pathlib import Path
 from pathlib import Path
+from typing import cast
+from urllib.parse import urlsplit
 
 
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse
 from fastapi.responses import FileResponse
-from sqlalchemy import select
+from sqlalchemy import select, update
+from sqlalchemy.engine import CursorResult
+from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_media_token_permission
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    ScopedCaller,
+    require_media_token_permission,
+    require_notification_send,
+)
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
-from backend.app.models.external_link import ExternalLink
+from backend.app.models.external_link import ExternalLink, ExternalLinkBadge
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.external_link import (
 from backend.app.schemas.external_link import (
+    ExternalLinkBadgeSet,
     ExternalLinkCreate,
     ExternalLinkCreate,
     ExternalLinkReorder,
     ExternalLinkReorder,
     ExternalLinkResponse,
     ExternalLinkResponse,
     ExternalLinkUpdate,
     ExternalLinkUpdate,
 )
 )
+from backend.app.utils.local_time import utcnow_naive
 
 
 # Directory for storing custom icons
 # Directory for storing custom icons
 ICONS_DIR = app_settings.base_dir / "icons"
 ICONS_DIR = app_settings.base_dir / "icons"
@@ -30,22 +42,85 @@ logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/external-links", tags=["external-links"])
 router = APIRouter(prefix="/external-links", tags=["external-links"])
 
 
+# An app that stopped reporting (stopped, crashed) leaves no old number behind.
+BADGE_FRESH_FOR = timedelta(minutes=10)
+
+
+_DEFAULT_PORTS = {"http": 80, "https": 443}
+
+
+def _same_place(url: str) -> tuple[str, str, int | None, str]:
+    """Scheme, host, port and path of an address. A login in it, the default
+    port written out and a trailing slash don't count."""
+    parts = urlsplit(url.strip())
+    scheme = parts.scheme.lower()
+    try:
+        port = parts.port or _DEFAULT_PORTS.get(scheme)
+    except ValueError:
+        port = None
+    return scheme, (parts.hostname or "").lower(), port, parts.path.rstrip("/")
+
 
 
 @router.get("/", response_model=list[ExternalLinkResponse])
 @router.get("/", response_model=list[ExternalLinkResponse])
 async def list_external_links(
 async def list_external_links(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
 ):
 ):
     """List all external links ordered by sort_order."""
     """List all external links ordered by sort_order."""
     result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     links = result.scalars().all()
     links = result.scalars().all()
-    return links
+    fresh_since = utcnow_naive() - BADGE_FRESH_FOR
+    badges = {
+        b.link_id: b.count
+        for b in (await db.execute(select(ExternalLinkBadge).where(ExternalLinkBadge.updated_at >= fresh_since)))
+        .scalars()
+        .all()
+    }
+    return [
+        ExternalLinkResponse.model_validate(link).model_copy(update={"badge_count": badges.get(link.id)})
+        for link in links
+    ]
+
+
+@router.put("/badge")
+async def set_badge(
+    data: ExternalLinkBadgeSet,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: ScopedCaller = Depends(require_notification_send()),
+):
+    """Set the count on the sidebar links pointing at an app, e.g. Bambuddy
+    Orders' To do. For the app itself: an API key needs the "Send
+    notifications" permission, like for its messages. The count shows while
+    the app keeps reporting it."""
+    wanted = _same_place(data.url)
+    links = [
+        link for link in (await db.execute(select(ExternalLink))).scalars().all() if _same_place(link.url) == wanted
+    ]
+    if not links:
+        raise HTTPException(status_code=404, detail="No sidebar link points at this address")
+    now = utcnow_naive()
+    for link in links:
+        values = {"count": data.count, "updated_at": now}
+        changed = cast(
+            CursorResult,
+            await db.execute(update(ExternalLinkBadge).where(ExternalLinkBadge.link_id == link.id).values(**values)),
+        )
+        if changed.rowcount:
+            continue
+        try:
+            async with db.begin_nested():
+                db.add(ExternalLinkBadge(link_id=link.id, **values))
+        except IntegrityError:
+            # Another report made the row in the meantime: update that one.
+            await db.execute(update(ExternalLinkBadge).where(ExternalLinkBadge.link_id == link.id).values(**values))
+    await db.commit()
+    return {"links": len(links)}
 
 
 
 
 @router.post("/", response_model=ExternalLinkResponse)
 @router.post("/", response_model=ExternalLinkResponse)
 async def create_external_link(
 async def create_external_link(
     link_data: ExternalLinkCreate,
     link_data: ExternalLinkCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
 ):
 ):
     """Create a new external link."""
     """Create a new external link."""
@@ -73,7 +148,7 @@ async def create_external_link(
 @router.get("/{link_id}", response_model=ExternalLinkResponse)
 @router.get("/{link_id}", response_model=ExternalLinkResponse)
 async def get_external_link(
 async def get_external_link(
     link_id: int,
     link_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
 ):
 ):
     """Get a specific external link."""
     """Get a specific external link."""
@@ -90,7 +165,7 @@ async def get_external_link(
 async def update_external_link(
 async def update_external_link(
     link_id: int,
     link_id: int,
     update_data: ExternalLinkUpdate,
     update_data: ExternalLinkUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
 ):
     """Update an external link."""
     """Update an external link."""
@@ -116,7 +191,7 @@ async def update_external_link(
 @router.delete("/{link_id}")
 @router.delete("/{link_id}")
 async def delete_external_link(
 async def delete_external_link(
     link_id: int,
     link_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
 ):
 ):
     """Delete an external link."""
     """Delete an external link."""
@@ -138,7 +213,7 @@ async def delete_external_link(
 @router.put("/reorder", response_model=list[ExternalLinkResponse])
 @router.put("/reorder", response_model=list[ExternalLinkResponse])
 async def reorder_external_links(
 async def reorder_external_links(
     reorder_data: ExternalLinkReorder,
     reorder_data: ExternalLinkReorder,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
 ):
     """Update the sort order of external links."""
     """Update the sort order of external links."""
@@ -164,7 +239,7 @@ async def reorder_external_links(
 async def upload_icon(
 async def upload_icon(
     link_id: int,
     link_id: int,
     file: UploadFile = File(...),
     file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
 ):
     """Upload a custom icon for an external link."""
     """Upload a custom icon for an external link."""
@@ -213,7 +288,7 @@ async def upload_icon(
 @router.delete("/{link_id}/icon", response_model=ExternalLinkResponse)
 @router.delete("/{link_id}/icon", response_model=ExternalLinkResponse)
 async def delete_icon(
 async def delete_icon(
     link_id: int,
     link_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
 ):
     """Delete the custom icon for an external link."""
     """Delete the custom icon for an external link."""
@@ -238,7 +313,7 @@ async def delete_icon(
 @router.get("/{link_id}/icon")
 @router.get("/{link_id}/icon")
 async def get_icon(
 async def get_icon(
     link_id: int,
     link_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_media_token_permission(Permission.EXTERNAL_LINKS_READ)),
     _: User | None = Depends(require_media_token_permission(Permission.EXTERNAL_LINKS_READ)),
 ):
 ):
     """Get the custom icon for an external link.
     """Get the custom icon for an external link.

+ 8 - 8
backend/app/api/routes/filaments.py

@@ -19,7 +19,7 @@ router = APIRouter(prefix="/filament-catalog", tags=["filament-catalog"])
 
 
 @router.get("/", response_model=list[FilamentResponse])
 @router.get("/", response_model=list[FilamentResponse])
 async def list_filaments(
 async def list_filaments(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
 ):
     """List all filaments."""
     """List all filaments."""
@@ -30,7 +30,7 @@ async def list_filaments(
 @router.post("/", response_model=FilamentResponse)
 @router.post("/", response_model=FilamentResponse)
 async def create_filament(
 async def create_filament(
     filament_data: FilamentCreate,
     filament_data: FilamentCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
 ):
 ):
     """Create a new filament entry."""
     """Create a new filament entry."""
@@ -44,7 +44,7 @@ async def create_filament(
 @router.get("/{filament_id}", response_model=FilamentResponse)
 @router.get("/{filament_id}", response_model=FilamentResponse)
 async def get_filament(
 async def get_filament(
     filament_id: int,
     filament_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
 ):
     """Get a specific filament."""
     """Get a specific filament."""
@@ -59,7 +59,7 @@ async def get_filament(
 async def update_filament(
 async def update_filament(
     filament_id: int,
     filament_id: int,
     filament_data: FilamentUpdate,
     filament_data: FilamentUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
 ):
     """Update a filament."""
     """Update a filament."""
@@ -79,7 +79,7 @@ async def update_filament(
 @router.delete("/{filament_id}")
 @router.delete("/{filament_id}")
 async def delete_filament(
 async def delete_filament(
     filament_id: int,
     filament_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_DELETE),
 ):
 ):
     """Delete a filament."""
     """Delete a filament."""
@@ -97,7 +97,7 @@ async def delete_filament(
 async def calculate_cost(
 async def calculate_cost(
     filament_id: int,
     filament_id: int,
     weight_grams: float,
     weight_grams: float,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
 ):
     """Calculate the cost for a given weight of filament."""
     """Calculate the cost for a given weight of filament."""
@@ -120,7 +120,7 @@ async def calculate_cost(
 @router.get("/by-type/{filament_type}", response_model=list[FilamentResponse])
 @router.get("/by-type/{filament_type}", response_model=list[FilamentResponse])
 async def get_filaments_by_type(
 async def get_filaments_by_type(
     filament_type: str,
     filament_type: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
 ):
     """Get all filaments of a specific type."""
     """Get all filaments of a specific type."""
@@ -130,7 +130,7 @@ async def get_filaments_by_type(
 
 
 @router.post("/seed-defaults")
 @router.post("/seed-defaults")
 async def seed_default_filaments(
 async def seed_default_filaments(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
 ):
 ):
     """Seed the database with common filament types."""
     """Seed the database with common filament types."""

+ 34 - 23
backend/app/api/routes/finance.py

@@ -7,7 +7,7 @@ from sqlalchemy import case, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
+from backend.app.core.auth import ApiKeyActor, RequestActor, RequirePermissionIfAuthEnabled, require_auth_if_enabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.finance import (
 from backend.app.models.finance import (
@@ -415,7 +415,7 @@ async def _create_wallet_adjustment(
 
 
 @router.get("/me/balance", response_model=WalletBalanceResponse)
 @router.get("/me/balance", response_model=WalletBalanceResponse)
 async def get_my_balance(
 async def get_my_balance(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
 ):
 ):
     """Return the current user's wallet balance."""
     """Return the current user's wallet balance."""
@@ -427,7 +427,7 @@ async def get_my_balance(
 async def get_my_transactions(
 async def get_my_transactions(
     limit: int = Query(50, ge=1, le=500),
     limit: int = Query(50, ge=1, le=500),
     offset: int = Query(0, ge=0),
     offset: int = Query(0, ge=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
 ):
 ):
     """Return wallet ledger entries for the current user."""
     """Return wallet ledger entries for the current user."""
@@ -468,7 +468,7 @@ async def get_all_transactions(
     limit: int = Query(50, ge=1, le=500),
     limit: int = Query(50, ge=1, le=500),
     offset: int = Query(0, ge=0),
     offset: int = Query(0, ge=0),
     user_id: int | None = Query(None, description="Optional filter by user id"),
     user_id: int | None = Query(None, description="Optional filter by user id"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
 ):
 ):
     """Return wallet ledger entries across users for admin finance view."""
     """Return wallet ledger entries across users for admin finance view."""
@@ -498,7 +498,7 @@ async def get_all_transactions(
     )
     )
 
 
 
 
-async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int) -> None:
+async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int | None) -> None:
     """Rebuild through the same canonical ledger repair used at startup."""
     """Rebuild through the same canonical ledger repair used at startup."""
     from backend.app.core.database import repair_wallet_ledger_internal
     from backend.app.core.database import repair_wallet_ledger_internal
 
 
@@ -508,7 +508,7 @@ async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int) -> Non
 @router.delete("/transactions/{transaction_id}")
 @router.delete("/transactions/{transaction_id}")
 async def delete_transaction(
 async def delete_transaction(
     transaction_id: int,
     transaction_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
 ):
     """Delete a wallet transaction and rebuild the user's ledger to keep balances consistent."""
     """Delete a wallet transaction and rebuild the user's ledger to keep balances consistent."""
@@ -542,7 +542,7 @@ async def delete_transaction(
 async def edit_transaction(
 async def edit_transaction(
     transaction_id: int,
     transaction_id: int,
     request: TransactionEditRequest,
     request: TransactionEditRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
 ):
     """Edit a wallet transaction (user_id, cost_center_id, amount, description) and rebuild ledger."""
     """Edit a wallet transaction (user_id, cost_center_id, amount, description) and rebuild ledger."""
@@ -593,7 +593,7 @@ async def edit_transaction(
 @router.post("/transactions/manual", response_model=WalletTransactionResponse)
 @router.post("/transactions/manual", response_model=WalletTransactionResponse)
 async def create_manual_print(
 async def create_manual_print(
     request: ManualPrintRequest,
     request: ManualPrintRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
 ):
     """Create a manual print charge transaction (for admin purposes)."""
     """Create a manual print charge transaction (for admin purposes)."""
@@ -636,10 +636,21 @@ async def create_manual_print(
 
 
 @router.get("/cost-centers/mine", response_model=list[CostCenterSummaryResponse])
 @router.get("/cost-centers/mine", response_model=list[CostCenterSummaryResponse])
 async def get_my_cost_centers(
 async def get_my_cost_centers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_auth_if_enabled),
     current_user: User | None = Depends(require_auth_if_enabled),
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
-    """Return private and assigned cost centers for the current user."""
+    """Return private and assigned cost centers for the current user.
+
+    An API key that may queue gets its owner's, the ones it can queue with
+    when billing is on (#3256). A key without an owner has none.
+    """
+    if isinstance(actor, ApiKeyActor):
+        if not actor.has_permission(Permission.QUEUE_CREATE.value):
+            raise HTTPException(status_code=403, detail="API key cannot queue prints")
+        if actor.owner is None:
+            return []
+        current_user = actor.owner
     user = await _require_authenticated_user(current_user)
     user = await _require_authenticated_user(current_user)
 
 
     result = await db.execute(
     result = await db.execute(
@@ -684,7 +695,7 @@ async def get_my_cost_centers(
 @router.get("/users/{user_id}/balance", response_model=WalletBalanceResponse)
 @router.get("/users/{user_id}/balance", response_model=WalletBalanceResponse)
 async def get_user_balance(
 async def get_user_balance(
     user_id: int,
     user_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
 ):
 ):
     """Return a specific user's wallet balance."""
     """Return a specific user's wallet balance."""
@@ -698,7 +709,7 @@ async def get_user_transactions(
     user_id: int,
     user_id: int,
     limit: int = Query(50, ge=1, le=500),
     limit: int = Query(50, ge=1, le=500),
     offset: int = Query(0, ge=0),
     offset: int = Query(0, ge=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
 ):
 ):
     """Return wallet ledger entries for a specific user."""
     """Return wallet ledger entries for a specific user."""
@@ -719,7 +730,7 @@ async def get_user_transactions(
 async def deposit_user_balance(
 async def deposit_user_balance(
     user_id: int,
     user_id: int,
     body: WalletAdjustmentRequest,
     body: WalletAdjustmentRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
 ):
     """Add funds to a user's wallet."""
     """Add funds to a user's wallet."""
@@ -740,7 +751,7 @@ async def deposit_user_balance(
 async def withdraw_user_balance(
 async def withdraw_user_balance(
     user_id: int,
     user_id: int,
     body: WalletAdjustmentRequest,
     body: WalletAdjustmentRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
 ):
     """Withdraw funds from a user's wallet."""
     """Withdraw funds from a user's wallet."""
@@ -759,7 +770,7 @@ async def withdraw_user_balance(
 
 
 @router.post("/rebuild-balance-ledger")
 @router.post("/rebuild-balance-ledger")
 async def rebuild_balance_ledger(
 async def rebuild_balance_ledger(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
 ):
     """Recompute balance_after for all wallet transactions.
     """Recompute balance_after for all wallet transactions.
@@ -784,7 +795,7 @@ async def rebuild_balance_ledger(
 @router.get("/cost-centers", response_model=list[CostCenterSummaryResponse])
 @router.get("/cost-centers", response_model=list[CostCenterSummaryResponse])
 async def list_cost_centers(
 async def list_cost_centers(
     include_inactive: bool = Query(False),
     include_inactive: bool = Query(False),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_auth_if_enabled),
     current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """List all cost centers.
     """List all cost centers.
@@ -819,7 +830,7 @@ async def list_cost_centers(
 @router.post("/cost-centers", response_model=CostCenterSummaryResponse)
 @router.post("/cost-centers", response_model=CostCenterSummaryResponse)
 async def create_cost_center(
 async def create_cost_center(
     body: CostCenterCreateRequest,
     body: CostCenterCreateRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_CREATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_CREATE),
 ):
 ):
     """Create a shared cost center."""
     """Create a shared cost center."""
@@ -850,7 +861,7 @@ async def create_cost_center(
 @router.get("/cost-centers/{cost_center_id}", response_model=CostCenterDetailResponse)
 @router.get("/cost-centers/{cost_center_id}", response_model=CostCenterDetailResponse)
 async def get_cost_center(
 async def get_cost_center(
     cost_center_id: int,
     cost_center_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_auth_if_enabled),
     current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Get one cost center with its memberships."""
     """Get one cost center with its memberships."""
@@ -877,7 +888,7 @@ async def get_cost_center(
 async def update_cost_center(
 async def update_cost_center(
     cost_center_id: int,
     cost_center_id: int,
     body: CostCenterUpdateRequest,
     body: CostCenterUpdateRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
 ):
     """Update name or active-state of a cost center."""
     """Update name or active-state of a cost center."""
@@ -914,7 +925,7 @@ async def update_cost_center(
 async def update_cost_center_budgets(
 async def update_cost_center_budgets(
     cost_center_id: int,
     cost_center_id: int,
     body: CostCenterBudgetUpdateRequest,
     body: CostCenterBudgetUpdateRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
 ):
     """Update budget values of a cost center."""
     """Update budget values of a cost center."""
@@ -949,7 +960,7 @@ async def update_cost_center_budgets(
 async def upsert_cost_center_member(
 async def upsert_cost_center_member(
     cost_center_id: int,
     cost_center_id: int,
     body: CostCenterMemberRequest,
     body: CostCenterMemberRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
 ):
     """Assign or update a user's membership on a cost center."""
     """Assign or update a user's membership on a cost center."""
@@ -982,7 +993,7 @@ async def upsert_cost_center_member(
 @router.delete("/cost-centers/{cost_center_id}")
 @router.delete("/cost-centers/{cost_center_id}")
 async def delete_cost_center(
 async def delete_cost_center(
     cost_center_id: int,
     cost_center_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
 ):
     """Delete a shared cost center."""
     """Delete a shared cost center."""
@@ -1029,7 +1040,7 @@ async def delete_cost_center(
 async def remove_cost_center_member(
 async def remove_cost_center_member(
     cost_center_id: int,
     cost_center_id: int,
     user_id: int,
     user_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
 ):
     """Remove a user from a shared cost center."""
     """Remove a user from a shared cost center."""

+ 24 - 19
backend/app/api/routes/firmware.py

@@ -12,9 +12,14 @@ from pydantic import BaseModel, Field
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -104,11 +109,12 @@ def _not_checked(printer: Printer) -> FirmwareUpdateInfo:
 
 
 @router.get("/updates", response_model=FirmwareUpdatesResponse)
 @router.get("/updates", response_model=FirmwareUpdatesResponse)
 async def check_firmware_updates(
 async def check_firmware_updates(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """
     """
-    Check for firmware updates for all connected printers.
+    Check for firmware updates for the connected printers the caller may see.
 
 
     Compares each printer's current firmware version against the latest
     Compares each printer's current firmware version against the latest
     available version from Bambu Lab's official firmware download page.
     available version from Bambu Lab's official firmware download page.
@@ -120,7 +126,7 @@ async def check_firmware_updates(
 
 
     # Get all printers from database
     # Get all printers from database
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
 
     if not await _checks_enabled(db):
     if not await _checks_enabled(db):
         return FirmwareUpdatesResponse(updates=[_not_checked(p) for p in printers], updates_available=0)
         return FirmwareUpdatesResponse(updates=[_not_checked(p) for p in printers], updates_available=0)
@@ -162,8 +168,8 @@ async def check_firmware_updates(
 @router.get("/updates/{printer_id}", response_model=FirmwareUpdateInfo)
 @router.get("/updates/{printer_id}", response_model=FirmwareUpdateInfo)
 async def check_printer_firmware(
 async def check_printer_firmware(
     printer_id: int,
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
 ):
     """
     """
     Check for firmware update for a specific printer.
     Check for firmware update for a specific printer.
@@ -179,7 +185,11 @@ async def check_printer_firmware(
 
 
     if not await _checks_enabled(db):
     if not await _checks_enabled(db):
         return _not_checked(printer)
         return _not_checked(printer)
+    return await _printer_update_info(firmware_service, printer)
+
 
 
+async def _printer_update_info(firmware_service, printer: Printer) -> FirmwareUpdateInfo:
+    """Whether *printer* has a firmware update, as ``GET /updates/{printer_id}`` reports it."""
     current_version = _current_version(printer.id)
     current_version = _current_version(printer.id)
 
 
     # Check for update
     # Check for update
@@ -266,8 +276,8 @@ class FirmwareUploadStartResponse(BaseModel):
 async def prepare_firmware_upload(
 async def prepare_firmware_upload(
     printer_id: int,
     printer_id: int,
     version: str | None = None,
     version: str | None = None,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
 ):
     """
     """
     Check prerequisites for uploading firmware to a printer.
     Check prerequisites for uploading firmware to a printer.
@@ -289,8 +299,8 @@ async def prepare_firmware_upload(
 async def start_firmware_upload(
 async def start_firmware_upload(
     printer_id: int,
     printer_id: int,
     version: str | None = None,
     version: str | None = None,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
 ):
 ):
     """
     """
     Start uploading firmware to a printer's SD card.
     Start uploading firmware to a printer's SD card.
@@ -299,8 +309,7 @@ async def start_firmware_upload(
     1. Downloads the firmware from Bambu Lab
     1. Downloads the firmware from Bambu Lab
     2. Uploads it to the printer's SD card via FTP
     2. Uploads it to the printer's SD card via FTP
 
 
-    Progress is broadcast via WebSocket with type "firmware_upload_progress".
-    Use GET /firmware/updates/{printer_id}/upload/status for polling fallback.
+    Poll GET /firmware/updates/{printer_id}/upload/status for progress.
 
 
     After upload completes, the user must trigger the update from the
     After upload completes, the user must trigger the update from the
     printer's screen (Settings > Firmware).
     printer's screen (Settings > Firmware).
@@ -333,21 +342,17 @@ async def start_firmware_upload(
 
 
     return FirmwareUploadStartResponse(
     return FirmwareUploadStartResponse(
         started=True,
         started=True,
-        message="Firmware upload started. Progress will be broadcast via WebSocket.",
+        message="Firmware upload started. Poll the upload status for progress.",
     )
     )
 
 
 
 
 @router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
 @router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
 async def get_firmware_upload_status(
 async def get_firmware_upload_status(
     printer_id: int,
     printer_id: int,
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
 ):
     """
     """
-    Get the current status of a firmware upload operation.
-
-    This is a polling fallback for clients that don't use WebSocket.
-    For real-time updates, connect to WebSocket and listen for
-    "firmware_upload_progress" messages.
+    Get the current status of a firmware upload operation (poll for progress).
     """
     """
     state = get_upload_state(printer_id)
     state = get_upload_state(printer_id)
     return FirmwareUploadStatusResponse(
     return FirmwareUploadStatusResponse(

+ 30 - 20
backend/app/api/routes/github_backup.py

@@ -24,8 +24,8 @@ from backend.app.schemas.github_backup import (
     GitHubRestorePreview,
     GitHubRestorePreview,
     GitHubRestoreRequest,
     GitHubRestoreRequest,
     GitHubRestoreResponse,
     GitHubRestoreResponse,
+    GitHubTestConnectionRequest,
     GitHubTestConnectionResponse,
     GitHubTestConnectionResponse,
-    ProviderType,
     RestoreCategory,
     RestoreCategory,
 )
 )
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.github_backup import github_backup_service
@@ -129,7 +129,7 @@ async def _count_cloud_accounts(db: AsyncSession) -> tuple[int, int]:
 
 
 @router.get("/cloud-accounts", response_model=CloudAccountCounts)
 @router.get("/cloud-accounts", response_model=CloudAccountCounts)
 async def get_cloud_accounts(
 async def get_cloud_accounts(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """How many cloud accounts the Cloud Profiles category would collect from.
     """How many cloud accounts the Cloud Profiles category would collect from.
@@ -172,7 +172,7 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
 
 
 @router.get("/config", response_model=GitHubBackupConfigResponse | None)
 @router.get("/config", response_model=GitHubBackupConfigResponse | None)
 async def get_config(
 async def get_config(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Get the current GitHub backup configuration."""
     """Get the current GitHub backup configuration."""
@@ -188,7 +188,7 @@ async def get_config(
 @router.post("/config", response_model=GitHubBackupConfigResponse)
 @router.post("/config", response_model=GitHubBackupConfigResponse)
 async def save_config(
 async def save_config(
     config_data: GitHubBackupConfigCreate,
     config_data: GitHubBackupConfigCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Create or update GitHub backup configuration.
     """Create or update GitHub backup configuration.
@@ -264,7 +264,7 @@ async def save_config(
 @router.patch("/config", response_model=GitHubBackupConfigResponse)
 @router.patch("/config", response_model=GitHubBackupConfigResponse)
 async def update_config(
 async def update_config(
     update_data: GitHubBackupConfigUpdate,
     update_data: GitHubBackupConfigUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Partially update GitHub backup configuration."""
     """Partially update GitHub backup configuration."""
@@ -325,7 +325,7 @@ async def update_config(
 
 
 @router.delete("/config")
 @router.delete("/config")
 async def delete_config(
 async def delete_config(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Delete the GitHub backup configuration and all logs."""
     """Delete the GitHub backup configuration and all logs."""
@@ -345,19 +345,25 @@ async def delete_config(
 
 
 @router.post("/test", response_model=GitHubTestConnectionResponse)
 @router.post("/test", response_model=GitHubTestConnectionResponse)
 async def test_connection(
 async def test_connection(
-    repo_url: str = Query(..., description="Repository URL"),
-    token: str = Query(..., description="Personal Access Token"),
-    provider: ProviderType = Query(default=ProviderType.GITHUB, description="Git provider key"),
+    body: GitHubTestConnectionRequest,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Test Git provider connection with provided credentials."""
     """Test Git provider connection with provided credentials."""
-    result = await github_backup_service.test_connection(repo_url, token, provider=provider)
+    from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+    # Checked like a saved repository URL (see _enforce_private_repo).
+    try:
+        assert_safe_lan_service_url(body.repo_url, label="Repository URL")
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+    result = await github_backup_service.test_connection(body.repo_url, body.token, provider=body.provider)
     return GitHubTestConnectionResponse(**result)
     return GitHubTestConnectionResponse(**result)
 
 
 
 
 @router.post("/test-stored", response_model=GitHubTestConnectionResponse)
 @router.post("/test-stored", response_model=GitHubTestConnectionResponse)
 async def test_stored_connection(
 async def test_stored_connection(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Test GitHub connection using stored configuration."""
     """Test GitHub connection using stored configuration."""
@@ -380,7 +386,7 @@ async def test_stored_connection(
 
 
 @router.post("/run", response_model=GitHubBackupTriggerResponse)
 @router.post("/run", response_model=GitHubBackupTriggerResponse)
 async def trigger_backup(
 async def trigger_backup(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Manually trigger a backup."""
     """Manually trigger a backup."""
@@ -400,7 +406,7 @@ async def trigger_backup(
 
 
 @router.get("/status", response_model=GitHubBackupStatus)
 @router.get("/status", response_model=GitHubBackupStatus)
 async def get_status(
 async def get_status(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Get current backup status."""
     """Get current backup status."""
@@ -433,7 +439,7 @@ async def get_status(
 @router.get("/commits", response_model=GitHubCommitListResponse)
 @router.get("/commits", response_model=GitHubCommitListResponse)
 async def list_commits(
 async def list_commits(
     limit: int = Query(default=20, ge=1, le=100),
     limit: int = Query(default=20, ge=1, le=100),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
 ):
 ):
     """List recent backup commits so the user can pick one to restore from."""
     """List recent backup commits so the user can pick one to restore from."""
@@ -450,8 +456,8 @@ async def list_commits(
 @router.get("/restore/preview", response_model=GitHubRestorePreview)
 @router.get("/restore/preview", response_model=GitHubRestorePreview)
 async def preview_restore(
 async def preview_restore(
     ref: str = Query(default="HEAD", pattern=REF_PATTERN),
     ref: str = Query(default="HEAD", pattern=REF_PATTERN),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
 ):
 ):
     """Report which categories a given backup commit contains."""
     """Report which categories a given backup commit contains."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -460,14 +466,16 @@ async def preview_restore(
     if not config:
     if not config:
         raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
         raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
 
 
-    preview = await github_restore_service.preview(db, config, ref=ref)
+    preview = await github_restore_service.preview(
+        db, config, ref=ref, as_admin=current_user is None or current_user.is_admin
+    )
     return GitHubRestorePreview(**preview)
     return GitHubRestorePreview(**preview)
 
 
 
 
 @router.post("/restore", response_model=GitHubRestoreResponse)
 @router.post("/restore", response_model=GitHubRestoreResponse)
 async def restore_backup(
 async def restore_backup(
     request: GitHubRestoreRequest,
     request: GitHubRestoreRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
 ):
 ):
     """Restore selected categories from one backup commit.
     """Restore selected categories from one backup commit.
@@ -513,6 +521,8 @@ async def restore_backup(
         ref=request.ref,
         ref=request.ref,
         categories=request.categories,
         categories=request.categories,
         overwrite_existing=request.overwrite_existing,
         overwrite_existing=request.overwrite_existing,
+        # A non-admin's restore keeps the sign-in and mail settings as they are.
+        as_admin=current_user is None or current_user.is_admin,
     )
     )
     return GitHubRestoreResponse(**restore_result)
     return GitHubRestoreResponse(**restore_result)
 
 
@@ -521,7 +531,7 @@ async def restore_backup(
 async def get_logs(
 async def get_logs(
     limit: int = Query(default=50, ge=1, le=200),
     limit: int = Query(default=50, ge=1, le=200),
     offset: int = Query(default=0, ge=0),
     offset: int = Query(default=0, ge=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Get backup logs."""
     """Get backup logs."""
@@ -559,7 +569,7 @@ async def get_logs(
 @router.delete("/logs")
 @router.delete("/logs")
 async def clear_logs(
 async def clear_logs(
     keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
     keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Clear backup logs, optionally keeping the most recent entries."""
     """Clear backup logs, optionally keeping the most recent entries."""

+ 145 - 37
backend/app/api/routes/groups.py

@@ -1,7 +1,7 @@
 """Group management API routes."""
 """Group management API routes."""
 
 
 from fastapi import APIRouter, Depends, HTTPException, status
 from fastapi import APIRouter, Depends, HTTPException, status
-from sqlalchemy import select
+from sqlalchemy import delete, insert, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
@@ -12,7 +12,10 @@ from backend.app.core.permissions import (
     PERMISSION_CATEGORIES,
     PERMISSION_CATEGORIES,
     Permission,
     Permission,
 )
 )
-from backend.app.models.group import Group
+from backend.app.core.printer_scope import group_location_names, group_printer_ids
+from backend.app.core.websocket import ws_manager
+from backend.app.models.group import Group, group_locations, group_printers
+from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.group import (
 from backend.app.schemas.group import (
     GroupCreate,
     GroupCreate,
@@ -34,6 +37,8 @@ router = APIRouter(prefix="/groups", tags=["groups"])
 # box in the group editor has nothing else to go on (#1894).
 # box in the group editor has nothing else to go on (#1894).
 _PERMISSION_LABEL_OVERRIDES: dict[Permission, str] = {
 _PERMISSION_LABEL_OVERRIDES: dict[Permission, str] = {
     Permission.USERS_READ_SLIM: "List User Names (id + username only)",
     Permission.USERS_READ_SLIM: "List User Names (id + username only)",
+    # "Start Unreviewed Queue" says nothing about what happens without it (#1620)
+    Permission.QUEUE_START_UNREVIEWED: "Print Without Review (else jobs wait for someone to start them)",
 }
 }
 
 
 
 
@@ -51,6 +56,108 @@ def _permission_label(perm: Permission) -> str:
     return perm.value
     return perm.value
 
 
 
 
+async def _printer_ids_by_group(db: AsyncSession) -> dict[int, list[int]]:
+    result = await db.execute(select(group_printers.c.group_id, group_printers.c.printer_id))
+    by_group: dict[int, list[int]] = {}
+    for group_id, printer_id in result.all():
+        by_group.setdefault(group_id, []).append(printer_id)
+    return {gid: sorted(pids) for gid, pids in by_group.items()}
+
+
+async def _locations_by_group(db: AsyncSession) -> dict[int, list[str]]:
+    result = await db.execute(select(group_locations.c.group_id, group_locations.c.location))
+    by_group: dict[int, list[str]] = {}
+    for group_id, location in result.all():
+        by_group.setdefault(group_id, []).append(location)
+    return {gid: sorted(locs) for gid, locs in by_group.items()}
+
+
+def _clean_locations(locations: list[str]) -> set[str]:
+    """Trimmed, non-empty location names; refuses ones too long to ever match a printer."""
+    cleaned = {loc.strip() for loc in locations if loc and loc.strip()}
+    if any(len(loc) > 100 for loc in cleaned):
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Location names are at most 100 characters",
+        )
+    return cleaned
+
+
+async def _apply_printer_scope(
+    db: AsyncSession,
+    group: Group,
+    restrict_printers: bool | None,
+    printer_ids: list[int] | None,
+    locations: list[str] | None = None,
+) -> bool:
+    """Validate and store a group's printer scope (#1727). Returns whether it changed.
+
+    The Administrators group can't be restricted: admins see every printer
+    regardless, so the setting would only mislead.
+    """
+    changed = False
+    if restrict_printers is not None and restrict_printers != bool(group.restrict_printers):
+        if restrict_printers and group.name == "Administrators":
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Administrators always see every printer",
+            )
+        group.restrict_printers = restrict_printers
+        changed = True
+    if printer_ids is not None:
+        wanted = set(printer_ids)
+        if wanted:
+            found = set((await db.execute(select(Printer.id).where(Printer.id.in_(wanted)))).scalars().all())
+            missing = sorted(wanted - found)
+            if missing:
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail=f"Invalid printers: {', '.join(str(pid) for pid in missing)}",
+                )
+        current = set(await group_printer_ids(db, group.id)) if group.id is not None else set()
+        if wanted != current:
+            if group.id is None:
+                await db.flush()
+            await db.execute(delete(group_printers).where(group_printers.c.group_id == group.id))
+            if wanted:
+                await db.execute(
+                    insert(group_printers), [{"group_id": group.id, "printer_id": pid} for pid in sorted(wanted)]
+                )
+            changed = True
+    if locations is not None:
+        # Not checked against existing printers: a location can be granted
+        # before its first printer is added there.
+        wanted_locations = _clean_locations(locations)
+        current_locations = set(await group_location_names(db, group.id)) if group.id is not None else set()
+        if wanted_locations != current_locations:
+            if group.id is None:
+                await db.flush()
+            await db.execute(delete(group_locations).where(group_locations.c.group_id == group.id))
+            if wanted_locations:
+                await db.execute(
+                    insert(group_locations),
+                    [{"group_id": group.id, "location": loc} for loc in sorted(wanted_locations)],
+                )
+            changed = True
+    return changed
+
+
+def _group_response(group: Group, printer_ids: list[int], locations: list[str], user_count: int) -> GroupResponse:
+    return GroupResponse(
+        id=group.id,
+        name=group.name,
+        description=group.description,
+        permissions=group.permissions or [],
+        is_system=group.is_system,
+        restrict_printers=bool(group.restrict_printers),
+        printer_ids=printer_ids,
+        locations=locations,
+        user_count=user_count,
+        created_at=group.created_at,
+        updated_at=group.updated_at,
+    )
+
+
 @router.get("/permissions", response_model=PermissionsListResponse)
 @router.get("/permissions", response_model=PermissionsListResponse)
 async def list_permissions(
 async def list_permissions(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
@@ -74,21 +181,16 @@ async def list_permissions(
 @router.get("/", response_model=list[GroupResponse])
 @router.get("/", response_model=list[GroupResponse])
 async def list_groups(
 async def list_groups(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """List all groups."""
     """List all groups."""
     result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
     result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
     groups = result.scalars().all()
     groups = result.scalars().all()
+    printers_by_group = await _printer_ids_by_group(db)
+    locations_by_group = await _locations_by_group(db)
     return [
     return [
-        GroupResponse(
-            id=group.id,
-            name=group.name,
-            description=group.description,
-            permissions=group.permissions or [],
-            is_system=group.is_system,
-            user_count=len(group.users),
-            created_at=group.created_at,
-            updated_at=group.updated_at,
+        _group_response(
+            group, printers_by_group.get(group.id, []), locations_by_group.get(group.id, []), len(group.users)
         )
         )
         for group in groups
         for group in groups
     ]
     ]
@@ -100,7 +202,7 @@ async def create_group(
     group_data: GroupCreate,
     group_data: GroupCreate,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Create a new group."""
     """Create a new group."""
     # Check if group name already exists
     # Check if group name already exists
@@ -124,28 +226,22 @@ async def create_group(
         description=group_data.description,
         description=group_data.description,
         permissions=group_data.permissions,
         permissions=group_data.permissions,
         is_system=False,  # User-created groups are not system groups
         is_system=False,  # User-created groups are not system groups
+        restrict_printers=False,
     )
     )
     db.add(group)
     db.add(group)
+    await db.flush()
+    await _apply_printer_scope(db, group, group_data.restrict_printers, group_data.printer_ids, group_data.locations)
     await db.commit()
     await db.commit()
     await db.refresh(group)
     await db.refresh(group)
 
 
-    return GroupResponse(
-        id=group.id,
-        name=group.name,
-        description=group.description,
-        permissions=group.permissions or [],
-        is_system=group.is_system,
-        user_count=0,
-        created_at=group.created_at,
-        updated_at=group.updated_at,
-    )
+    return _group_response(group, await group_printer_ids(db, group.id), await group_location_names(db, group.id), 0)
 
 
 
 
 @router.get("/{group_id}", response_model=GroupDetailResponse)
 @router.get("/{group_id}", response_model=GroupDetailResponse)
 async def get_group(
 async def get_group(
     group_id: int,
     group_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Get a group by ID with user list. Read-only — gated on
     """Get a group by ID with user list. Read-only — gated on
     ``GROUPS_READ`` only."""
     ``GROUPS_READ`` only."""
@@ -163,6 +259,9 @@ async def get_group(
         description=group.description,
         description=group.description,
         permissions=group.permissions or [],
         permissions=group.permissions or [],
         is_system=group.is_system,
         is_system=group.is_system,
+        restrict_printers=bool(group.restrict_printers),
+        printer_ids=await group_printer_ids(db, group.id),
+        locations=await group_location_names(db, group.id),
         user_count=len(group.users),
         user_count=len(group.users),
         created_at=group.created_at,
         created_at=group.created_at,
         updated_at=group.updated_at,
         updated_at=group.updated_at,
@@ -176,7 +275,7 @@ async def update_group(
     group_data: GroupUpdate,
     group_data: GroupUpdate,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Update a group."""
     """Update a group."""
     result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
     result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
@@ -226,18 +325,19 @@ async def update_group(
             )
             )
         group.permissions = group_data.permissions
         group.permissions = group_data.permissions
 
 
+    scope_changed = await _apply_printer_scope(
+        db, group, group_data.restrict_printers, group_data.printer_ids, group_data.locations
+    )
+
     await db.commit()
     await db.commit()
     await db.refresh(group)
     await db.refresh(group)
+    if scope_changed or group_data.permissions is not None:
+        # Printers, or websocket:connect itself (a socket of a user who lost it
+        # is closed)
+        await ws_manager.refresh_printer_scopes()
 
 
-    return GroupResponse(
-        id=group.id,
-        name=group.name,
-        description=group.description,
-        permissions=group.permissions or [],
-        is_system=group.is_system,
-        user_count=len(group.users),
-        created_at=group.created_at,
-        updated_at=group.updated_at,
+    return _group_response(
+        group, await group_printer_ids(db, group.id), await group_location_names(db, group.id), len(group.users)
     )
     )
 
 
 
 
@@ -246,7 +346,7 @@ async def delete_group(
     group_id: int,
     group_id: int,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Delete a group (non-system groups only)."""
     """Delete a group (non-system groups only)."""
     result = await db.execute(select(Group).where(Group.id == group_id))
     result = await db.execute(select(Group).where(Group.id == group_id))
@@ -263,8 +363,13 @@ async def delete_group(
             detail="Cannot delete system groups",
             detail="Cannot delete system groups",
         )
         )
 
 
+    # SQLite doesn't enforce the FK cascade
+    await db.execute(delete(group_printers).where(group_printers.c.group_id == group_id))
+    await db.execute(delete(group_locations).where(group_locations.c.group_id == group_id))
     await db.delete(group)
     await db.delete(group)
     await db.commit()
     await db.commit()
+    # Its members may lose printers, or websocket:connect itself
+    await ws_manager.refresh_printer_scopes()
 
 
 
 
 @router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
 @router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -273,7 +378,7 @@ async def add_user_to_group(
     user_id: int,
     user_id: int,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Add a user to a group."""
     """Add a user to a group."""
     # Get group with users
     # Get group with users
@@ -303,6 +408,7 @@ async def add_user_to_group(
 
 
     group.users.append(user)
     group.users.append(user)
     await db.commit()
     await db.commit()
+    await ws_manager.refresh_printer_scopes()
 
 
 
 
 @router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
 @router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -311,7 +417,7 @@ async def remove_user_from_group(
     user_id: int,
     user_id: int,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Remove a user from a group."""
     """Remove a user from a group."""
     # Get group with users
     # Get group with users
@@ -341,3 +447,5 @@ async def remove_user_from_group(
 
 
     group.users.remove(user)
     group.users.remove(user)
     await db.commit()
     await db.commit()
+    # They may lose printers, or websocket:connect itself
+    await ws_manager.refresh_printer_scopes()

+ 58 - 40
backend/app/api/routes/ha_sensors.py

@@ -6,9 +6,14 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.printer_ha_sensor import PrinterHASensor
 from backend.app.models.printer_ha_sensor import PrinterHASensor
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -49,14 +54,25 @@ async def _refresh_quietly(sensor: PrinterHASensor, db: AsyncSession) -> None:
         logger.warning("Could not read %s right after saving it: %s", sensor.entity_id, e)
         logger.warning("Could not read %s right after saving it: %s", sensor.entity_id, e)
 
 
 
 
+async def _load_sensor(db: AsyncSession, sensor_id: int, printer_scope: PrinterScope) -> PrinterHASensor:
+    """The sensor, if its printer is one the caller may use; otherwise the 404 of a missing one."""
+    sensor = await db.get(PrinterHASensor, sensor_id)
+    if not sensor or not printer_scope.allows(sensor.printer_id):
+        raise HTTPException(404, "Sensor not found")
+    return sensor
+
+
 @router.get("/", response_model=list[PrinterHASensorResponse])
 @router.get("/", response_model=list[PrinterHASensorResponse])
 async def list_ha_sensors(
 async def list_ha_sensors(
     printer_id: int | None = None,
     printer_id: int | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
     _: User | None = _READ,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """List configured sensors, grouped by printer and in display order."""
     """List configured sensors, grouped by printer and in display order."""
     query = select(PrinterHASensor)
     query = select(PrinterHASensor)
+    if (scope_clause := printer_scope.where(PrinterHASensor.printer_id)) is not None:
+        query = query.where(scope_clause)
     if printer_id is not None:
     if printer_id is not None:
         query = query.where(PrinterHASensor.printer_id == printer_id)
         query = query.where(PrinterHASensor.printer_id == printer_id)
     result = await db.execute(query.order_by(PrinterHASensor.printer_id, PrinterHASensor.sort_order))
     result = await db.execute(query.order_by(PrinterHASensor.printer_id, PrinterHASensor.sort_order))
@@ -67,7 +83,7 @@ async def list_ha_sensors(
 @router.get("/entities", response_model=list[HADisplayEntity])
 @router.get("/entities", response_model=list[HADisplayEntity])
 async def list_bindable_entities(
 async def list_bindable_entities(
     search: str | None = None,
     search: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
     _: User | None = _READ,
 ):
 ):
     """List the Home Assistant entities that can be bound to a printer."""
     """List the Home Assistant entities that can be bound to a printer."""
@@ -87,8 +103,8 @@ async def list_bindable_entities(
 @router.get("/by-printer/{printer_id}/readings", response_model=list[PrinterHASensorReading])
 @router.get("/by-printer/{printer_id}/readings", response_model=list[PrinterHASensorReading])
 async def get_printer_sensor_readings(
 async def get_printer_sensor_readings(
     printer_id: int,
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = _READ,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
 ):
     """Live state of a printer's card-visible sensors.
     """Live state of a printer's card-visible sensors.
 
 
@@ -97,44 +113,50 @@ async def get_printer_sensor_readings(
     to its last persisted state, marked unreachable, rather than vanishing
     to its last persisted state, marked unreachable, rather than vanishing
     from the card on every restart.
     from the card on every restart.
     """
     """
+    return [_reading(sensor) for sensor in await _card_sensors(db, PrinterHASensor.printer_id == printer_id)]
+
+
+async def _card_sensors(db: AsyncSession, printer_clause) -> list[PrinterHASensor]:
+    """The card-visible sensors matching *printer_clause*, in card order."""
     result = await db.execute(
     result = await db.execute(
         select(PrinterHASensor)
         select(PrinterHASensor)
         .where(
         .where(
-            PrinterHASensor.printer_id == printer_id,
+            printer_clause,
             PrinterHASensor.show_on_printer_card.is_(True),
             PrinterHASensor.show_on_printer_card.is_(True),
         )
         )
         .order_by(PrinterHASensor.sort_order, PrinterHASensor.id)
         .order_by(PrinterHASensor.sort_order, PrinterHASensor.id)
     )
     )
+    return list(result.scalars().all())
 
 
-    readings = []
-    for sensor in result.scalars().all():
-        cached = ha_sensor_manager.get_reading(sensor.id)
-        readings.append(
-            PrinterHASensorReading(
-                id=sensor.id,
-                name=sensor.name,
-                entity_id=sensor.entity_id,
-                kind=sensor.kind,
-                device_class=sensor.device_class,
-                unit=sensor.unit,
-                state=cached.state if cached else sensor.last_state,
-                value=cached.value if cached else None,
-                alerting=cached.alerting if cached else False,
-                block_print=sensor.block_print,
-                reachable=cached.reachable if cached else False,
-                last_changed=sensor.last_changed,
-            )
-        )
-    return readings
+
+def _reading(sensor: PrinterHASensor) -> PrinterHASensorReading:
+    """A sensor's live reading from the poller's cache, else its last persisted state."""
+    cached = ha_sensor_manager.get_reading(sensor.id)
+    return PrinterHASensorReading(
+        id=sensor.id,
+        name=sensor.name,
+        entity_id=sensor.entity_id,
+        kind=sensor.kind,
+        device_class=sensor.device_class,
+        unit=sensor.unit,
+        state=cached.state if cached else sensor.last_state,
+        value=cached.value if cached else None,
+        alerting=cached.alerting if cached else False,
+        block_print=sensor.block_print,
+        reachable=cached.reachable if cached else False,
+        last_changed=sensor.last_changed,
+    )
 
 
 
 
 @router.post("/", response_model=PrinterHASensorResponse)
 @router.post("/", response_model=PrinterHASensorResponse)
 async def create_ha_sensor(
 async def create_ha_sensor(
     data: PrinterHASensorCreate,
     data: PrinterHASensorCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _CREATE,
     _: User | None = _CREATE,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Bind a Home Assistant entity to a printer."""
     """Bind a Home Assistant entity to a printer."""
+    printer_scope.ensure(data.printer_id)
     printer = await db.get(Printer, data.printer_id)
     printer = await db.get(Printer, data.printer_id)
     if not printer:
     if not printer:
         raise HTTPException(404, "Printer not found")
         raise HTTPException(404, "Printer not found")
@@ -165,25 +187,22 @@ async def create_ha_sensor(
 @router.get("/{sensor_id}", response_model=PrinterHASensorResponse)
 @router.get("/{sensor_id}", response_model=PrinterHASensorResponse)
 async def get_ha_sensor(
 async def get_ha_sensor(
     sensor_id: int,
     sensor_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
     _: User | None = _READ,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
-    sensor = await db.get(PrinterHASensor, sensor_id)
-    if not sensor:
-        raise HTTPException(404, "Sensor not found")
-    return sensor
+    return await _load_sensor(db, sensor_id, printer_scope)
 
 
 
 
 @router.patch("/{sensor_id}", response_model=PrinterHASensorResponse)
 @router.patch("/{sensor_id}", response_model=PrinterHASensorResponse)
 async def update_ha_sensor(
 async def update_ha_sensor(
     sensor_id: int,
     sensor_id: int,
     data: PrinterHASensorUpdate,
     data: PrinterHASensorUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _UPDATE,
     _: User | None = _UPDATE,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
-    sensor = await db.get(PrinterHASensor, sensor_id)
-    if not sensor:
-        raise HTTPException(404, "Sensor not found")
+    sensor = await _load_sensor(db, sensor_id, printer_scope)
 
 
     updates = data.model_dump(exclude_unset=True)
     updates = data.model_dump(exclude_unset=True)
 
 
@@ -224,12 +243,11 @@ async def update_ha_sensor(
 @router.delete("/{sensor_id}")
 @router.delete("/{sensor_id}")
 async def delete_ha_sensor(
 async def delete_ha_sensor(
     sensor_id: int,
     sensor_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _DELETE,
     _: User | None = _DELETE,
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
-    sensor = await db.get(PrinterHASensor, sensor_id)
-    if not sensor:
-        raise HTTPException(404, "Sensor not found")
+    sensor = await _load_sensor(db, sensor_id, printer_scope)
 
 
     name = sensor.name
     name = sensor.name
     await db.delete(sensor)
     await db.delete(sensor)

File diff suppressed because it is too large
+ 383 - 81
backend/app/api/routes/inventory.py


+ 71 - 21
backend/app/api/routes/kprofiles.py

@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePrinterPermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
 from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
@@ -21,6 +21,7 @@ from backend.app.schemas.kprofile import (
     KProfileNoteResponse,
     KProfileNoteResponse,
     KProfilesResponse,
     KProfilesResponse,
 )
 )
+from backend.app.services.bambu_mqtt import CALI_ACK_UNCONFIRMED, KProfileReadError
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -32,8 +33,8 @@ router = APIRouter(prefix="/printers/{printer_id}/kprofiles", tags=["kprofiles"]
 async def get_kprofiles(
 async def get_kprofiles(
     printer_id: int,
     printer_id: int,
     nozzle_diameter: str = "0.4",
     nozzle_diameter: str = "0.4",
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
 ):
     """Get K-profiles from a printer.
     """Get K-profiles from a printer.
 
 
@@ -52,8 +53,12 @@ async def get_kprofiles(
     if not client or not client.state.connected:
     if not client or not client.state.connected:
         raise HTTPException(400, "Printer not connected")
         raise HTTPException(400, "Printer not connected")
 
 
-    # Request K-profiles from printer
-    profiles = await client.get_kprofiles(nozzle_diameter=nozzle_diameter)
+    # Request K-profiles from printer. No answer is an error, not an empty
+    # table: that one means the printer has none for this nozzle (#3404).
+    try:
+        profiles = await client.get_kprofiles(nozzle_diameter=nozzle_diameter)
+    except KProfileReadError as e:
+        raise HTTPException(504, "The printer did not answer the K-profile request") from e
 
 
     # Convert from MQTT dataclass to Pydantic schema
     # Convert from MQTT dataclass to Pydantic schema
     return KProfilesResponse(
     return KProfilesResponse(
@@ -81,8 +86,8 @@ async def get_kprofiles(
 async def set_kprofile(
 async def set_kprofile(
     printer_id: int,
     printer_id: int,
     profile: KProfileCreate,
     profile: KProfileCreate,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
 ):
     """Create or update a K-profile on the printer.
     """Create or update a K-profile on the printer.
 
 
@@ -120,6 +125,8 @@ async def set_kprofile(
     from backend.app.utils.printer_models import is_dual_nozzle_model
     from backend.app.utils.printer_models import is_dual_nozzle_model
 
 
     is_dual_nozzle = client._is_dual_nozzle or is_dual_nozzle_model(printer.model)
     is_dual_nozzle = client._is_dual_nozzle or is_dual_nozzle_model(printer.model)
+    # Whether the printer answered the delete of a delete-and-add edit.
+    delete_confirmed = True
 
 
     if is_edit and is_dual_nozzle:
     if is_edit and is_dual_nozzle:
         # Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id
         # Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id
@@ -151,6 +158,7 @@ async def set_kprofile(
         ok, detail = await client.await_cali_ack(delete_success)
         ok, detail = await client.await_cali_ack(delete_success)
         if not ok:
         if not ok:
             raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
             raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
+        delete_confirmed = detail != CALI_ACK_UNCONFIRMED
 
 
         # Wait for printer to process the delete before adding
         # Wait for printer to process the delete before adding
         await asyncio.sleep(0.5)
         await asyncio.sleep(0.5)
@@ -188,17 +196,55 @@ async def set_kprofile(
     ok, detail = await client.await_cali_ack(success)
     ok, detail = await client.await_cali_ack(success)
     if not ok:
     if not ok:
         raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
         raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
+    confirmed = detail != CALI_ACK_UNCONFIRMED
 
 
-    message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
-    return {"success": True, "message": message}
+    if is_edit and not is_dual_nozzle:
+        # The edit deleted the profile and added it again: read the table back
+        # to see the edited one landed. A read the printer did not answer
+        # leaves it to the acks: the write has gone out by then. Same pause as
+        # before the add.
+        await asyncio.sleep(0.5)
+        try:
+            table = await client.get_kprofiles(nozzle_diameter=profile.nozzle_diameter)
+        except KProfileReadError as e:
+            logger.warning("[API] Edit: could not read the K-profile table back: %s", e)
+            table = None
+        if table is not None:
+            if not any(_is_saved_profile(stored, profile) for stored in table):
+                raise HTTPException(
+                    502,
+                    "The edited K-profile is not in the printer's table. Check the list before adding it again.",
+                )
+            # The old one may still be there when its delete went unanswered.
+            confirmed = delete_confirmed
+
+    if not confirmed:
+        message = "K-profile sent; the printer did not confirm it"
+    else:
+        message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
+    return {"success": True, "confirmed": confirmed, "message": message}
+
+
+def _is_saved_profile(stored, profile: KProfileCreate) -> bool:
+    """Whether a profile read from the printer is the one just written."""
+    try:
+        same_k = abs(float(stored.k_value) - float(profile.k_value)) < 1e-6
+    except (TypeError, ValueError):
+        return False
+    return (
+        same_k
+        and stored.name == profile.name
+        and stored.filament_id == profile.filament_id
+        and stored.extruder_id == profile.extruder_id
+    )
 
 
 
 
 @router.post("/batch", response_model=dict)
 @router.post("/batch", response_model=dict)
 async def set_kprofiles_batch(
 async def set_kprofiles_batch(
     printer_id: int,
     printer_id: int,
     profiles: list[KProfileCreate],
     profiles: list[KProfileCreate],
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
 ):
     """Create multiple K-profiles in a single command (for dual-nozzle).
     """Create multiple K-profiles in a single command (for dual-nozzle).
 
 
@@ -253,15 +299,19 @@ async def set_kprofiles_batch(
     if not ok:
     if not ok:
         raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
         raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
 
 
-    return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
+    return {
+        "success": True,
+        "confirmed": detail != CALI_ACK_UNCONFIRMED,
+        "message": f"Added {len(profiles)} K-profiles",
+    }
 
 
 
 
 @router.delete("/", response_model=dict)
 @router.delete("/", response_model=dict)
 async def delete_kprofile(
 async def delete_kprofile(
     printer_id: int,
     printer_id: int,
     profile: KProfileDelete,
     profile: KProfileDelete,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
 ):
     """Delete a K-profile from the printer.
     """Delete a K-profile from the printer.
 
 
@@ -304,14 +354,14 @@ async def delete_kprofile(
     # Wait for printer to process the delete before frontend refetches
     # Wait for printer to process the delete before frontend refetches
     await asyncio.sleep(0.5)
     await asyncio.sleep(0.5)
 
 
-    return {"success": True, "message": "K-profile deleted successfully"}
+    return {"success": True, "confirmed": detail != CALI_ACK_UNCONFIRMED, "message": "K-profile deleted successfully"}
 
 
 
 
 @router.get("/notes", response_model=KProfileNoteResponse)
 @router.get("/notes", response_model=KProfileNoteResponse)
 async def get_kprofile_notes(
 async def get_kprofile_notes(
     printer_id: int,
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
 ):
     """Get all K-profile notes for a printer.
     """Get all K-profile notes for a printer.
 
 
@@ -338,8 +388,8 @@ async def get_kprofile_notes(
 async def set_kprofile_note(
 async def set_kprofile_note(
     printer_id: int,
     printer_id: int,
     note_data: KProfileNote,
     note_data: KProfileNote,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
 ):
     """Set or update a note for a K-profile.
     """Set or update a note for a K-profile.
 
 
@@ -387,8 +437,8 @@ async def set_kprofile_note(
 async def delete_kprofile_note(
 async def delete_kprofile_note(
     printer_id: int,
     printer_id: int,
     setting_id: str,
     setting_id: str,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
 ):
     """Delete a note for a K-profile.
     """Delete a note for a K-profile.
 
 

+ 129 - 0
backend/app/api/routes/label_templates.py

@@ -0,0 +1,129 @@
+"""Saved custom label sheets (#3296).
+
+A saved template is a named sheet layout, listed in the print dialog for
+everyone on the install. Printing does not read this table: the dialog sends
+the layout itself with the print request, so anyone who can print can use a
+saved template or a one-off custom sheet, and only saving, editing and
+deleting need ``inventory:update`` (the same as storage locations).
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, HTTPException, Response
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.label_template import LabelTemplate
+from backend.app.models.user import User
+from backend.app.schemas.label_template import (
+    LabelTemplateIn,
+    LabelTemplateResponse,
+    SheetLayoutResponse,
+)
+from backend.app.utils.natural_sort import natural_sort_key
+
+router = APIRouter(prefix="/label-templates", tags=["labels"])
+
+DUPLICATE_NAME = "A label template with this name already exists"
+
+_LAYOUT_COLUMNS = tuple(SheetLayoutResponse.model_fields)
+
+
+def _to_response(template: LabelTemplate) -> LabelTemplateResponse:
+    return LabelTemplateResponse(
+        id=template.id,
+        name=template.name,
+        layout=SheetLayoutResponse(**{col: getattr(template, col) for col in _LAYOUT_COLUMNS}),
+        created_at=template.created_at,
+        updated_at=template.updated_at,
+    )
+
+
+def _apply(template: LabelTemplate, data: LabelTemplateIn) -> None:
+    template.name = data.name
+    template.name_key = data.name_key
+    for col, value in data.layout.model_dump().items():
+        setattr(template, col, value)
+
+
+async def _name_taken(db: AsyncSession, name_key: str, exclude_id: int | None = None) -> bool:
+    query = select(LabelTemplate.id).where(LabelTemplate.name_key == name_key)
+    if exclude_id is not None:
+        query = query.where(LabelTemplate.id != exclude_id)
+    return (await db.execute(query)).first() is not None
+
+
+async def _commit(db: AsyncSession, template: LabelTemplate) -> None:
+    try:
+        await db.commit()
+    except IntegrityError as exc:
+        # Two saves under the same name at once: the unique index catches the
+        # one the lookup above could not.
+        await db.rollback()
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
+    await db.refresh(template)
+
+
+@router.get("", response_model=list[LabelTemplateResponse])
+async def list_label_templates(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """All saved label templates, in natural name order."""
+    result = await db.execute(select(LabelTemplate))
+    templates = sorted(result.scalars().all(), key=lambda t: natural_sort_key(t.name))
+    return [_to_response(t) for t in templates]
+
+
+@router.post("", response_model=LabelTemplateResponse, status_code=201)
+async def create_label_template(
+    data: LabelTemplateIn,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Save a custom sheet layout under a name."""
+    if await _name_taken(db, data.name_key):
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
+    template = LabelTemplate()
+    _apply(template, data)
+    db.add(template)
+    await _commit(db, template)
+    return _to_response(template)
+
+
+@router.put("/{template_id}", response_model=LabelTemplateResponse)
+async def replace_label_template(
+    template_id: int,
+    data: LabelTemplateIn,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Rename a saved template or change its layout. The body is the whole
+    template, as the dialog's form always holds all of it."""
+    template = await db.get(LabelTemplate, template_id)
+    if template is None:
+        raise HTTPException(status_code=404, detail="Label template not found")
+    if await _name_taken(db, data.name_key, exclude_id=template_id):
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
+    _apply(template, data)
+    await _commit(db, template)
+    return _to_response(template)
+
+
+@router.delete("/{template_id}", status_code=204)
+async def delete_label_template(
+    template_id: int,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Delete a saved template. Labels already printed with it are unaffected."""
+    template = await db.get(LabelTemplate, template_id)
+    if template is None:
+        raise HTTPException(status_code=404, detail="Label template not found")
+    await db.delete(template)
+    await db.commit()
+    return Response(status_code=204)

+ 55 - 15
backend/app/api/routes/labels.py

@@ -6,7 +6,8 @@ Two endpoints, one per inventory backend:
 - ``POST /spoolman/labels``   — Spoolman-backed spools
 - ``POST /spoolman/labels``   — Spoolman-backed spools
 
 
 Both accept ``{spool_ids: [int], template: str, starting_position: int}`` plus
 Both accept ``{spool_ids: [int], template: str, starting_position: int}`` plus
-the lines to print (``fields``) and the output ``format``: a PDF, or PNGs for
+the lines to print (``fields``) and the output ``format``. ``template`` is a
+built-in name, or ``"custom"`` with the sheet itself in ``layout`` (#3296): a PDF, or PNGs for
 label-printer software that takes images — one PNG as is, several in a ZIP.
 label-printer software that takes images — one PNG as is, several in a ZIP.
 Each has a ``/preview`` sibling that renders one label as a PNG for the
 Each has a ``/preview`` sibling that renders one label as a PNG for the
 picker (#2981).
 picker (#2981).
@@ -37,16 +38,19 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
 from backend.app.models.user import User
 from backend.app.models.user import User
+from backend.app.schemas.label_template import SheetLayoutSpec
 from backend.app.services.label_renderer import (
 from backend.app.services.label_renderer import (
     ALL_LABEL_FIELDS,
     ALL_LABEL_FIELDS,
     DEFAULT_LABEL_FIELDS,
     DEFAULT_LABEL_FIELDS,
     LabelData,
     LabelData,
     LabelField,
     LabelField,
+    Template,
     TemplateName,
     TemplateName,
     get_sheet_capacity,
     get_sheet_capacity,
     pdf_to_pngs,
     pdf_to_pngs,
     render_label_preview_pdf,
     render_label_preview_pdf,
     render_labels,
     render_labels,
+    sheet_page_count,
 )
 )
 from backend.app.services.spoolman import get_spoolman_client
 from backend.app.services.spoolman import get_spoolman_client
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.http import build_content_disposition
@@ -59,6 +63,14 @@ router = APIRouter(tags=["labels"])
 # largest realistic batch (an Avery sheet at 30/page × ~10 pages).
 # largest realistic batch (an Avery sheet at 30/page × ~10 pages).
 MAX_LABELS_PER_REQUEST = 500
 MAX_LABELS_PER_REQUEST = 500
 
 
+# PNG export of sheets: pages × dpi. A sheet page is a whole A4 or Letter
+# page, rasterised under the process-wide PDFium lock that PDF thumbnails also
+# wait on, so a custom one-label-per-page sheet of 500 spools at 600 dpi would
+# hold it for minutes. 15 000 is 25 pages at 600 dpi, 50 at 300 and 73 at 203;
+# the built-in sheets need at most 24 pages (500 spools on an L7160), so they
+# always fit. Mirrored in the dialog (labelSheetLayout.ts).
+MAX_PNG_SHEET_PAGES_X_DPI = 15_000
+
 # Preview resolution. High enough that the browser's downscale stays sharp on
 # Preview resolution. High enough that the browser's downscale stays sharp on
 # a HiDPI screen; a label is a few centimetres, so the PNG stays small.
 # a HiDPI screen; a label is a few centimetres, so the PNG stays small.
 PREVIEW_DPI = 300
 PREVIEW_DPI = 300
@@ -69,16 +81,34 @@ SpoolId = Annotated[int, Field(gt=0)]
 
 
 
 
 class LabelOptions(BaseModel):
 class LabelOptions(BaseModel):
-    template: TemplateName
+    template: TemplateName | Literal["custom"]
+    # The sheet, when ``template`` is "custom" (#3296). A saved template is
+    # sent this way too: the dialog holds its layout, so printing never reads
+    # the saved-template table.
+    layout: SheetLayoutSpec | None = None
     # Black-and-white thermal printers: drop the colour swatch (prints as a
     # Black-and-white thermal printers: drop the colour swatch (prints as a
     # muddy grey block) and widen the text column instead (#1870).
     # muddy grey block) and widen the text column instead (#1870).
     monochrome: bool = False
     monochrome: bool = False
     # The lines to print (#2981). Omitted: what labels always carried.
     # The lines to print (#2981). Omitted: what labels always carried.
     fields: list[LabelField] | None = Field(default=None, max_length=len(ALL_LABEL_FIELDS))
     fields: list[LabelField] | None = Field(default=None, max_length=len(ALL_LABEL_FIELDS))
 
 
+    @model_validator(mode="after")
+    def layout_only_for_custom(self) -> LabelOptions:
+        if (self.template == "custom") != (self.layout is not None):
+            raise ValueError('layout is required for template "custom" and not allowed otherwise')
+        return self
+
     def field_set(self) -> frozenset[LabelField]:
     def field_set(self) -> frozenset[LabelField]:
         return DEFAULT_LABEL_FIELDS if self.fields is None else frozenset(self.fields)
         return DEFAULT_LABEL_FIELDS if self.fields is None else frozenset(self.fields)
 
 
+    def resolved_template(self) -> Template:
+        if self.layout is not None:
+            return self.layout.to_layout()
+        if self.template == "custom":
+            # layout_only_for_custom refuses this, so a validated request never gets here.
+            raise ValueError('layout is required for template "custom"')
+        return self.template
+
 
 
 class LabelRequest(LabelOptions):
 class LabelRequest(LabelOptions):
     spool_ids: list[SpoolId] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
     spool_ids: list[SpoolId] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
@@ -90,13 +120,23 @@ class LabelRequest(LabelOptions):
 
 
     @model_validator(mode="after")
     @model_validator(mode="after")
     def validate_starting_position(self) -> LabelRequest:
     def validate_starting_position(self) -> LabelRequest:
-        capacity = get_sheet_capacity(self.template)
+        if (self.template == "custom") != (self.layout is not None):
+            return self  # layout_only_for_custom reports this one
+        capacity = get_sheet_capacity(self.resolved_template())
         if capacity is None:
         if capacity is None:
             if self.starting_position != 1:
             if self.starting_position != 1:
                 raise ValueError("starting_position is only supported for sheet label templates")
                 raise ValueError("starting_position is only supported for sheet label templates")
             return self
             return self
         if self.starting_position > capacity:
         if self.starting_position > capacity:
             raise ValueError(f"starting_position must be between 1 and {capacity} for template {self.template}")
             raise ValueError(f"starting_position must be between 1 and {capacity} for template {self.template}")
+        if self.format == "png":
+            pages = sheet_page_count(self.resolved_template(), len(self.spool_ids), self.starting_position) or 0
+            if pages * self.dpi > MAX_PNG_SHEET_PAGES_X_DPI:
+                max_pages = MAX_PNG_SHEET_PAGES_X_DPI // self.dpi
+                raise ValueError(
+                    f"PNG export is limited to {max_pages} sheet pages at {self.dpi} dpi, and this is {pages}. "
+                    "Use PDF, a lower resolution or fewer spools."
+                )
         return self
         return self
 
 
 
 
@@ -202,8 +242,9 @@ def _stream_pdf(pdf: bytes, filename: str) -> StreamingResponse:
 
 
 
 
 async def _render_response(body: LabelRequest, data_list: list[LabelData], filename_stem: str) -> Response:
 async def _render_response(body: LabelRequest, data_list: list[LabelData], filename_stem: str) -> Response:
+    template = body.resolved_template()
     pdf = render_labels(
     pdf = render_labels(
-        body.template,
+        template,
         data_list,
         data_list,
         monochrome=body.monochrome,
         monochrome=body.monochrome,
         starting_position=body.starting_position,
         starting_position=body.starting_position,
@@ -226,7 +267,7 @@ async def _render_response(body: LabelRequest, data_list: list[LabelData], filen
 
 
     # A roll template has one page per spool, so its files are named after
     # A roll template has one page per spool, so its files are named after
     # the spool; a sheet's pages are numbered.
     # the spool; a sheet's pages are numbered.
-    if get_sheet_capacity(body.template) is None:
+    if get_sheet_capacity(template) is None:
         names = [f"label-{d.spool_id}.png" for d in data_list]
         names = [f"label-{d.spool_id}.png" for d in data_list]
     else:
     else:
         names = [f"sheet-{n}.png" for n in range(1, len(pngs) + 1)]
         names = [f"sheet-{n}.png" for n in range(1, len(pngs) + 1)]
@@ -246,7 +287,7 @@ async def _render_response(body: LabelRequest, data_list: list[LabelData], filen
 
 
 
 
 async def _render_preview(body: LabelPreviewRequest, data: LabelData) -> Response:
 async def _render_preview(body: LabelPreviewRequest, data: LabelData) -> Response:
-    pdf = render_label_preview_pdf(body.template, data, monochrome=body.monochrome, fields=body.field_set())
+    pdf = render_label_preview_pdf(body.resolved_template(), data, monochrome=body.monochrome, fields=body.field_set())
     pngs = await asyncio.to_thread(pdf_to_pngs, pdf, PREVIEW_DPI)
     pngs = await asyncio.to_thread(pdf_to_pngs, pdf, PREVIEW_DPI)
     return Response(content=pngs[0], media_type="image/png", headers={"Cache-Control": "no-store"})
     return Response(content=pngs[0], media_type="image/png", headers={"Cache-Control": "no-store"})
 
 
@@ -268,10 +309,9 @@ async def _local_label_data(spool_ids: list[int], request: Request, db: AsyncSes
 
 
 
 
 async def _spoolman_label_data(spool_ids: list[int], request: Request, db: AsyncSession) -> list[LabelData]:
 async def _spoolman_label_data(spool_ids: list[int], request: Request, db: AsyncSession) -> list[LabelData]:
-    """The Spoolman client doesn't expose a per-id endpoint, so this fetches the
-    full spool list and filters in-memory. For typical libraries (~50 spools)
-    that's negligible; for very large libraries this is the trade-off until
-    Spoolman gains a bulk filter.
+    """Fetches the full spool list once and filters in-memory, rather than one
+    request per id. Archived spools included: Spoolman leaves them out of its
+    plain list, and the Inventory page can show and select them (SM-C12).
     """
     """
     spoolman_on = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
     spoolman_on = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
     if not spoolman_on:
     if not spoolman_on:
@@ -282,7 +322,7 @@ async def _spoolman_label_data(spool_ids: list[int], request: Request, db: Async
         raise HTTPException(503, "Spoolman not reachable")
         raise HTTPException(503, "Spoolman not reachable")
 
 
     try:
     try:
-        all_spools = await client.get_spools()
+        all_spools = await client.get_all_spools(allow_archived=True)
     except Exception as exc:
     except Exception as exc:
         logger.warning("Spoolman fetch failed during label render: %s", exc)
         logger.warning("Spoolman fetch failed during label render: %s", exc)
         raise HTTPException(502, "Failed to fetch spools from Spoolman") from exc
         raise HTTPException(502, "Failed to fetch spools from Spoolman") from exc
@@ -300,7 +340,7 @@ async def _spoolman_label_data(spool_ids: list[int], request: Request, db: Async
 async def render_local_inventory_labels(
 async def render_local_inventory_labels(
     body: LabelRequest,
     body: LabelRequest,
     request: Request,
     request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> Response:
 ) -> Response:
     """Render labels for spools in the local inventory."""
     """Render labels for spools in the local inventory."""
@@ -312,7 +352,7 @@ async def render_local_inventory_labels(
 async def preview_local_inventory_label(
 async def preview_local_inventory_label(
     body: LabelPreviewRequest,
     body: LabelPreviewRequest,
     request: Request,
     request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> Response:
 ) -> Response:
     """One label for a local spool, as a PNG, for the picker's preview."""
     """One label for a local spool, as a PNG, for the picker's preview."""
@@ -324,7 +364,7 @@ async def preview_local_inventory_label(
 async def render_spoolman_labels(
 async def render_spoolman_labels(
     body: LabelRequest,
     body: LabelRequest,
     request: Request,
     request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> Response:
 ) -> Response:
     """Render labels for spools tracked in Spoolman."""
     """Render labels for spools tracked in Spoolman."""
@@ -336,7 +376,7 @@ async def render_spoolman_labels(
 async def preview_spoolman_label(
 async def preview_spoolman_label(
     body: LabelPreviewRequest,
     body: LabelPreviewRequest,
     request: Request,
     request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> Response:
 ) -> Response:
     """One label for a Spoolman spool, as a PNG, for the picker's preview."""
     """One label for a Spoolman spool, as a PNG, for the picker's preview."""

File diff suppressed because it is too large
+ 352 - 146
backend/app/api/routes/library.py


+ 8 - 8
backend/app/api/routes/library_tags.py

@@ -35,7 +35,7 @@ from sqlalchemy import delete, distinct, func, select
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission, require_permission_if_auth_enabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryTag
 from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryTag
@@ -67,8 +67,8 @@ def _name_key(name: str) -> str:
 @router.get("", response_model=list[TagResponse])
 @router.get("", response_model=list[TagResponse])
 @router.get("/", response_model=list[TagResponse])
 @router.get("/", response_model=list[TagResponse])
 async def list_tags(
 async def list_tags(
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
             Permission.LIBRARY_READ_OWN,
@@ -124,7 +124,7 @@ async def list_tags(
 @router.post("/", response_model=TagResponse, status_code=201)
 @router.post("/", response_model=TagResponse, status_code=201)
 async def create_tag(
 async def create_tag(
     payload: TagCreate,
     payload: TagCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
 ) -> TagResponse:
 ) -> TagResponse:
     """Create a tag. Case-insensitive dup → 409."""
     """Create a tag. Case-insensitive dup → 409."""
@@ -147,7 +147,7 @@ async def create_tag(
 async def update_tag(
 async def update_tag(
     tag_id: int,
     tag_id: int,
     payload: TagUpdate,
     payload: TagUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
 ) -> TagResponse:
 ) -> TagResponse:
     """Rename a tag. Case-insensitive dup → 409 (own-name no-op is allowed)."""
     """Rename a tag. Case-insensitive dup → 409 (own-name no-op is allowed)."""
@@ -194,7 +194,7 @@ async def update_tag(
 @router.delete("/{tag_id}", status_code=204, response_model=None)
 @router.delete("/{tag_id}", status_code=204, response_model=None)
 async def delete_tag(
 async def delete_tag(
     tag_id: int,
     tag_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
 ) -> None:
 ) -> None:
     """Delete a tag. Association rows ON DELETE CASCADE — files are untouched."""
     """Delete a tag. Association rows ON DELETE CASCADE — files are untouched."""
@@ -208,8 +208,8 @@ async def delete_tag(
 @router.post("/bulk-assign", response_model=TagBulkAssignResponse)
 @router.post("/bulk-assign", response_model=TagBulkAssignResponse)
 async def bulk_assign(
 async def bulk_assign(
     payload: TagBulkAssignRequest,
     payload: TagBulkAssignRequest,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
             Permission.LIBRARY_UPDATE_OWN,

+ 14 - 14
backend/app/api/routes/library_trash.py

@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission, require_permission_if_auth_enabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
@@ -52,7 +52,7 @@ router = APIRouter(prefix="/library", tags=["library-trash"])
 async def preview_purge(
 async def preview_purge(
     older_than_days: int = Query(ge=1, le=3650),
     older_than_days: int = Query(ge=1, le=3650),
     include_never_printed: bool = True,
     include_never_printed: bool = True,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
 ):
 ):
     """Preview how many files would move to trash for the given age threshold.
     """Preview how many files would move to trash for the given age threshold.
@@ -70,7 +70,7 @@ async def preview_purge(
 @router.post("/purge", response_model=PurgeResponse)
 @router.post("/purge", response_model=PurgeResponse)
 async def execute_purge(
 async def execute_purge(
     body: PurgeRequest,
     body: PurgeRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
 ):
 ):
     """Move matching files to trash. Idempotent — already-trashed rows skip."""
     """Move matching files to trash. Idempotent — already-trashed rows skip."""
@@ -89,8 +89,8 @@ async def execute_purge(
 async def list_trash(
 async def list_trash(
     limit: int = Query(default=100, ge=1, le=500),
     limit: int = Query(default=100, ge=1, le=500),
     offset: int = Query(default=0, ge=0),
     offset: int = Query(default=0, ge=0),
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
             Permission.LIBRARY_DELETE_OWN,
@@ -153,7 +153,7 @@ async def list_trash(
 async def _load_trashed_file(
 async def _load_trashed_file(
     db: AsyncSession,
     db: AsyncSession,
     file_id: int,
     file_id: int,
-    user: User | None,
+    user: User | ApiKeyActor | None,
     can_modify_all: bool,
     can_modify_all: bool,
 ) -> LibraryFile:
 ) -> LibraryFile:
     """Fetch a trashed file, enforcing ownership for non-admins."""
     """Fetch a trashed file, enforcing ownership for non-admins."""
@@ -175,8 +175,8 @@ async def _load_trashed_file(
 @router.post("/trash/{file_id}/restore")
 @router.post("/trash/{file_id}/restore")
 async def restore_from_trash(
 async def restore_from_trash(
     file_id: int,
     file_id: int,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
             Permission.LIBRARY_DELETE_OWN,
@@ -192,8 +192,8 @@ async def restore_from_trash(
 @router.delete("/trash/{file_id}")
 @router.delete("/trash/{file_id}")
 async def hard_delete_from_trash(
 async def hard_delete_from_trash(
     file_id: int,
     file_id: int,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
             Permission.LIBRARY_DELETE_OWN,
@@ -209,8 +209,8 @@ async def hard_delete_from_trash(
 
 
 @router.delete("/trash", response_model=EmptyTrashResponse)
 @router.delete("/trash", response_model=EmptyTrashResponse)
 async def empty_trash(
 async def empty_trash(
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
             Permission.LIBRARY_DELETE_OWN,
@@ -242,7 +242,7 @@ async def empty_trash(
 
 
 @router.get("/trash/settings", response_model=TrashSettings)
 @router.get("/trash/settings", response_model=TrashSettings)
 async def get_trash_settings(
 async def get_trash_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
 ):
 ):
     retention = await library_trash_service.get_retention_days(db)
     retention = await library_trash_service.get_retention_days(db)
@@ -258,7 +258,7 @@ async def get_trash_settings(
 @router.put("/trash/settings", response_model=TrashSettings)
 @router.put("/trash/settings", response_model=TrashSettings)
 async def update_trash_settings(
 async def update_trash_settings(
     body: TrashSettings,
     body: TrashSettings,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
 ):
 ):
     if body.retention_days < MIN_RETENTION_DAYS or body.retention_days > MAX_RETENTION_DAYS:
     if body.retention_days < MIN_RETENTION_DAYS or body.retention_days > MAX_RETENTION_DAYS:

+ 16 - 16
backend/app/api/routes/library_variants.py

@@ -43,7 +43,7 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import require_ownership_permission
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.library import FileVariantGroup, LibraryFile
 from backend.app.models.library import FileVariantGroup, LibraryFile
@@ -97,7 +97,7 @@ def resolve_variant_model(lib_file: LibraryFile, explicit: str | None = None) ->
 async def _load_files(
 async def _load_files(
     db: AsyncSession,
     db: AsyncSession,
     file_ids: list[int],
     file_ids: list[int],
-    user: User | None,
+    user: User | ApiKeyActor | None,
     can_access_all: bool,
     can_access_all: bool,
 ) -> dict[int, LibraryFile]:
 ) -> dict[int, LibraryFile]:
     """Fetch the caller's visible, untrashed files by id."""
     """Fetch the caller's visible, untrashed files by id."""
@@ -186,8 +186,8 @@ async def _dissolve_if_too_small(db: AsyncSession, group: FileVariantGroup) -> b
 @router.post("/", response_model=VariantGroupResponse, status_code=201)
 @router.post("/", response_model=VariantGroupResponse, status_code=201)
 async def create_variant_group(
 async def create_variant_group(
     payload: VariantGroupCreate,
     payload: VariantGroupCreate,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
             Permission.LIBRARY_UPDATE_OWN,
@@ -241,8 +241,8 @@ async def create_variant_group(
 @router.get("/by-file/{file_id}", response_model=VariantGroupResponse)
 @router.get("/by-file/{file_id}", response_model=VariantGroupResponse)
 async def get_group_for_file(
 async def get_group_for_file(
     file_id: int,
     file_id: int,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
             Permission.LIBRARY_READ_OWN,
@@ -268,8 +268,8 @@ async def get_group_for_file(
 @router.get("/{group_id}", response_model=VariantGroupResponse)
 @router.get("/{group_id}", response_model=VariantGroupResponse)
 async def get_variant_group(
 async def get_variant_group(
     group_id: int,
     group_id: int,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
             Permission.LIBRARY_READ_OWN,
@@ -283,8 +283,8 @@ async def get_variant_group(
 async def update_variant_group(
 async def update_variant_group(
     group_id: int,
     group_id: int,
     payload: VariantGroupUpdate,
     payload: VariantGroupUpdate,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
             Permission.LIBRARY_UPDATE_OWN,
@@ -323,8 +323,8 @@ async def update_variant_group(
 async def add_variant_group_member(
 async def add_variant_group_member(
     payload: VariantGroupMemberRequest,
     payload: VariantGroupMemberRequest,
     group_id: int,
     group_id: int,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
             Permission.LIBRARY_UPDATE_OWN,
@@ -379,8 +379,8 @@ async def add_variant_group_member(
 async def remove_variant_group_member(
 async def remove_variant_group_member(
     group_id: int,
     group_id: int,
     file_id: int,
     file_id: int,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
             Permission.LIBRARY_UPDATE_OWN,
@@ -406,8 +406,8 @@ async def remove_variant_group_member(
 @router.delete("/{group_id}", response_model=None, status_code=204)
 @router.delete("/{group_id}", response_model=None, status_code=204)
 async def delete_variant_group(
 async def delete_variant_group(
     group_id: int,
     group_id: int,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
             Permission.LIBRARY_UPDATE_OWN,

+ 3 - 1
backend/app/api/routes/local_backup.py

@@ -5,7 +5,7 @@ import logging
 from fastapi import APIRouter, Path
 from fastapi import APIRouter, Path
 from fastapi.responses import FileResponse, JSONResponse
 from fastapi.responses import FileResponse, JSONResponse
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireAdminIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.local_backup import local_backup_service
 from backend.app.services.local_backup import local_backup_service
@@ -74,6 +74,7 @@ async def list_backups(
 @router.get("/backups/{filename}/download")
 @router.get("/backups/{filename}/download")
 async def download_backup(
 async def download_backup(
     filename: str = Path(..., description="Backup filename to download"),
     filename: str = Path(..., description="Backup filename to download"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
 ):
 ):
     """Download a specific backup file."""
     """Download a specific backup file."""
@@ -91,6 +92,7 @@ async def download_backup(
 @router.post("/backups/{filename}/restore")
 @router.post("/backups/{filename}/restore")
 async def restore_backup(
 async def restore_backup(
     filename: str = Path(..., description="Backup filename to restore"),
     filename: str = Path(..., description="Backup filename to restore"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
 ):
 ):
     """Restore from a scheduled backup file on the server."""
     """Restore from a scheduled backup file on the server."""

+ 9 - 9
backend/app/api/routes/local_presets.py

@@ -37,7 +37,7 @@ router = APIRouter(prefix="/local-presets", tags=["Local Presets"])
 @router.get("/", response_model=LocalPresetsResponse)
 @router.get("/", response_model=LocalPresetsResponse)
 async def list_local_presets(
 async def list_local_presets(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """List all local presets grouped by type."""
     """List all local presets grouped by type."""
     result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
     result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
@@ -60,7 +60,7 @@ async def list_local_presets(
 async def get_local_preset(
 async def get_local_preset(
     preset_id: int,
     preset_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Get full detail for a local preset including the setting JSON."""
     """Get full detail for a local preset including the setting JSON."""
     result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
     result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
@@ -81,7 +81,7 @@ async def get_local_preset(
 async def import_presets(
 async def import_presets(
     file: UploadFile,
     file: UploadFile,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Import presets from an OrcaSlicer export file (.json, .orca_filament, .bbscfg, .bbsflmt, .zip)."""
     """Import presets from an OrcaSlicer export file (.json, .orca_filament, .bbscfg, .bbsflmt, .zip)."""
     if not file.filename:
     if not file.filename:
@@ -99,7 +99,7 @@ async def import_presets(
 async def create_local_preset(
 async def create_local_preset(
     data: LocalPresetCreate,
     data: LocalPresetCreate,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Manually create a local preset."""
     """Manually create a local preset."""
     if data.preset_type not in ("filament", "printer", "process"):
     if data.preset_type not in ("filament", "printer", "process"):
@@ -126,7 +126,7 @@ async def update_local_preset(
     preset_id: int,
     preset_id: int,
     data: LocalPresetUpdate,
     data: LocalPresetUpdate,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Update a local preset's name or settings."""
     """Update a local preset's name or settings."""
     result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
     result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
@@ -161,7 +161,7 @@ async def update_local_preset(
 async def delete_local_preset(
 async def delete_local_preset(
     preset_id: int,
     preset_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Delete a local preset."""
     """Delete a local preset."""
     result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
     result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
@@ -176,7 +176,7 @@ async def delete_local_preset(
 @router.get("/base-cache/status")
 @router.get("/base-cache/status")
 async def base_cache_status(
 async def base_cache_status(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Get the status of the OrcaSlicer base profile cache."""
     """Get the status of the OrcaSlicer base profile cache."""
     return await get_cache_status(db)
     return await get_cache_status(db)
@@ -185,7 +185,7 @@ async def base_cache_status(
 @router.post("/base-cache/refresh")
 @router.post("/base-cache/refresh")
 async def refresh_cache(
 async def refresh_cache(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Force refresh all cached base profiles from GitHub."""
     """Force refresh all cached base profiles from GitHub."""
     return await refresh_base_cache(db)
     return await refresh_base_cache(db)
@@ -194,7 +194,7 @@ async def refresh_cache(
 @router.post("/reclassify")
 @router.post("/reclassify")
 async def reclassify(
 async def reclassify(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Re-evaluate preset types for all local presets using the improved heuristic."""
     """Re-evaluate preset types for all local presets using the improved heuristic."""
     return await reclassify_presets(db)
     return await reclassify_presets(db)

+ 7 - 7
backend/app/api/routes/location_ha_sensors.py

@@ -110,7 +110,7 @@ async def _refresh_quietly(sensor: LocationHASensor, db: AsyncSession) -> None:
 @router.get("/", response_model=list[LocationHASensorResponse])
 @router.get("/", response_model=list[LocationHASensorResponse])
 async def list_location_ha_sensors(
 async def list_location_ha_sensors(
     location_id: int | None = None,
     location_id: int | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
     _: User | None = _READ,
 ):
 ):
     """List configured sensors, grouped by location and in display order."""
     """List configured sensors, grouped by location and in display order."""
@@ -125,7 +125,7 @@ async def list_location_ha_sensors(
 @router.get("/entities", response_model=list[HADisplayEntity])
 @router.get("/entities", response_model=list[HADisplayEntity])
 async def list_bindable_entities(
 async def list_bindable_entities(
     search: str | None = None,
     search: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
     _: User | None = _READ,
 ):
 ):
     """List the Home Assistant entities that can be bound to a storage location."""
     """List the Home Assistant entities that can be bound to a storage location."""
@@ -146,7 +146,7 @@ async def list_bindable_entities(
 async def get_location_sensor_readings(
 async def get_location_sensor_readings(
     location_id: int,
     location_id: int,
     show_on_card: bool = True,
     show_on_card: bool = True,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
     _: User | None = _READ,
 ):
 ):
     """Live state of a location's card-visible sensors.
     """Live state of a location's card-visible sensors.
@@ -192,7 +192,7 @@ async def get_location_sensor_readings(
 @router.post("/", response_model=LocationHASensorResponse)
 @router.post("/", response_model=LocationHASensorResponse)
 async def create_location_ha_sensor(
 async def create_location_ha_sensor(
     data: LocationHASensorCreate,
     data: LocationHASensorCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _CREATE,
     _: User | None = _CREATE,
 ):
 ):
     """Bind a Home Assistant entity to a storage location."""
     """Bind a Home Assistant entity to a storage location."""
@@ -235,7 +235,7 @@ async def create_location_ha_sensor(
 @router.get("/{sensor_id}", response_model=LocationHASensorResponse)
 @router.get("/{sensor_id}", response_model=LocationHASensorResponse)
 async def get_location_ha_sensor(
 async def get_location_ha_sensor(
     sensor_id: int,
     sensor_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
     _: User | None = _READ,
 ):
 ):
     sensor = await db.get(LocationHASensor, sensor_id)
     sensor = await db.get(LocationHASensor, sensor_id)
@@ -248,7 +248,7 @@ async def get_location_ha_sensor(
 async def update_location_ha_sensor(
 async def update_location_ha_sensor(
     sensor_id: int,
     sensor_id: int,
     data: LocationHASensorUpdate,
     data: LocationHASensorUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _UPDATE,
     _: User | None = _UPDATE,
 ):
 ):
     sensor = await db.get(LocationHASensor, sensor_id)
     sensor = await db.get(LocationHASensor, sensor_id)
@@ -309,7 +309,7 @@ async def update_location_ha_sensor(
 @router.delete("/{sensor_id}")
 @router.delete("/{sensor_id}")
 async def delete_location_ha_sensor(
 async def delete_location_ha_sensor(
     sensor_id: int,
     sensor_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _DELETE,
     _: User | None = _DELETE,
 ):
 ):
     sensor = await db.get(LocationHASensor, sensor_id)
     sensor = await db.get(LocationHASensor, sensor_id)

+ 98 - 39
backend/app/api/routes/maintenance.py

@@ -5,12 +5,18 @@ from datetime import datetime, timezone
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -183,7 +189,7 @@ async def ensure_default_types(db: AsyncSession) -> None:
 
 
 @router.get("/types", response_model=list[MaintenanceTypeResponse])
 @router.get("/types", response_model=list[MaintenanceTypeResponse])
 async def get_maintenance_types(
 async def get_maintenance_types(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
 ):
     """Get all maintenance types."""
     """Get all maintenance types."""
@@ -199,7 +205,7 @@ async def get_maintenance_types(
 @router.post("/types", response_model=MaintenanceTypeResponse)
 @router.post("/types", response_model=MaintenanceTypeResponse)
 async def create_maintenance_type(
 async def create_maintenance_type(
     data: MaintenanceTypeCreate,
     data: MaintenanceTypeCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
 ):
     """Create a custom maintenance type."""
     """Create a custom maintenance type."""
@@ -222,7 +228,7 @@ async def create_maintenance_type(
 async def update_maintenance_type(
 async def update_maintenance_type(
     type_id: int,
     type_id: int,
     data: MaintenanceTypeUpdate,
     data: MaintenanceTypeUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
 ):
     """Update a maintenance type."""
     """Update a maintenance type."""
@@ -243,7 +249,7 @@ async def update_maintenance_type(
 @router.delete("/types/{type_id}")
 @router.delete("/types/{type_id}")
 async def delete_maintenance_type(
 async def delete_maintenance_type(
     type_id: int,
     type_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
 ):
 ):
     """Delete a maintenance type."""
     """Delete a maintenance type."""
@@ -264,7 +270,7 @@ async def delete_maintenance_type(
 
 
 @router.post("/types/restore-defaults")
 @router.post("/types/restore-defaults")
 async def restore_default_maintenance_types(
 async def restore_default_maintenance_types(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
 ):
 ):
     """Restore deleted default maintenance types."""
     """Restore deleted default maintenance types."""
@@ -283,6 +289,47 @@ async def restore_default_maintenance_types(
 # ============== Printer Maintenance ==============
 # ============== Printer Maintenance ==============
 
 
 
 
+def maintenance_alert_values(item: MaintenanceStatus) -> tuple[float, float, str]:
+    """What the MQTT relay's maintenance/alert reports: (value, threshold, unit).
+
+    Print-hour items count hours since the last maintenance against the
+    interval; time-based ones count days, and their interval_hours holds days.
+    """
+    if item.interval_type == "days":
+        return (item.days_since_maintenance or 0.0, item.interval_hours, "days")
+    return (item.hours_since_maintenance, item.interval_hours, "hours")
+
+
+async def _get_or_create_maintenance_item(db: AsyncSession, printer_id: int, type_id: int) -> PrinterMaintenance:
+    """The printer's row for this type, created with defaults if missing.
+
+    Insert first, in a savepoint: two reads of a new printer (two dashboards,
+    or a dashboard and the background check) each used to create the row. The
+    unique index now refuses the second, which takes the first's (FO-C14).
+    """
+    item = PrinterMaintenance(
+        printer_id=printer_id,
+        maintenance_type_id=type_id,
+        enabled=True,
+        last_performed_hours=0.0,
+    )
+    try:
+        async with db.begin_nested():
+            db.add(item)
+            await db.flush()
+        return item
+    except IntegrityError:
+        existing = await db.scalar(
+            select(PrinterMaintenance).where(
+                PrinterMaintenance.printer_id == printer_id,
+                PrinterMaintenance.maintenance_type_id == type_id,
+            )
+        )
+        if existing is None:
+            raise
+        return existing
+
+
 async def _get_printer_maintenance_internal(
 async def _get_printer_maintenance_internal(
     printer_id: int,
     printer_id: int,
     db: AsyncSession,
     db: AsyncSession,
@@ -340,21 +387,15 @@ async def _get_printer_maintenance_internal(
             if not maint_type.is_system:
             if not maint_type.is_system:
                 continue
                 continue
 
 
-            # Create default entry for this printer/type
-            item = PrinterMaintenance(
-                printer_id=printer_id,
-                maintenance_type_id=maint_type.id,
-                enabled=True,
-                last_performed_hours=0.0,
-            )
-            db.add(item)
-            await db.flush()
+            # Create default entry for this printer/type, or take the one a
+            # concurrent read just created.
+            item = await _get_or_create_maintenance_item(db, printer_id, maint_type.id)
 
 
-            interval = maint_type.default_interval_hours
-            interval_type = default_interval_type
-            enabled = True
-            last_performed_hours = 0.0
-            last_performed_at = None
+            interval = item.custom_interval_hours or maint_type.default_interval_hours
+            interval_type = getattr(item, "custom_interval_type", None) or default_interval_type
+            enabled = item.enabled
+            last_performed_hours = item.last_performed_hours
+            last_performed_at = item.last_performed_at
             item_id = item.id
             item_id = item.id
 
 
         # Calculate status based on interval type
         # Calculate status based on interval type
@@ -439,8 +480,8 @@ async def _get_printer_maintenance_internal(
 @router.get("/printers/{printer_id}", response_model=PrinterMaintenanceOverview)
 @router.get("/printers/{printer_id}", response_model=PrinterMaintenanceOverview)
 async def get_printer_maintenance(
 async def get_printer_maintenance(
     printer_id: int,
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
 ):
     """Get maintenance overview for a specific printer."""
     """Get maintenance overview for a specific printer."""
     return await _get_printer_maintenance_internal(printer_id, db, commit=True)
     return await _get_printer_maintenance_internal(printer_id, db, commit=True)
@@ -448,14 +489,15 @@ async def get_printer_maintenance(
 
 
 @router.get("/overview", response_model=list[PrinterMaintenanceOverview])
 @router.get("/overview", response_model=list[PrinterMaintenanceOverview])
 async def get_all_maintenance_overview(
 async def get_all_maintenance_overview(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get maintenance overview for all active printers."""
     """Get maintenance overview for all active printers."""
     await ensure_default_types(db)
     await ensure_default_types(db)
 
 
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
 
     overviews = []
     overviews = []
     for printer in printers:
     for printer in printers:
@@ -473,8 +515,9 @@ async def get_all_maintenance_overview(
 async def update_printer_maintenance(
 async def update_printer_maintenance(
     item_id: int,
     item_id: int,
     data: PrinterMaintenanceUpdate,
     data: PrinterMaintenanceUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Update a printer maintenance item (e.g., custom interval, enabled)."""
     """Update a printer maintenance item (e.g., custom interval, enabled)."""
     result = await db.execute(
     result = await db.execute(
@@ -483,7 +526,7 @@ async def update_printer_maintenance(
         .options(selectinload(PrinterMaintenance.maintenance_type))
         .options(selectinload(PrinterMaintenance.maintenance_type))
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(status_code=404, detail="Maintenance item not found")
         raise HTTPException(status_code=404, detail="Maintenance item not found")
 
 
     update_data = data.model_dump(exclude_unset=True)
     update_data = data.model_dump(exclude_unset=True)
@@ -499,8 +542,8 @@ async def update_printer_maintenance(
 async def assign_maintenance_type(
 async def assign_maintenance_type(
     printer_id: int,
     printer_id: int,
     type_id: int,
     type_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
 ):
     """Assign a maintenance type to a specific printer (for custom types)."""
     """Assign a maintenance type to a specific printer (for custom types)."""
     # Verify printer exists
     # Verify printer exists
@@ -534,7 +577,13 @@ async def assign_maintenance_type(
         last_performed_hours=0.0,
         last_performed_hours=0.0,
     )
     )
     db.add(item)
     db.add(item)
-    await db.commit()
+    try:
+        await db.commit()
+    except IntegrityError:
+        # A concurrent assign (a double click) passed the check above too; the
+        # unique index refuses this one (FO-C14).
+        await db.rollback()
+        raise HTTPException(status_code=400, detail="Maintenance type already assigned to this printer") from None
 
 
     # Re-fetch with relationship loaded for response serialization
     # Re-fetch with relationship loaded for response serialization
     from sqlalchemy.orm import selectinload
     from sqlalchemy.orm import selectinload
@@ -552,8 +601,9 @@ async def assign_maintenance_type(
 @router.delete("/items/{item_id}")
 @router.delete("/items/{item_id}")
 async def remove_maintenance_item(
 async def remove_maintenance_item(
     item_id: int,
     item_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Remove a maintenance item (unassign a custom type from a printer)."""
     """Remove a maintenance item (unassign a custom type from a printer)."""
     result = await db.execute(
     result = await db.execute(
@@ -562,7 +612,7 @@ async def remove_maintenance_item(
         .options(selectinload(PrinterMaintenance.maintenance_type))
         .options(selectinload(PrinterMaintenance.maintenance_type))
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(status_code=404, detail="Maintenance item not found")
         raise HTTPException(status_code=404, detail="Maintenance item not found")
 
 
     # Only allow removing custom (non-system) types
     # Only allow removing custom (non-system) types
@@ -579,8 +629,9 @@ async def remove_maintenance_item(
 async def perform_maintenance(
 async def perform_maintenance(
     item_id: int,
     item_id: int,
     data: PerformMaintenanceRequest,
     data: PerformMaintenanceRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Mark maintenance as performed (reset the counter)."""
     """Mark maintenance as performed (reset the counter)."""
     result = await db.execute(
     result = await db.execute(
@@ -589,7 +640,7 @@ async def perform_maintenance(
         .options(selectinload(PrinterMaintenance.maintenance_type))
         .options(selectinload(PrinterMaintenance.maintenance_type))
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(status_code=404, detail="Maintenance item not found")
         raise HTTPException(status_code=404, detail="Maintenance item not found")
 
 
     # Get printer for name
     # Get printer for name
@@ -627,7 +678,8 @@ async def perform_maintenance(
 
 
     # Calculate status
     # Calculate status
     interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
     interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
-    interval_type = getattr(item.maintenance_type, "interval_type", "hours") or "hours"
+    # The item's own interval type first, as the status list reads it (dbcol-09).
+    interval_type = item.custom_interval_type or getattr(item.maintenance_type, "interval_type", "hours") or "hours"
     hours_since = current_hours - item.last_performed_hours
     hours_since = current_hours - item.last_performed_hours
     hours_until = interval - hours_since
     hours_until = interval - hours_since
 
 
@@ -657,10 +709,16 @@ async def perform_maintenance(
 @router.get("/items/{item_id}/history", response_model=list[MaintenanceHistoryResponse])
 @router.get("/items/{item_id}/history", response_model=list[MaintenanceHistoryResponse])
 async def get_maintenance_history(
 async def get_maintenance_history(
     item_id: int,
     item_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get maintenance history for a specific item."""
     """Get maintenance history for a specific item."""
+    item_printer_id = (
+        await db.execute(select(PrinterMaintenance.printer_id).where(PrinterMaintenance.id == item_id))
+    ).scalar_one_or_none()
+    if not printer_scope.allows(item_printer_id):
+        raise HTTPException(status_code=404, detail="Maintenance item not found")
     result = await db.execute(
     result = await db.execute(
         select(MaintenanceHistory)
         select(MaintenanceHistory)
         .where(MaintenanceHistory.printer_maintenance_id == item_id)
         .where(MaintenanceHistory.printer_maintenance_id == item_id)
@@ -671,14 +729,15 @@ async def get_maintenance_history(
 
 
 @router.get("/summary")
 @router.get("/summary")
 async def get_maintenance_summary(
 async def get_maintenance_summary(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get a summary of maintenance status across all printers."""
     """Get a summary of maintenance status across all printers."""
     await ensure_default_types(db)
     await ensure_default_types(db)
 
 
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
 
     total_due = 0
     total_due = 0
     total_warning = 0
     total_warning = 0
@@ -709,8 +768,8 @@ async def get_maintenance_summary(
 async def set_printer_hours(
 async def set_printer_hours(
     printer_id: int,
     printer_id: int,
     total_hours: float,
     total_hours: float,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
 ):
     """Set the total print hours for a printer (adjusts offset to match).
     """Set the total print hours for a printer (adjusts offset to match).
 
 

+ 60 - 44
backend/app/api/routes/makerworld.py

@@ -32,14 +32,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.library import save_3mf_bytes_to_library
 from backend.app.api.routes.library import save_3mf_bytes_to_library
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    ApiKeyActor,
+    RequestActor,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    require_actor_still_exists,
     require_auth_if_enabled,
     require_auth_if_enabled,
     require_permission_if_auth_enabled,
     require_permission_if_auth_enabled,
     security,
     security,
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
-from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.core.row_visibility import owned_row_clauses
+from backend.app.models.library import LibraryFile
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.makerworld import (
 from backend.app.schemas.makerworld import (
     MakerWorldImportRequest,
     MakerWorldImportRequest,
@@ -49,6 +53,7 @@ from backend.app.schemas.makerworld import (
     MakerWorldResolveRequest,
     MakerWorldResolveRequest,
     MakerWorldStatus,
     MakerWorldStatus,
 )
 )
+from backend.app.services.library_folder_access import default_import_folder, get_writable_folder
 from backend.app.services.model_providers import makerworld_provider, registry
 from backend.app.services.model_providers import makerworld_provider, registry
 from backend.app.services.model_providers.base import (
 from backend.app.services.model_providers.base import (
     ModelProvider,
     ModelProvider,
@@ -91,10 +96,17 @@ def _provider_for_source(source_type: str) -> ModelProvider:
     is the *repr* of its argument and would ship the quotes to the client.
     is the *repr* of its argument and would ship the quotes to the client.
     """
     """
     try:
     try:
-        return registry.get(source_type)
+        provider = registry.get(source_type)
     except KeyError as exc:
     except KeyError as exc:
         msg = f"No model provider registered for source_type {source_type!r}"
         msg = f"No model provider registered for source_type {source_type!r}"
         raise HTTPException(status_code=400, detail=msg) from exc
         raise HTTPException(status_code=400, detail=msg) from exc
+    # A provider that claims no pasted URLs (Manyfold, #1471) is browsed
+    # through routes of its own, which apply its own checks; it must not be
+    # importable through this pasted-URL flow as well.
+    if not provider.host_patterns:
+        msg = f"Model provider {source_type!r} is not imported through this route"
+        raise HTTPException(status_code=400, detail=msg)
+    return provider
 
 
 
 
 async def _authorize_for_provider(
 async def _authorize_for_provider(
@@ -205,7 +217,7 @@ async def proxy_thumbnail(
 
 
 @router.get("/status", response_model=MakerWorldStatus)
 @router.get("/status", response_model=MakerWorldStatus)
 async def get_status(
 async def get_status(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
     current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
 ):
@@ -245,10 +257,11 @@ async def get_status(
 )
 )
 async def resolve_url(
 async def resolve_url(
     body: MakerWorldResolveRequest,
     body: MakerWorldResolveRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
     """Resolve a MakerWorld URL to full model metadata + plate list.
     """Resolve a MakerWorld URL to full model metadata + plate list.
 
 
@@ -281,13 +294,18 @@ async def resolve_url(
     # the provider's :meth:`source_url_filter` owns what "belongs" means
     # the provider's :meth:`source_url_filter` owns what "belongs" means
     # (whole-model key, per-plate keys, ...). The frontend surfaces the ids
     # (whole-model key, per-plate keys, ...). The frontend surfaces the ids
     # to mark imported plates in the instance picker.
     # to mark imported plates in the instance picker.
-    existing_q = await db.execute(
-        select(LibraryFile.id).where(
-            provider.source_url_filter(LibraryFile.source_url, str(model_id)),
-            LibraryFile.deleted_at.is_(None),
+    # Only rows the caller may open in the File Manager.
+    readable = owned_row_clauses(LibraryFile, actor, Permission.LIBRARY_READ_ALL, Permission.LIBRARY_READ_OWN)
+    already_imported = []
+    if readable is not None:
+        existing_q = await db.execute(
+            select(LibraryFile.id).where(
+                provider.source_url_filter(LibraryFile.source_url, str(model_id)),
+                LibraryFile.deleted_at.is_(None),
+                *readable,
+            )
         )
         )
-    )
-    already_imported = [row[0] for row in existing_q.all()]
+        already_imported = [row[0] for row in existing_q.all()]
 
 
     return MakerWorldResolvedModel(
     return MakerWorldResolvedModel(
         model_id=model_id,
         model_id=model_id,
@@ -307,10 +325,11 @@ async def resolve_url(
 )
 )
 async def import_instance(
 async def import_instance(
     body: MakerWorldImportRequest,
     body: MakerWorldImportRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
     """Download a specific MakerWorld instance (plate configuration) and save
     """Download a specific MakerWorld instance (plate configuration) and save
     the 3MF into the library.
     the 3MF into the library.
@@ -329,10 +348,8 @@ async def import_instance(
     current_user = await _authorize_for_provider(provider, provider.import_permission, credentials, x_api_key)
     current_user = await _authorize_for_provider(provider, provider.import_permission, credentials, x_api_key)
 
 
     if body.folder_id is not None:
     if body.folder_id is not None:
-        folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id))
-        target_folder = folder_q.scalar_one_or_none()
-        if target_folder is None:
-            raise HTTPException(status_code=404, detail="Folder not found")
+        # Only into a folder the user may write to (#3201).
+        target_folder = await get_writable_folder(db, body.folder_id, actor)
         if target_folder.is_external and target_folder.external_readonly:
         if target_folder.is_external and target_folder.external_readonly:
             raise HTTPException(
             raise HTTPException(
                 status_code=403,
                 status_code=403,
@@ -352,27 +369,16 @@ async def import_instance(
         if default_folder_name is None:
         if default_folder_name is None:
             effective_folder_id = None
             effective_folder_id = None
         else:
         else:
-            default_folder_q = await db.execute(
-                select(LibraryFolder).where(
-                    LibraryFolder.name == default_folder_name,
-                    LibraryFolder.parent_id.is_(None),
-                    LibraryFolder.is_external.is_(False),
-                )
-            )
-            default_folder = default_folder_q.scalar_one_or_none()
-            if default_folder is None:
-                default_folder = LibraryFolder(name=default_folder_name, parent_id=None)
-                db.add(default_folder)
-                await db.flush()
+            default_folder = await default_import_folder(db, default_folder_name, actor)
             effective_folder_id = default_folder.id
             effective_folder_id = default_folder.id
 
 
     service = await _build_service(db, provider, current_user, api_key_cloud_owner)
     service = await _build_service(db, provider, current_user, api_key_cloud_owner)
 
 
-    # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
-    # (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N} —
-    # resolving that, plus picking a default profile when the frontend didn't
-    # specify one, lives inside ``get_download``. The route only orchestrates
-    # dedupe + persistence so every provider shares those concerns here.
+    # Finding the download link (the instance behind the profile, or the
+    # profile endpoint with the alphanumeric modelId as a fallback, #3447),
+    # plus picking a default profile when the frontend didn't specify one,
+    # lives inside ``get_download``. The route only orchestrates dedupe +
+    # persistence so every provider shares those concerns here.
     ref = ProviderResourceRef(
     ref = ProviderResourceRef(
         source_type=provider.source_type,
         source_type=provider.source_type,
         external_id=str(body.model_id),
         external_id=str(body.model_id),
@@ -389,9 +395,15 @@ async def import_instance(
         # entry (see ``ModelProvider.canonical_url``).
         # entry (see ``ModelProvider.canonical_url``).
         source_url = provider.canonical_url(info.ref)
         source_url = provider.canonical_url(info.ref)
 
 
-        # Dedupe check upfront so we don't burn bandwidth re-downloading.
-        existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
-        existing_row = existing_q.scalar_one_or_none()
+        # Dedupe check upfront so we don't burn bandwidth re-downloading. A copy
+        # the caller cannot open is no copy for them: they get their own.
+        readable = owned_row_clauses(LibraryFile, actor, Permission.LIBRARY_READ_ALL, Permission.LIBRARY_READ_OWN)
+        existing_row = None
+        if readable is not None:
+            existing_q = await db.execute(
+                LibraryFile.active().where(LibraryFile.source_url == source_url, *readable).limit(1)
+            )
+            existing_row = existing_q.scalar_one_or_none()
         if existing_row is not None:
         if existing_row is not None:
             return MakerWorldImportResponse(
             return MakerWorldImportResponse(
                 library_file_id=existing_row.id,
                 library_file_id=existing_row.id,
@@ -428,11 +440,10 @@ async def import_instance(
     # there as on the manifest-supplied name.
     # there as on the manifest-supplied name.
     filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download.filename)
     filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download.filename)
 
 
-    # API-keyed callers carry identity on the key, not in current_user (#1777);
-    # this collapse stays route-side solely so the library row is attributed
-    # to the key's owner rather than NULL. Credential identity is resolved
-    # inside the provider.
-    cloud_token_user = current_user or api_key_cloud_owner
+    # Not saved for a user deleted during the download (SQLite reuses the id).
+    await require_actor_still_exists(db, actor)
+    # Credited to the key's owner for an API key. Credential identity is
+    # resolved inside the provider.
     library_file, was_existing = await save_3mf_bytes_to_library(
     library_file, was_existing = await save_3mf_bytes_to_library(
         db,
         db,
         file_bytes=download.file_bytes,
         file_bytes=download.file_bytes,
@@ -440,7 +451,8 @@ async def import_instance(
         folder_id=effective_folder_id,
         folder_id=effective_folder_id,
         source_type=provider.source_type,
         source_type=provider.source_type,
         source_url=source_url,
         source_url=source_url,
-        owner_id=cloud_token_user.id if cloud_token_user else None,
+        owner_id=actor.id if actor else None,
+        dedupe_within=readable,
     )
     )
 
 
     return MakerWorldImportResponse(
     return MakerWorldImportResponse(
@@ -455,10 +467,11 @@ async def import_instance(
 @router.get("/recent-imports", response_model=list[MakerWorldRecentImport])
 @router.get("/recent-imports", response_model=list[MakerWorldRecentImport])
 async def recent_imports(
 async def recent_imports(
     limit: int = 10,
     limit: int = 10,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
     current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
-    """Last N MakerWorld imports, newest first.
+    """Last N MakerWorld imports the caller may open, newest first.
 
 
     Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld
     Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld
     page can show a 'Recent imports' sidebar that persists across resolves.
     page can show a 'Recent imports' sidebar that persists across resolves.
@@ -469,9 +482,12 @@ async def recent_imports(
     _ = current_user  # permission gate only
     _ = current_user  # permission gate only
     capped = max(1, min(50, int(limit)))
     capped = max(1, min(50, int(limit)))
 
 
+    readable = owned_row_clauses(LibraryFile, actor, Permission.LIBRARY_READ_ALL, Permission.LIBRARY_READ_OWN)
+    if readable is None:
+        return []
     result = await db.execute(
     result = await db.execute(
         LibraryFile.active()
         LibraryFile.active()
-        .where(LibraryFile.source_type == makerworld_provider.source_type)
+        .where(LibraryFile.source_type == makerworld_provider.source_type, *readable)
         .order_by(LibraryFile.created_at.desc())
         .order_by(LibraryFile.created_at.desc())
         .limit(capped)
         .limit(capped)
     )
     )

+ 382 - 0
backend/app/api/routes/manyfold.py

@@ -0,0 +1,382 @@
+"""Manyfold integration routes (#1471).
+
+Browse and search a self-hosted Manyfold library and import its files into
+the Bambuddy library, from where they are sliced and printed like any other
+file. Files are only fetched when someone imports one; nothing is mirrored.
+
+One connection serves the whole install: an admin stores the Manyfold URL and
+an OAuth application's client ID and secret (``/manyfold/config``), and the
+application's owner in Manyfold decides which models Bambuddy can see.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import Response
+from sqlalchemy import delete
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.library import save_3mf_bytes_to_library, validate_print_file_upload
+from backend.app.api.routes.settings import set_setting
+from backend.app.core.auth import ApiKeyActor, RequestActor, RequirePermissionIfAuthEnabled, require_actor_still_exists
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.row_visibility import owned_row_clauses
+from backend.app.models.library import LibraryFile
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.schemas.manyfold import (
+    ManyfoldConfigResponse,
+    ManyfoldConfigUpdate,
+    ManyfoldFile,
+    ManyfoldImportRequest,
+    ManyfoldImportResponse,
+    ManyfoldLibraryRef,
+    ManyfoldModel,
+    ManyfoldModelList,
+    ManyfoldStatus,
+    ManyfoldTestRequest,
+    ManyfoldTestResponse,
+)
+from backend.app.services.library_folder_access import default_import_folder, get_writable_folder
+from backend.app.services.model_providers import manyfold_provider
+from backend.app.services.model_providers.base import ProviderResourceRef
+from backend.app.services.model_providers.manyfold.config import (
+    CLIENT_ID_KEY,
+    CLIENT_SECRET_KEY,
+    CONFIG_KEYS,
+    URL_KEY,
+    ManyfoldConfig,
+    load_config,
+    normalize_url,
+)
+from backend.app.services.model_providers.manyfold.service import (
+    ManyfoldError,
+    ManyfoldNotFoundError,
+    ManyfoldService,
+    clear_token_cache,
+    is_importable_filename,
+    valid_id,
+)
+from backend.app.utils.filename import INVALID_FILENAME_CHARS, InvalidFilenameError, validate_print_filename
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/manyfold", tags=["manyfold"])
+
+
+def _error(status_code: int, code: str, message: str) -> HTTPException:
+    # Structured detail: the frontend shows its own translated text for the code.
+    return HTTPException(status_code=status_code, detail={"code": code, "message": message})
+
+
+def _map_error(exc: ManyfoldError) -> HTTPException:
+    """Manyfold's refusals are not Bambuddy's: never answer them with 401 or 403."""
+    if isinstance(exc, ManyfoldNotFoundError):
+        status_code = 404
+    elif exc.code == "manyfold_not_configured":
+        status_code = 409
+    else:
+        status_code = 502
+    return _error(status_code, exc.code, str(exc))
+
+
+async def _service(db: AsyncSession) -> ManyfoldService:
+    config = await load_config(db)
+    if not config.configured:
+        raise _error(409, "manyfold_not_configured", "Manyfold is not set up")
+    return ManyfoldService(config)
+
+
+def _id(value: str) -> str:
+    try:
+        return valid_id(value)
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+
+
+def _library_filename(name: str, file_id: str) -> str:
+    """The Manyfold file name, made safe for the printer's SD card."""
+    cleaned = "".join("_" if ch in INVALID_FILENAME_CHARS or ord(ch) < 0x20 else ch for ch in name)
+    cleaned = cleaned.rstrip(" .")
+    try:
+        validate_print_filename(cleaned)
+    except InvalidFilenameError:
+        cleaned = f"manyfold-{file_id}{os.path.splitext(name)[1].lower()}"
+    return cleaned
+
+
+# ---- connection ---------------------------------------------------------
+
+
+def _config_response(config: ManyfoldConfig) -> ManyfoldConfigResponse:
+    return ManyfoldConfigResponse(
+        url=config.url,
+        client_id=config.client_id,
+        has_client_secret=bool(config.client_secret),
+        configured=config.configured,
+    )
+
+
+@router.get("/config", response_model=ManyfoldConfigResponse)
+async def get_config(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """The stored connection, without the secret."""
+    return _config_response(await load_config(db))
+
+
+@router.put("/config", response_model=ManyfoldConfigResponse)
+async def update_config(
+    body: ManyfoldConfigUpdate,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Store the connection. An empty secret keeps the stored one."""
+    try:
+        url = normalize_url(body.url)
+    except ValueError as exc:
+        raise _error(400, "manyfold_bad_url", str(exc)) from exc
+    stored = await load_config(db)
+    secret = (body.client_secret or "").strip() or stored.client_secret
+    if not secret:
+        raise _error(400, "manyfold_secret_required", "Enter the client secret")
+    await set_setting(db, URL_KEY, url)
+    await set_setting(db, CLIENT_ID_KEY, body.client_id.strip())
+    await set_setting(db, CLIENT_SECRET_KEY, secret)
+    await db.commit()
+    clear_token_cache()
+    return _config_response(await load_config(db))
+
+
+@router.delete("/config", status_code=204)
+async def delete_config(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Disconnect Manyfold. Files imported earlier stay in the library."""
+    await db.execute(delete(Settings).where(Settings.key.in_(CONFIG_KEYS)))
+    await db.commit()
+    clear_token_cache()
+
+
+@router.post("/config/test", response_model=ManyfoldTestResponse)
+async def test_config(
+    body: ManyfoldTestRequest,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Sign in with the entered values and count the models Bambuddy can see.
+
+    Nothing is stored. An empty secret tests the stored one, so an admin can
+    re-test without typing it again.
+    """
+    try:
+        url = normalize_url(body.url)
+    except ValueError as exc:
+        raise _error(400, "manyfold_bad_url", str(exc)) from exc
+    secret = (body.client_secret or "").strip() or (await load_config(db)).client_secret
+    if not secret:
+        raise _error(400, "manyfold_secret_required", "Enter the client secret")
+    service = ManyfoldService(ManyfoldConfig(url=url, client_id=body.client_id.strip(), client_secret=secret))
+    try:
+        return ManyfoldTestResponse(model_count=await service.check())
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+    finally:
+        await service.close()
+
+
+# ---- browsing -----------------------------------------------------------
+
+
+@router.get("/status", response_model=ManyfoldStatus)
+async def get_status(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
+):
+    """Whether Manyfold is set up, and where it is."""
+    config = await load_config(db)
+    return ManyfoldStatus(configured=config.configured, url=config.url if config.configured else "")
+
+
+@router.get("/models", response_model=ManyfoldModelList)
+async def list_models(
+    q: str = "",
+    page: int = 1,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
+):
+    """One page of Manyfold's models, optionally searched.
+
+    ``q`` takes Manyfold's own search syntax; the page size is the
+    application owner's Manyfold setting.
+    """
+    service = await _service(db)
+    try:
+        return ManyfoldModelList(**await service.list_models(query=q[:200], page=max(1, min(page, 100000))))
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+    finally:
+        await service.close()
+
+
+@router.get("/models/{model_id}", response_model=ManyfoldModel)
+async def get_model(
+    model_id: str,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
+    actor: User | ApiKeyActor | None = RequestActor,
+):
+    """A model's details and files, with the library file of each earlier import
+    the caller may open."""
+    model_id = _id(model_id)
+    service = await _service(db)
+    try:
+        model = await service.get_model(model_id)
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+    finally:
+        await service.close()
+
+    imported: dict[str, ManyfoldLibraryRef] = {}
+    readable = owned_row_clauses(LibraryFile, actor, Permission.LIBRARY_READ_ALL, Permission.LIBRARY_READ_OWN)
+    rows = (
+        await db.execute(
+            LibraryFile.active()
+            .where(manyfold_provider.source_url_filter(LibraryFile.source_url, model_id), *readable)
+            .order_by(LibraryFile.id)
+        )
+        if readable is not None
+        else None
+    )
+    for row in rows.scalars().all() if rows is not None else []:
+        file_id = (row.source_url or "").rsplit("/", 1)[-1]
+        imported.setdefault(file_id, ManyfoldLibraryRef(id=row.id, filename=row.filename, folder_id=row.folder_id))
+
+    return ManyfoldModel(
+        id=model["id"],
+        name=model["name"],
+        caption=model["caption"],
+        description=model["description"],
+        license=model["license"],
+        tags=model["tags"],
+        url=model["url"],
+        has_preview=model["preview_file_id"] is not None,
+        files=[ManyfoldFile(**file, library_file=imported.get(file["id"])) for file in model["files"]],
+    )
+
+
+@router.get("/models/{model_id}/preview")
+async def get_preview(
+    model_id: str,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
+):
+    """The model's preview image, fetched from Manyfold.
+
+    The browser can't send Manyfold's token, and the page's CSP only allows
+    images from Bambuddy itself, so the image comes through here. The page
+    fetches it rather than pointing an ``<img>`` at it: a model without a
+    preview answers 404, and a failing ``<img>`` on a protected URL makes the
+    app renew its media token.
+    """
+    model_id = _id(model_id)
+    service = await _service(db)
+    try:
+        data, content_type = await service.fetch_preview(model_id)
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+    finally:
+        await service.close()
+    return Response(content=data, media_type=content_type, headers={"Cache-Control": "private, max-age=3600"})
+
+
+# ---- import -------------------------------------------------------------
+
+
+async def _import_folder_id(db: AsyncSession, folder_id: int | None, user: User | ApiKeyActor | None) -> int | None:
+    """The chosen folder, or the top-level "Manyfold" folder (created on first use)."""
+    if folder_id is not None:
+        # Only into a folder the user may write to (#3201).
+        folder = await get_writable_folder(db, folder_id, user)
+        if folder.is_external and folder.external_readonly:
+            raise HTTPException(status_code=403, detail="Cannot import into a read-only external folder")
+        return folder_id
+    folder = await default_import_folder(db, manyfold_provider.default_folder_name, user)
+    return folder.id
+
+
+@router.post("/import", response_model=ManyfoldImportResponse)
+async def import_file(
+    body: ManyfoldImportRequest,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_IMPORT),
+    actor: User | ApiKeyActor | None = RequestActor,
+):
+    """Download one Manyfold file into the library.
+
+    A file imported before, and still in the library, is returned as it is
+    rather than downloaded again.
+    """
+    model_id, file_id = _id(body.model_id), _id(body.file_id)
+    source_url = manyfold_provider.canonical_url(
+        ProviderResourceRef(source_type=manyfold_provider.source_type, external_id=model_id, sub_id=file_id)
+    )
+    # A copy the caller cannot open is no copy for them: they get their own.
+    readable = owned_row_clauses(LibraryFile, actor, Permission.LIBRARY_READ_ALL, Permission.LIBRARY_READ_OWN)
+    existing = (
+        (
+            await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url, *readable).limit(1))
+        ).scalar_one_or_none()
+        if readable is not None
+        else None
+    )
+    if existing is not None:
+        return ManyfoldImportResponse(
+            library_file_id=existing.id, filename=existing.filename, folder_id=existing.folder_id, was_existing=True
+        )
+
+    service = await _service(db)
+    try:
+        file = await service.get_file(model_id, file_id)
+        if not is_importable_filename(file["filename"]):
+            raise _error(
+                400,
+                "manyfold_not_importable",
+                "Only 3MF, STL and STEP files can be imported; Bambuddy can't slice or print the others",
+            )
+        data = await service.download_file(file)
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+    finally:
+        await service.close()
+
+    filename = _library_filename(file["filename"], file_id)
+    validate_print_file_upload(filename, data)
+    # Not saved for a user deleted during the download (SQLite reuses the id).
+    await require_actor_still_exists(db, actor)
+    folder_id = await _import_folder_id(db, body.folder_id, actor)
+    library_file, was_existing = await save_3mf_bytes_to_library(
+        db,
+        file_bytes=data,
+        filename=filename,
+        folder_id=folder_id,
+        source_type=manyfold_provider.source_type,
+        source_url=source_url,
+        owner_id=actor.id if actor else None,
+        dedupe_within=readable,
+    )
+    logger.info(
+        "[MANYFOLD] Imported %s (model %s, file %s) as library file %s", filename, model_id, file_id, library_file.id
+    )
+    return ManyfoldImportResponse(
+        library_file_id=library_file.id,
+        filename=library_file.filename,
+        folder_id=library_file.folder_id,
+        was_existing=was_existing,
+    )

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

@@ -53,7 +53,7 @@ def state_to_numeric(state: str) -> int:
 
 
 @router.get("/metrics", response_class=Response)
 @router.get("/metrics", response_class=Response)
 async def get_metrics(
 async def get_metrics(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     authorization: str | None = Header(None),
     authorization: str | None = Header(None),
 ):
 ):
     """
     """

+ 111 - 80
backend/app/api/routes/mfa.py

@@ -34,13 +34,14 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Requ
 from fastapi.responses import RedirectResponse
 from fastapi.responses import RedirectResponse
 from jwt import PyJWKClient
 from jwt import PyJWKClient
 from passlib.context import CryptContext
 from passlib.context import CryptContext
-from sqlalchemy import delete, select, update
+from sqlalchemy import delete, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload, undefer
 from sqlalchemy.orm import selectinload, undefer
 
 
 from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
 from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
 from backend.app.api.routes.settings import get_setting, set_setting
 from backend.app.api.routes.settings import get_setting, set_setting
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     create_access_token,
     create_access_token,
     get_current_active_user,
     get_current_active_user,
@@ -48,6 +49,8 @@ from backend.app.core.auth import (
     get_user_by_username,
     get_user_by_username,
     is_auth_enabled,
     is_auth_enabled,
     resolve_session_max_minutes,
     resolve_session_max_minutes,
+    session_dated,
+    token_outlived_its_session,
     verify_password,
     verify_password,
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
@@ -65,7 +68,6 @@ from backend.app.schemas.auth import (
     EmailOTPDisableRequest,
     EmailOTPDisableRequest,
     EmailOTPEnableConfirmRequest,
     EmailOTPEnableConfirmRequest,
     EmailOTPSendRequest,
     EmailOTPSendRequest,
-    GroupBrief,
     LoginResponse,
     LoginResponse,
     OIDCAuthorizeResponse,
     OIDCAuthorizeResponse,
     OIDCExchangeRequest,
     OIDCExchangeRequest,
@@ -160,9 +162,9 @@ def _etag_matches(if_none_match: str | None, etag_raw: str | None) -> bool:
 def _as_utc(dt: datetime) -> datetime:
 def _as_utc(dt: datetime) -> datetime:
     """Return *dt* with UTC timezone attached.
     """Return *dt* with UTC timezone attached.
 
 
-    SQLite/aiosqlite strips timezone info when reading DateTime(timezone=True)
-    columns back – the stored value is always UTC, so we just re-attach the
-    info when doing Python-level comparisons.
+    DateTime columns come back naive on both SQLite and PostgreSQL – the
+    stored value is always UTC, so we just re-attach the info when doing
+    Python-level comparisons.
     """
     """
     return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
     return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
 
 
@@ -194,17 +196,12 @@ router = APIRouter(prefix="/auth", tags=["2fa", "oidc"])
 # Helper: user response
 # Helper: user response
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 def _user_to_response(user: User) -> UserResponse:
 def _user_to_response(user: User) -> UserResponse:
-    return UserResponse(
-        id=user.id,
-        username=user.username,
-        email=user.email,
-        role=user.role,
-        is_active=user.is_active,
-        is_admin=user.is_admin,
-        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
-        permissions=sorted(user.get_permissions()),
-        created_at=user.created_at.isoformat(),
-    )
+    # The auth routes' builder, not a copy: the copy here left out
+    # ``auth_source``, so a 2FA or OIDC login reported LDAP and OIDC users as
+    # local and the page offered them "Change Password" (PC-C9).
+    from backend.app.api.routes.auth import _user_to_response as build
+
+    return build(user)
 
 
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
@@ -283,12 +280,15 @@ async def consume_pre_auth_token(db: AsyncSession, token: str, challenge_id: str
             AuthEphemeralToken.token_type == TokenType.PRE_AUTH,
             AuthEphemeralToken.token_type == TokenType.PRE_AUTH,
             AuthEphemeralToken.expires_at > now,
             AuthEphemeralToken.expires_at > now,
         )
         )
-        .returning(AuthEphemeralToken.username, AuthEphemeralToken.challenge_id)
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.challenge_id, AuthEphemeralToken.created_at)
     )
     )
     row = result.one_or_none()
     row = result.one_or_none()
     if row is None:
     if row is None:
         return None
         return None
-    username, stored_challenge_id = row
+    username, stored_challenge_id, minted_at = row
+    if await token_outlived_its_session(db, username, minted_at):
+        await db.commit()  # spent all the same
+        return None
     # Enforce client binding: if the token was issued with a challenge_id,
     # Enforce client binding: if the token was issued with a challenge_id,
     # the caller must supply the matching value.
     # the caller must supply the matching value.
     if stored_challenge_id is not None and stored_challenge_id != challenge_id:
     if stored_challenge_id is not None and stored_challenge_id != challenge_id:
@@ -314,7 +314,7 @@ async def peek_pre_auth_token(db: AsyncSession, token: str, challenge_id: str |
         )
         )
     )
     )
     eph = result.scalar_one_or_none()
     eph = result.scalar_one_or_none()
-    if eph is None:
+    if eph is None or await token_outlived_its_session(db, eph.username, eph.created_at):
         return None
         return None
     # Enforce client binding: if the token was issued with a challenge_id the
     # Enforce client binding: if the token was issued with a challenge_id the
     # cookie must match.  Treat a mismatch as if the token doesn't exist.
     # cookie must match.  Treat a mismatch as if the token doesn't exist.
@@ -421,27 +421,17 @@ async def record_email_otp_send(db: AsyncSession, username: str) -> None:
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # TOTP replay-protection helper
 # TOTP replay-protection helper
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
-def _assert_totp_not_replayed(totp_obj: pyotp.TOTP, totp_record: UserTOTP, code: str) -> None:
-    """Raise HTTP 400 if this TOTP code was already accepted in its time window.
+def _matched_totp_counter(totp_obj: pyotp.TOTP, code: str) -> int | None:
+    """The time step whose code matches, one step either side of now, or None.
 
 
-    M3 fix: store the counter of the *accepted* code rather than the current
-    wall-clock counter.  With valid_window=1, pyotp accepts codes from the
-    previous 30-second step.  Using timecode(now) would store the wrong counter
-    when the previous-window code is accepted, allowing immediate replay.
+    The caller records the returned step with ``accept_counter``. Matching and
+    naming the step share one clock reading.
     """
     """
-    # Determine which time-step the accepted code belongs to.
-    now = datetime.now(timezone.utc)
-    accepted_counter: int | None = None
-    for offset in (0, -1):  # current window first, then previous
-        candidate_time = now.timestamp() + offset * totp_obj.interval
-        candidate_counter = totp_obj.timecode(datetime.fromtimestamp(candidate_time, tz=timezone.utc))
-        if totp_obj.at(candidate_counter) == code:
-            accepted_counter = candidate_counter
-            break
-    if accepted_counter is None:
-        accepted_counter = totp_obj.timecode(now)  # fallback (should not happen after verify())
-
-    totp_record.accept_counter(accepted_counter)
+    current = totp_obj.timecode(datetime.now(timezone.utc))
+    for counter in (current, current - 1, current + 1):
+        if pyotp.utils.strings_equal(str(code), totp_obj.generate_otp(counter)):
+            return counter
+    return None
 
 
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
@@ -634,7 +624,7 @@ async def _set_email_2fa_enabled(db: AsyncSession, user_id: int, enabled: bool)
 @router.get("/2fa/status", response_model=TwoFAStatusResponse)
 @router.get("/2fa/status", response_model=TwoFAStatusResponse)
 async def get_2fa_status(
 async def get_2fa_status(
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> TwoFAStatusResponse:
 ) -> TwoFAStatusResponse:
     """Return the current 2FA configuration for the authenticated user."""
     """Return the current 2FA configuration for the authenticated user."""
     result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))
     result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))
@@ -655,7 +645,7 @@ async def get_2fa_status(
 async def setup_totp(
 async def setup_totp(
     body: TOTPSetupRequest | None = Body(default=None),
     body: TOTPSetupRequest | None = Body(default=None),
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> TOTPSetupResponse:
 ) -> TOTPSetupResponse:
     """Initiate TOTP setup: generates a new secret and QR code.
     """Initiate TOTP setup: generates a new secret and QR code.
 
 
@@ -679,7 +669,7 @@ async def setup_totp(
         # S4: narrow the RuntimeError catch to ONLY the property access — that
         # S4: narrow the RuntimeError catch to ONLY the property access — that
         # is the single line that raises on key-loss. The previous wide try
         # is the single line that raises on key-loss. The previous wide try
         # block also covered record_failed_attempt, clear_failed_attempts,
         # block also covered record_failed_attempt, clear_failed_attempts,
-        # and _assert_totp_not_replayed, so a future RuntimeError from any
+        # and the replay guard, so a future RuntimeError from any
         # of those would have been misreported as "TOTP secret unavailable".
         # of those would have been misreported as "TOTP secret unavailable".
         try:
         try:
             secret_plain = existing.secret
             secret_plain = existing.secret
@@ -689,14 +679,15 @@ async def setup_totp(
                 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                 detail="TOTP secret unavailable",
                 detail="TOTP secret unavailable",
             )
             )
-        if not pyotp.TOTP(secret_plain).verify(supplied_code, valid_window=1):
+        counter = _matched_totp_counter(pyotp.TOTP(secret_plain), supplied_code)
+        if counter is None:
             await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
             await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
             raise HTTPException(
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Current TOTP code required to replace an active authenticator",
                 detail="Current TOTP code required to replace an active authenticator",
             )
             )
         await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
         await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
-        _assert_totp_not_replayed(pyotp.TOTP(secret_plain), existing, supplied_code)
+        existing.accept_counter(counter)
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
 
 
     secret = pyotp.random_base32()
     secret = pyotp.random_base32()
@@ -720,7 +711,7 @@ async def setup_totp(
 async def enable_totp(
 async def enable_totp(
     body: TOTPEnableRequest,
     body: TOTPEnableRequest,
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> TOTPEnableResponse:
 ) -> TOTPEnableResponse:
     """Confirm TOTP setup by verifying a code from the authenticator app.
     """Confirm TOTP setup by verifying a code from the authenticator app.
 
 
@@ -763,7 +754,7 @@ async def enable_totp(
 async def disable_totp(
 async def disable_totp(
     body: TOTPDisableRequest,
     body: TOTPDisableRequest,
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
 ) -> dict:
     """Disable TOTP by verifying a valid TOTP code or a backup code.
     """Disable TOTP by verifying a valid TOTP code or a backup code.
 
 
@@ -782,10 +773,12 @@ async def disable_totp(
     # code path so the user can still disable 2FA with their printed codes.
     # code path so the user can still disable 2FA with their printed codes.
     totp_obj: pyotp.TOTP | None = None
     totp_obj: pyotp.TOTP | None = None
     code_valid = False
     code_valid = False
+    counter: int | None = None
     decryption_failed = False
     decryption_failed = False
     try:
     try:
         totp_obj = pyotp.TOTP(totp_record.secret)
         totp_obj = pyotp.TOTP(totp_record.secret)
-        code_valid = totp_obj.verify(body.code, valid_window=1)
+        counter = _matched_totp_counter(totp_obj, body.code)
+        code_valid = counter is not None
     except RuntimeError:
     except RuntimeError:
         # S3: track that the failure was server-side so we don't penalise
         # S3: track that the failure was server-side so we don't penalise
         # the user with a fail-counter increment for a problem they can't fix.
         # the user with a fail-counter increment for a problem they can't fix.
@@ -795,8 +788,8 @@ async def disable_totp(
             totp_record.user_id,
             totp_record.user_id,
         )
         )
 
 
-    if code_valid and totp_obj is not None:
-        _assert_totp_not_replayed(totp_obj, totp_record, body.code)
+    if code_valid and counter is not None:
+        totp_record.accept_counter(counter)
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
     else:
     else:
         # Check backup codes — always iterate all entries (L-R9-A: no early break
         # Check backup codes — always iterate all entries (L-R9-A: no early break
@@ -823,7 +816,7 @@ async def disable_totp(
 async def regenerate_backup_codes(
 async def regenerate_backup_codes(
     body: TOTPDisableRequest,
     body: TOTPDisableRequest,
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> BackupCodesResponse:
 ) -> BackupCodesResponse:
     """Generate 10 new backup codes. Requires a valid TOTP code OR a backup code.
     """Generate 10 new backup codes. Requires a valid TOTP code OR a backup code.
 
 
@@ -844,10 +837,12 @@ async def regenerate_backup_codes(
     # rotate their codes with a printed backup code.
     # rotate their codes with a printed backup code.
     totp_obj: pyotp.TOTP | None = None
     totp_obj: pyotp.TOTP | None = None
     code_valid = False
     code_valid = False
+    counter: int | None = None
     decryption_failed = False
     decryption_failed = False
     try:
     try:
         totp_obj = pyotp.TOTP(totp_record.secret)
         totp_obj = pyotp.TOTP(totp_record.secret)
-        code_valid = totp_obj.verify(body.code, valid_window=1)
+        counter = _matched_totp_counter(totp_obj, body.code)
+        code_valid = counter is not None
     except RuntimeError:
     except RuntimeError:
         # S3: track server-side failure so we skip the fail-counter debit.
         # S3: track server-side failure so we skip the fail-counter debit.
         decryption_failed = True
         decryption_failed = True
@@ -856,8 +851,8 @@ async def regenerate_backup_codes(
             totp_record.user_id,
             totp_record.user_id,
         )
         )
 
 
-    if code_valid and totp_obj is not None:
-        _assert_totp_not_replayed(totp_obj, totp_record, body.code)
+    if code_valid and counter is not None:
+        totp_record.accept_counter(counter)
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
     else:
     else:
         # Accept a backup code as an alternative (M10)
         # Accept a backup code as an alternative (M10)
@@ -887,7 +882,7 @@ async def regenerate_backup_codes(
 @router.post("/2fa/email/enable")
 @router.post("/2fa/email/enable")
 async def enable_email_otp(
 async def enable_email_otp(
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
 ) -> dict:
     """Step 1 of email OTP enable: send a verification code to the user's email.
     """Step 1 of email OTP enable: send a verification code to the user's email.
 
 
@@ -929,6 +924,7 @@ async def enable_email_otp(
             # Reuse the nonce field to store the code hash
             # Reuse the nonce field to store the code hash
             nonce=code_hash,
             nonce=code_hash,
             expires_at=now + timedelta(minutes=10),
             expires_at=now + timedelta(minutes=10),
+            **session_dated(current_user.username),
         )
         )
     )
     )
     await db.commit()
     await db.commit()
@@ -965,7 +961,7 @@ async def enable_email_otp(
 async def confirm_enable_email_otp(
 async def confirm_enable_email_otp(
     body: EmailOTPEnableConfirmRequest,
     body: EmailOTPEnableConfirmRequest,
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
 ) -> dict:
     """Step 2 of email OTP enable: verify the code and activate email 2FA.
     """Step 2 of email OTP enable: verify the code and activate email 2FA.
 
 
@@ -987,7 +983,7 @@ async def confirm_enable_email_otp(
         )
         )
     )
     )
     eph = peek_result.scalar_one_or_none()
     eph = peek_result.scalar_one_or_none()
-    if eph is None:
+    if eph is None or await token_outlived_its_session(db, eph.username, eph.created_at):
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired setup token")
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired setup token")
 
 
     code_hash = eph.nonce  # code hash stored in the nonce field
     code_hash = eph.nonce  # code hash stored in the nonce field
@@ -1022,7 +1018,7 @@ async def confirm_enable_email_otp(
 async def disable_email_otp(
 async def disable_email_otp(
     body: EmailOTPDisableRequest,
     body: EmailOTPDisableRequest,
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
 ) -> dict:
     """Disable email-based OTP 2FA for the current user.
     """Disable email-based OTP 2FA for the current user.
 
 
@@ -1045,7 +1041,7 @@ async def disable_email_otp(
 async def send_email_otp(
 async def send_email_otp(
     request: Request,
     request: Request,
     body: EmailOTPSendRequest,
     body: EmailOTPSendRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
 ) -> dict:
     """Send a 6-digit OTP code to the user's email address.
     """Send a 6-digit OTP code to the user's email address.
 
 
@@ -1134,7 +1130,7 @@ async def send_email_otp(
 async def verify_2fa(
 async def verify_2fa(
     request: Request,
     request: Request,
     body: TwoFAVerifyRequest,
     body: TwoFAVerifyRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> TwoFAVerifyResponse:
 ) -> TwoFAVerifyResponse:
     """Verify a 2FA code and exchange the pre_auth_token for a full JWT.
     """Verify a 2FA code and exchange the pre_auth_token for a full JWT.
 
 
@@ -1170,10 +1166,11 @@ async def verify_2fa(
         except RuntimeError:
         except RuntimeError:
             logger.exception("TOTP decryption failed for user_id=%s", totp_record.user_id)
             logger.exception("TOTP decryption failed for user_id=%s", totp_record.user_id)
             raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="TOTP secret unavailable")
             raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="TOTP secret unavailable")
-        if not totp_obj.verify(body.code, valid_window=1):
+        counter = _matched_totp_counter(totp_obj, body.code)
+        if counter is None:
             await record_failed_attempt(db, username)
             await record_failed_attempt(db, username)
             raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code")
             raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code")
-        _assert_totp_not_replayed(totp_obj, totp_record, body.code)
+        totp_record.accept_counter(counter)
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
 
 
     elif method == "email":
     elif method == "email":
@@ -1277,11 +1274,14 @@ async def verify_2fa(
 async def admin_disable_2fa(
 async def admin_disable_2fa(
     user_id: int,
     user_id: int,
     body: AdminDisable2FARequest = Body(default_factory=AdminDisable2FARequest),
     body: AdminDisable2FARequest = Body(default_factory=AdminDisable2FARequest),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
 ) -> dict:
     """Admin endpoint: disable all 2FA for a given user.
     """Admin endpoint: disable all 2FA for a given user.
 
 
+    Admin-only, like every other change to a user account (users.update_user).
+
     Nit 3: Requires the admin's own password as a re-auth step (matching how
     Nit 3: Requires the admin's own password as a re-auth step (matching how
     disable_email_otp protects a user's own 2FA removal). OIDC/LDAP-only admins
     disable_email_otp protects a user's own 2FA removal). OIDC/LDAP-only admins
     (no local password_hash) are exempt.
     (no local password_hash) are exempt.
@@ -1291,6 +1291,14 @@ async def admin_disable_2fa(
         if not body.admin_password or not verify_password(body.admin_password, current_user.password_hash):
         if not body.admin_password or not verify_password(body.admin_password, current_user.password_hash):
             raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin password required")
             raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin password required")
 
 
+    target_user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
+    if target_user is None:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
+
+    # Reported back so the Edit user dialog can say whether anything was removed.
+    totp = (await db.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))).scalar_one_or_none()
+    had_2fa = bool(totp is not None and totp.is_enabled) or await _get_email_2fa_enabled(db, user_id)
+
     # Delete TOTP record
     # Delete TOTP record
     await db.execute(delete(UserTOTP).where(UserTOTP.user_id == user_id))
     await db.execute(delete(UserTOTP).where(UserTOTP.user_id == user_id))
 
 
@@ -1306,14 +1314,21 @@ async def admin_disable_2fa(
 
 
     # I2: Invalidate existing JWTs for the target user by bumping password_changed_at.
     # I2: Invalidate existing JWTs for the target user by bumping password_changed_at.
     # Without this, a stolen token remains valid after 2FA removal.
     # Without this, a stolen token remains valid after 2FA removal.
-    target_user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
-    if target_user:
-        target_user.password_changed_at = datetime.now(timezone.utc)
+    target_user.password_changed_at = datetime.now(timezone.utc)
+    # The WebSocket and camera tokens too: reusable for an hour, they would
+    # reconnect a closed dashboard straight away.
+    from backend.app.api.routes.users import drop_pending_tokens
+
+    await drop_pending_tokens(db, target_user.username)
 
 
     await db.commit()
     await db.commit()
+    # And their open dashboards: they are never signed in again.
+    from backend.app.core.websocket import ws_manager
+
+    await ws_manager.close_user(user_id)
     actor = current_user.username if current_user else "anonymous"
     actor = current_user.username if current_user else "anonymous"
     logger.info("Admin %s disabled all 2FA for user_id=%d", actor, user_id)
     logger.info("Admin %s disabled all 2FA for user_id=%d", actor, user_id)
-    return {"message": "2FA disabled for user"}
+    return {"message": "2FA disabled for user", "had_2fa": had_2fa}
 
 
 
 
 # ===========================================================================
 # ===========================================================================
@@ -1323,7 +1338,7 @@ async def admin_disable_2fa(
 
 
 @router.get("/oidc/providers", response_model=list[OIDCProviderPublicResponse])
 @router.get("/oidc/providers", response_model=list[OIDCProviderPublicResponse])
 async def list_oidc_providers(
 async def list_oidc_providers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> list[OIDCProviderPublicResponse]:
 ) -> list[OIDCProviderPublicResponse]:
     """List all enabled OIDC providers (public).
     """List all enabled OIDC providers (public).
 
 
@@ -1344,7 +1359,7 @@ async def list_oidc_providers(
 @router.get("/oidc/providers/all", response_model=list[OIDCProviderResponse])
 @router.get("/oidc/providers/all", response_model=list[OIDCProviderResponse])
 async def list_all_oidc_providers(
 async def list_all_oidc_providers(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> list[OIDCProviderResponse]:
 ) -> list[OIDCProviderResponse]:
     """List ALL OIDC providers including disabled ones (admin only)."""
     """List ALL OIDC providers including disabled ones (admin only)."""
     result2 = await db.execute(select(OIDCProvider))
     result2 = await db.execute(select(OIDCProvider))
@@ -1355,8 +1370,9 @@ async def list_all_oidc_providers(
 @router.post("/oidc/providers", response_model=OIDCProviderResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/oidc/providers", response_model=OIDCProviderResponse, status_code=status.HTTP_201_CREATED)
 async def create_oidc_provider(
 async def create_oidc_provider(
     body: OIDCProviderCreate,
     body: OIDCProviderCreate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> OIDCProviderResponse:
 ) -> OIDCProviderResponse:
     """Create a new OIDC provider (admin only).
     """Create a new OIDC provider (admin only).
 
 
@@ -1455,8 +1471,9 @@ async def _missing_group_names(db: AsyncSession, names: set[str]) -> set[str]:
 async def update_oidc_provider(
 async def update_oidc_provider(
     provider_id: int,
     provider_id: int,
     body: OIDCProviderUpdate,
     body: OIDCProviderUpdate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> OIDCProviderResponse:
 ) -> OIDCProviderResponse:
     """Update an existing OIDC provider (admin only).
     """Update an existing OIDC provider (admin only).
 
 
@@ -1554,8 +1571,9 @@ async def update_oidc_provider(
 @router.delete("/oidc/providers/{provider_id}")
 @router.delete("/oidc/providers/{provider_id}")
 async def delete_oidc_provider(
 async def delete_oidc_provider(
     provider_id: int,
     provider_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
 ) -> dict:
     """Delete an OIDC provider and all its user links (admin only)."""
     """Delete an OIDC provider and all its user links (admin only)."""
     result2 = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
     result2 = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
@@ -1578,7 +1596,7 @@ async def delete_oidc_provider(
 async def get_oidc_provider_icon(
 async def get_oidc_provider_icon(
     provider_id: int,
     provider_id: int,
     if_none_match: str | None = Header(default=None, alias="If-None-Match"),
     if_none_match: str | None = Header(default=None, alias="If-None-Match"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> Response:
 ) -> Response:
     """Serve the cached icon for an enabled OIDC provider (public, no auth).
     """Serve the cached icon for an enabled OIDC provider (public, no auth).
 
 
@@ -1616,8 +1634,9 @@ async def get_oidc_provider_icon(
 @router.delete("/oidc/providers/{provider_id}/icon", status_code=status.HTTP_204_NO_CONTENT)
 @router.delete("/oidc/providers/{provider_id}/icon", status_code=status.HTTP_204_NO_CONTENT)
 async def delete_oidc_provider_icon(
 async def delete_oidc_provider_icon(
     provider_id: int,
     provider_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> Response:
 ) -> Response:
     """Remove the icon entirely for a provider (admin only).
     """Remove the icon entirely for a provider (admin only).
 
 
@@ -1645,8 +1664,9 @@ async def delete_oidc_provider_icon(
 @router.post("/oidc/providers/{provider_id}/icon/refresh", response_model=OIDCProviderResponse)
 @router.post("/oidc/providers/{provider_id}/icon/refresh", response_model=OIDCProviderResponse)
 async def refresh_oidc_provider_icon(
 async def refresh_oidc_provider_icon(
     provider_id: int,
     provider_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> OIDCProviderResponse:
 ) -> OIDCProviderResponse:
     """Refetch the icon from the stored `icon_url` (admin only).
     """Refetch the icon from the stored `icon_url` (admin only).
 
 
@@ -1683,7 +1703,7 @@ async def refresh_oidc_provider_icon(
 @router.get("/oidc/authorize/{provider_id}", response_model=OIDCAuthorizeResponse)
 @router.get("/oidc/authorize/{provider_id}", response_model=OIDCAuthorizeResponse)
 async def oidc_authorize(
 async def oidc_authorize(
     provider_id: int,
     provider_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> OIDCAuthorizeResponse:
 ) -> OIDCAuthorizeResponse:
     """Return the OIDC authorization URL for the given provider."""
     """Return the OIDC authorization URL for the given provider."""
     result = await db.execute(
     result = await db.execute(
@@ -1768,7 +1788,7 @@ async def oidc_callback(
     code: str | None = Query(default=None, max_length=2048),
     code: str | None = Query(default=None, max_length=2048),
     state: str | None = Query(default=None, max_length=2048),
     state: str | None = Query(default=None, max_length=2048),
     error: str | None = Query(default=None, max_length=256),
     error: str | None = Query(default=None, max_length=256),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> RedirectResponse:
 ) -> RedirectResponse:
     """Handle the OIDC authorization code callback from the identity provider."""
     """Handle the OIDC authorization code callback from the identity provider."""
     external_url = await _get_base_external_url(db)
     external_url = await _get_base_external_url(db)
@@ -2181,7 +2201,7 @@ async def oidc_exchange(
     body: OIDCExchangeRequest,
     body: OIDCExchangeRequest,
     raw_request: Request,
     raw_request: Request,
     response: Response,
     response: Response,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> LoginResponse:
 ) -> LoginResponse:
     """Exchange an OIDC exchange token (from the callback redirect) for a full JWT.
     """Exchange an OIDC exchange token (from the callback redirect) for a full JWT.
 
 
@@ -2198,14 +2218,16 @@ async def oidc_exchange(
             AuthEphemeralToken.token_type == TokenType.OIDC_EXCHANGE,
             AuthEphemeralToken.token_type == TokenType.OIDC_EXCHANGE,
             AuthEphemeralToken.expires_at > now,  # reject expired tokens atomically
             AuthEphemeralToken.expires_at > now,  # reject expired tokens atomically
         )
         )
-        .returning(AuthEphemeralToken.username)
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.created_at)
     )
     )
     row = consume_result.one_or_none()
     row = consume_result.one_or_none()
     if row is None:
     if row is None:
         raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired OIDC exchange token")
         raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired OIDC exchange token")
 
 
-    (username,) = row
+    username, minted_at = row
     await db.commit()
     await db.commit()
+    if await token_outlived_its_session(db, username, minted_at):
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired OIDC exchange token")
 
 
     user = await get_user_by_username(db, username)
     user = await get_user_by_username(db, username)
     if not user or not user.is_active:
     if not user or not user.is_active:
@@ -2265,7 +2287,7 @@ async def oidc_exchange(
 @router.get("/oidc/links", response_model=list[OIDCLinkResponse])
 @router.get("/oidc/links", response_model=list[OIDCLinkResponse])
 async def list_oidc_links(
 async def list_oidc_links(
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> list[OIDCLinkResponse]:
 ) -> list[OIDCLinkResponse]:
     """List all OIDC provider links for the current user."""
     """List all OIDC provider links for the current user."""
     result = await db.execute(
     result = await db.execute(
@@ -2293,7 +2315,7 @@ async def list_oidc_links(
 async def remove_oidc_link(
 async def remove_oidc_link(
     provider_id: int,
     provider_id: int,
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
 ) -> dict:
     """Remove the OIDC link between the current user and a provider."""
     """Remove the OIDC link between the current user and a provider."""
     result = await db.execute(
     result = await db.execute(
@@ -2304,6 +2326,15 @@ async def remove_oidc_link(
     link = result.scalar_one_or_none()
     link = result.scalar_one_or_none()
     if not link:
     if not link:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC link not found")
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC link not found")
+    # A user created from an SSO login has no password: their last link is
+    # their only way to sign in.
+    if current_user.auth_source == "oidc" and not current_user.password_hash:
+        links = await db.execute(select(func.count()).where(UserOIDCLink.user_id == current_user.id))
+        if links.scalar_one() <= 1:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="This is the only way this account can sign in; it can't be unlinked",
+            )
 
 
     await db.delete(link)
     await db.delete(link)
     await db.commit()
     await db.commit()

+ 13 - 4
backend/app/api/routes/notification_templates.py

@@ -37,9 +37,18 @@ EVENT_NAMES = {
     "ai_failure_detection": "AI Failure Detection",
     "ai_failure_detection": "AI Failure Detection",
     "plate_not_empty": "Plate Not Empty",
     "plate_not_empty": "Plate Not Empty",
     "plate_clear_required": "Plate Clear Required",
     "plate_clear_required": "Plate Clear Required",
+    "print_confirm_request": "Print Outcome Confirmation",
+    "print_missing_spool_assignment": "Missing Spool Assignment",
+    "bed_cooled": "Bed Cooled",
+    "ams_humidity_high": "AMS Humidity High",
+    "ams_temperature_high": "AMS Temperature High",
+    "ams_drying_suspended": "Auto-Drying Suspended",
+    "ha_sensor_alert": "Printer Sensor Alert",
+    "location_ha_sensor_alert": "Storage Location Sensor Alert",
     "filament_low": "Filament Low",
     "filament_low": "Filament Low",
     "maintenance_due": "Maintenance Due",
     "maintenance_due": "Maintenance Due",
     "first_layer_complete": "First Layer Complete",
     "first_layer_complete": "First Layer Complete",
+    "print_pause_upcoming": "Pause Coming Up",
     "test": "Test Notification",
     "test": "Test Notification",
     # Queue notifications
     # Queue notifications
     "queue_job_added": "Queue Job Added",
     "queue_job_added": "Queue Job Added",
@@ -66,7 +75,7 @@ EVENT_NAMES = {
 @router.get("", response_model=list[NotificationTemplateResponse])
 @router.get("", response_model=list[NotificationTemplateResponse])
 @router.get("/", response_model=list[NotificationTemplateResponse])
 @router.get("/", response_model=list[NotificationTemplateResponse])
 async def get_templates(
 async def get_templates(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
 ):
 ):
     """Get all notification templates."""
     """Get all notification templates."""
@@ -93,7 +102,7 @@ async def get_variables(
 @router.get("/{template_id}", response_model=NotificationTemplateResponse)
 @router.get("/{template_id}", response_model=NotificationTemplateResponse)
 async def get_template(
 async def get_template(
     template_id: int,
     template_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
 ):
 ):
     """Get a single notification template."""
     """Get a single notification template."""
@@ -108,7 +117,7 @@ async def get_template(
 async def update_template(
 async def update_template(
     template_id: int,
     template_id: int,
     update: NotificationTemplateUpdate,
     update: NotificationTemplateUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
 ):
 ):
     """Update a notification template."""
     """Update a notification template."""
@@ -134,7 +143,7 @@ async def update_template(
 @router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
 @router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
 async def reset_template(
 async def reset_template(
     template_id: int,
     template_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
 ):
 ):
     """Reset a notification template to its default values."""
     """Reset a notification template to its default values."""

+ 288 - 57
backend/app/api/routes/notifications.py

@@ -8,13 +8,21 @@ from datetime import datetime, timedelta, timezone
 
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import FileResponse
 from fastapi.responses import FileResponse
-from sqlalchemy import delete, desc, func, select
+from sqlalchemy import delete, desc, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, ScopedCaller, require_notification_send
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    ScopedCaller,
+    probe_permissions_if_auth_enabled,
+    require_notification_send,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope, ensure_printers_exist
 from backend.app.models.notification import NotificationLog, NotificationProvider
 from backend.app.models.notification import NotificationLog, NotificationProvider
+from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.notification import (
 from backend.app.schemas.notification import (
     AppMessage,
     AppMessage,
@@ -29,6 +37,15 @@ from backend.app.schemas.notification import (
     NotificationTestResponse,
     NotificationTestResponse,
 )
 )
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
+from backend.app.services.notify_client import NotifyError, notify_credentials
+from backend.app.services.notify_live_activities import notify_live_activities
+from backend.app.services.notify_widgets import notify_widgets
+from backend.app.services.secret_fields import (
+    SecretConfigError,
+    mask_config,
+    merge_config,
+    reject_placeholder,
+)
 from backend.app.services.telegram_reactions import telegram_reaction_poller
 from backend.app.services.telegram_reactions import telegram_reaction_poller
 from backend.app.utils.notification_photos import find_notification_photo
 from backend.app.utils.notification_photos import find_notification_photo
 
 
@@ -37,6 +54,15 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/notifications", tags=["notifications"])
 router = APIRouter(prefix="/notifications", tags=["notifications"])
 
 
 
 
+def _notify_providers_changed() -> None:
+    """Wake optional workers after a provider edit, without waiting for their HTTP work."""
+    for worker in (notify_live_activities, notify_widgets):
+        try:
+            worker.providers_changed()
+        except Exception:
+            logger.exception("Could not refresh Notify worker configuration")
+
+
 async def _resync_reaction_poller():
 async def _resync_reaction_poller():
     """Start/stop Telegram reaction polls after a provider changed (#3046).
     """Start/stop Telegram reaction polls after a provider changed (#3046).
 
 
@@ -49,14 +75,48 @@ async def _resync_reaction_poller():
         logger.warning("Telegram reaction poller resync failed: %s", e)
         logger.warning("Telegram reaction poller resync failed: %s", e)
 
 
 
 
+async def delete_provider(db: AsyncSession, provider: NotificationProvider) -> bool:
+    """Delete a provider (the caller commits, then calls after_providers_deleted).
+
+    A Bambuddy Notify provider is first retired and its live activities and
+    widgets queued for remote cleanup, while the rows that name them still
+    exist. Returns whether it was one.
+    """
+    notify = provider.provider_type == "notify"
+    if notify:
+        config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+        # Retire this provider and capture owned IDs before FK cascade removes
+        # them. Queue remote cleanup; HTTP must never delay this request.
+        provider.enabled = False
+        await db.commit()
+        await notify_live_activities.schedule_cleanup(provider.id, config)
+        await notify_widgets.schedule_cleanup(provider.id, config)
+    await db.delete(provider)
+    return notify
+
+
+async def after_providers_deleted(*, notify: bool) -> None:
+    """What deleted providers leave to do once the delete is committed."""
+    if notify:
+        _notify_providers_changed()
+    await _resync_reaction_poller()
+
+
+def _stored_config(provider: NotificationProvider) -> dict:
+    return json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+
+
 def _provider_to_dict(provider: NotificationProvider) -> dict:
 def _provider_to_dict(provider: NotificationProvider) -> dict:
-    """Convert a NotificationProvider model to a response dictionary."""
+    """Convert a NotificationProvider model to a response dictionary.
+
+    Secrets are write-only: a set one is shown as the placeholder.
+    """
     return {
     return {
         "id": provider.id,
         "id": provider.id,
         "name": provider.name,
         "name": provider.name,
         "provider_type": provider.provider_type,
         "provider_type": provider.provider_type,
         "enabled": provider.enabled,
         "enabled": provider.enabled,
-        "config": json.loads(provider.config) if isinstance(provider.config, str) else provider.config,
+        "config": mask_config(_stored_config(provider)),
         "attach_photo": provider.attach_photo,
         "attach_photo": provider.attach_photo,
         # Print lifecycle events
         # Print lifecycle events
         "on_print_start": provider.on_print_start,
         "on_print_start": provider.on_print_start,
@@ -95,6 +155,8 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_bed_cooled": provider.on_bed_cooled,
         "on_bed_cooled": provider.on_bed_cooled,
         # First layer complete
         # First layer complete
         "on_first_layer_complete": provider.on_first_layer_complete,
         "on_first_layer_complete": provider.on_first_layer_complete,
+        # A slicer pause coming up (#2984)
+        "on_print_pause_upcoming": bool(provider.on_print_pause_upcoming),
         # Messages from connected apps
         # Messages from connected apps
         "on_app_message": bool(provider.on_app_message),
         "on_app_message": bool(provider.on_app_message),
         # Inventory stock alerts. Absent here, the toggles above always read
         # Inventory stock alerts. Absent here, the toggles above always read
@@ -134,14 +196,58 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
 # ============================================================================
 # ============================================================================
 
 
 
 
+def _provider_in_scope(provider: NotificationProvider, printer_scope: PrinterScope) -> bool:
+    """Whether a caller limited to some printers may see and manage ``provider``.
+
+    Only one bound to one of their printers: a provider for all printers (or
+    another printer) delivers events of printers outside their groups.
+    """
+    if printer_scope.is_unrestricted:
+        return True
+    return provider.printer_id is not None and printer_scope.allows(provider.printer_id)
+
+
+def _log_scope_clause(printer_scope: PrinterScope):
+    """The log entries a caller limited to some printers sees (None: all).
+
+    Those of their printers, and those with no printer sent by a provider they
+    can see: an entry with no printer belongs to whoever owns its provider.
+    """
+    if printer_scope.printer_ids is None:
+        return None
+    own_providers = select(NotificationProvider.id).where(
+        NotificationProvider.printer_id.in_(printer_scope.printer_ids)
+    )
+    return NotificationLog.printer_id.in_(printer_scope.printer_ids) | (
+        NotificationLog.printer_id.is_(None) & NotificationLog.provider_id.in_(own_providers)
+    )
+
+
+async def _load_provider(db: AsyncSession, provider_id: int, printer_scope: PrinterScope) -> NotificationProvider:
+    provider = await db.get(NotificationProvider, provider_id)
+    if provider is None or not _provider_in_scope(provider, printer_scope):
+        raise HTTPException(status_code=404, detail="Notification provider not found")
+    return provider
+
+
+def _ensure_target_in_scope(printer_id: int | None, printer_scope: PrinterScope) -> None:
+    """A caller limited to some printers binds a provider to one of them, never to all."""
+    if printer_scope.is_unrestricted:
+        return
+    if printer_id is None:
+        raise HTTPException(status_code=400, detail="Choose one of your printers for this provider")
+    printer_scope.ensure(printer_id)
+
+
 @router.get("/", response_model=list[NotificationProviderResponse])
 @router.get("/", response_model=list[NotificationProviderResponse])
 async def list_notification_providers(
 async def list_notification_providers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """List all notification providers."""
     """List all notification providers."""
     result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
     result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
-    providers = result.scalars().all()
+    providers = [p for p in result.scalars().all() if _provider_in_scope(p, printer_scope)]
 
 
     return [_provider_to_dict(provider) for provider in providers]
     return [_provider_to_dict(provider) for provider in providers]
 
 
@@ -149,10 +255,22 @@ async def list_notification_providers(
 @router.post("/", response_model=NotificationProviderResponse)
 @router.post("/", response_model=NotificationProviderResponse)
 async def create_notification_provider(
 async def create_notification_provider(
     provider_data: NotificationProviderCreate,
     provider_data: NotificationProviderCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Create a new notification provider."""
     """Create a new notification provider."""
+    _ensure_target_in_scope(provider_data.printer_id, printer_scope)
+    await ensure_printers_exist(db, [provider_data.printer_id])
+    try:
+        reject_placeholder(provider_data.config)
+    except SecretConfigError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from None
+    if provider_data.provider_type.value == "notify":
+        try:
+            notify_credentials(provider_data.config)
+        except NotifyError as exc:
+            raise HTTPException(status_code=422, detail=str(exc)) from None
     provider = NotificationProvider(
     provider = NotificationProvider(
         name=provider_data.name,
         name=provider_data.name,
         provider_type=provider_data.provider_type.value,
         provider_type=provider_data.provider_type.value,
@@ -193,6 +311,7 @@ async def create_notification_provider(
         on_bed_cooled=provider_data.on_bed_cooled,
         on_bed_cooled=provider_data.on_bed_cooled,
         # First layer complete
         # First layer complete
         on_first_layer_complete=provider_data.on_first_layer_complete,
         on_first_layer_complete=provider_data.on_first_layer_complete,
+        on_print_pause_upcoming=provider_data.on_print_pause_upcoming,
         on_app_message=provider_data.on_app_message,
         on_app_message=provider_data.on_app_message,
         # Inventory stock alerts
         # Inventory stock alerts
         on_stock_reorder_alert=provider_data.on_stock_reorder_alert,
         on_stock_reorder_alert=provider_data.on_stock_reorder_alert,
@@ -221,6 +340,8 @@ async def create_notification_provider(
     await db.refresh(provider)
     await db.refresh(provider)
 
 
     logger.info("Created notification provider: %s (%s)", provider.name, provider.provider_type)
     logger.info("Created notification provider: %s (%s)", provider.name, provider.provider_type)
+    if provider.provider_type == "notify":
+        _notify_providers_changed()
     await _resync_reaction_poller()
     await _resync_reaction_poller()
 
 
     return _provider_to_dict(provider)
     return _provider_to_dict(provider)
@@ -234,12 +355,31 @@ async def create_notification_provider(
 @router.post("/test-config", response_model=NotificationTestResponse)
 @router.post("/test-config", response_model=NotificationTestResponse)
 async def test_notification_config(
 async def test_notification_config(
     test_request: NotificationTestRequest,
     test_request: NotificationTestRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
+    can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.NOTIFICATIONS_UPDATE)),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
-    """Test notification configuration before saving."""
+    """Test notification configuration before saving.
+
+    With provider_id (an edited provider), the stored secrets fill in the
+    placeholders, for those who may edit providers.
+    """
+    config = test_request.config
+    try:
+        if test_request.provider_id is None:
+            reject_placeholder(config)
+        else:
+            if not can_update:
+                raise HTTPException(status_code=403, detail="Testing a saved provider requires notifications:update")
+            provider = await _load_provider(db, test_request.provider_id, printer_scope)
+            config = merge_config(
+                provider.provider_type, _stored_config(provider), test_request.provider_type.value, config
+            )
+    except SecretConfigError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from None
     success, message = await notification_service.send_test_notification(
     success, message = await notification_service.send_test_notification(
-        test_request.provider_type.value, test_request.config, db, attach_photo=test_request.attach_photo
+        test_request.provider_type.value, config, db, attach_photo=test_request.attach_photo
     )
     )
 
 
     return NotificationTestResponse(success=success, message=message)
     return NotificationTestResponse(success=success, message=message)
@@ -247,12 +387,13 @@ async def test_notification_config(
 
 
 @router.post("/test-all")
 @router.post("/test-all")
 async def test_all_notification_providers(
 async def test_all_notification_providers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Send a test notification to all enabled providers."""
     """Send a test notification to all enabled providers."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
-    providers = result.scalars().all()
+    providers = [p for p in result.scalars().all() if _provider_in_scope(p, printer_scope)]
 
 
     if not providers:
     if not providers:
         return {"tested": 0, "success": 0, "failed": 0, "results": []}
         return {"tested": 0, "success": 0, "failed": 0, "results": []}
@@ -309,11 +450,14 @@ async def get_notification_logs(
     event_type: str | None = Query(default=None),
     event_type: str | None = Query(default=None),
     success: bool | None = Query(default=None),
     success: bool | None = Query(default=None),
     days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
     days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get notification logs with optional filters."""
     """Get notification logs with optional filters."""
     query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
     query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
+    if (scope_clause := _log_scope_clause(printer_scope)) is not None:
+        query = query.where(scope_clause)
 
 
     # Apply filters
     # Apply filters
     if provider_id is not None:
     if provider_id is not None:
@@ -343,6 +487,13 @@ async def get_notification_logs(
             providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
             providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
 
 
         provider = providers_cache[log.provider_id]
         provider = providers_cache[log.provider_id]
+        # A provider the caller can't see is not named, as it isn't listed, and
+        # neither is its error, which names its kind and destination.
+        hidden = not printer_scope.is_unrestricted and (
+            provider is None or not _provider_in_scope(provider, printer_scope)
+        )
+        if hidden:
+            provider = None
         response.append(
         response.append(
             NotificationLogResponse(
             NotificationLogResponse(
                 id=log.id,
                 id=log.id,
@@ -353,7 +504,7 @@ async def get_notification_logs(
                 title=log.title,
                 title=log.title,
                 message=log.message,
                 message=log.message,
                 success=log.success,
                 success=log.success,
-                error_message=log.error_message,
+                error_message=None if hidden else log.error_message,
                 printer_id=log.printer_id,
                 printer_id=log.printer_id,
                 printer_name=log.printer_name,
                 printer_name=log.printer_name,
                 created_at=log.created_at,
                 created_at=log.created_at,
@@ -366,39 +517,46 @@ async def get_notification_logs(
 @router.get("/logs/stats", response_model=NotificationLogStats)
 @router.get("/logs/stats", response_model=NotificationLogStats)
 async def get_notification_log_stats(
 async def get_notification_log_stats(
     days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
     days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get notification log statistics."""
     """Get notification log statistics."""
     cutoff = datetime.now(timezone.utc) - timedelta(days=days)
     cutoff = datetime.now(timezone.utc) - timedelta(days=days)
+    # Counted over the entries the log list shows this caller.
+    window = [NotificationLog.created_at >= cutoff]
+    if (scope_clause := _log_scope_clause(printer_scope)) is not None:
+        window.append(scope_clause)
 
 
     # Total counts
     # Total counts
-    total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
+    total_result = await db.execute(select(func.count(NotificationLog.id)).where(*window))
     total = total_result.scalar() or 0
     total = total_result.scalar() or 0
 
 
     success_result = await db.execute(
     success_result = await db.execute(
-        select(func.count(NotificationLog.id)).where(
-            NotificationLog.created_at >= cutoff, NotificationLog.success.is_(True)
-        )
+        select(func.count(NotificationLog.id)).where(*window, NotificationLog.success.is_(True))
     )
     )
     success_count = success_result.scalar() or 0
     success_count = success_result.scalar() or 0
 
 
     # By event type
     # By event type
     event_result = await db.execute(
     event_result = await db.execute(
         select(NotificationLog.event_type, func.count(NotificationLog.id))
         select(NotificationLog.event_type, func.count(NotificationLog.id))
-        .where(NotificationLog.created_at >= cutoff)
+        .where(*window)
         .group_by(NotificationLog.event_type)
         .group_by(NotificationLog.event_type)
     )
     )
     by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
     by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
 
 
     # By provider (need to join to get name)
     # By provider (need to join to get name)
     provider_result = await db.execute(
     provider_result = await db.execute(
-        select(NotificationProvider.name, func.count(NotificationLog.id))
+        select(NotificationProvider, func.count(NotificationLog.id))
         .join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)
         .join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)
-        .where(NotificationLog.created_at >= cutoff)
-        .group_by(NotificationProvider.name)
+        .where(*window)
+        .group_by(NotificationProvider.id)
     )
     )
-    by_provider = {row[0]: row[1] for row in provider_result.fetchall()}
+    # Providers the caller can't see are counted together, not by name.
+    by_provider: dict[str, int] = {}
+    for provider, count in provider_result.all():
+        name = provider.name if _provider_in_scope(provider, printer_scope) else "Other"
+        by_provider[name] = by_provider.get(name, 0) + count
 
 
     return NotificationLogStats(
     return NotificationLogStats(
         total=total,
         total=total,
@@ -412,13 +570,18 @@ async def get_notification_log_stats(
 @router.delete("/logs")
 @router.delete("/logs")
 async def clear_notification_logs(
 async def clear_notification_logs(
     older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
     older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Clear old notification logs."""
     """Clear old notification logs."""
     cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
     cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
 
 
-    result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
+    statement = delete(NotificationLog).where(NotificationLog.created_at < cutoff)
+    # A caller limited to some printers clears only those printers' entries.
+    if (scope_clause := printer_scope.where_strict(NotificationLog.printer_id)) is not None:
+        statement = statement.where(scope_clause)
+    result = await db.execute(statement)
     await db.commit()
     await db.commit()
 
 
     deleted_count = result.rowcount
     deleted_count = result.rowcount
@@ -466,6 +629,15 @@ def _app_sender(caller: ScopedCaller) -> tuple[str, str]:
     return "anonymous", "app"
     return "anonymous", "app"
 
 
 
 
+def forget_app_senders(api_key_ids=(), user_ids=()) -> None:
+    """Drop deleted keys' and users' message counts: SQLite gives an id to the
+    next key or user created, which would start out throttled."""
+    for key_id in api_key_ids:
+        _app_message_times.pop(f"key:{key_id}", None)
+    for user_id in user_ids:
+        _app_message_times.pop(f"user:{user_id}", None)
+
+
 def _check_app_message_rate(key: str) -> None:
 def _check_app_message_rate(key: str) -> None:
     times = _app_message_times[key]
     times = _app_message_times[key]
     cutoff = time.monotonic() - APP_MESSAGE_WINDOW_SECONDS
     cutoff = time.monotonic() - APP_MESSAGE_WINDOW_SECONDS
@@ -479,24 +651,32 @@ def _check_app_message_rate(key: str) -> None:
 @router.post("/app-message", response_model=AppMessageResult)
 @router.post("/app-message", response_model=AppMessageResult)
 async def send_app_message(
 async def send_app_message(
     data: AppMessage,
     data: AppMessage,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     caller: ScopedCaller = Depends(require_notification_send()),
     caller: ScopedCaller = Depends(require_notification_send()),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Send a message through every enabled channel that has "Messages from
     """Send a message through every enabled channel that has "Messages from
     connected apps" on. For other applications, e.g. Bambuddy Orders; an API
     connected apps" on. For other applications, e.g. Bambuddy Orders; an API
-    key needs the "Send notifications" permission."""
+    key needs the "Send notifications" permission. A caller limited to some
+    printers reaches only the channels of those printers."""
     key, sender = _app_sender(caller)
     key, sender = _app_sender(caller)
     _check_app_message_rate(key)
     _check_app_message_rate(key)
     channels = await notification_service.on_app_message(
     channels = await notification_service.on_app_message(
-        db, sender=sender, title=data.title, message=data.message, url=data.url
+        db,
+        sender=sender,
+        title=data.title,
+        message=data.message,
+        url=data.url,
+        allowed=lambda provider: _provider_in_scope(provider, printer_scope),
     )
     )
     return AppMessageResult(channels=channels)
     return AppMessageResult(channels=channels)
 
 
 
 
 @router.get("/app-message/channels", response_model=list[AppMessageChannel])
 @router.get("/app-message/channels", response_model=list[AppMessageChannel])
 async def app_message_channels(
 async def app_message_channels(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: ScopedCaller = Depends(require_notification_send()),
     _: ScopedCaller = Depends(require_notification_send()),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """The enabled channels that deliver app messages: names and types only,
     """The enabled channels that deliver app messages: names and types only,
     so an app can tell its user where its messages will arrive."""
     so an app can tell its user where its messages will arrive."""
@@ -505,21 +685,22 @@ async def app_message_channels(
         .where(NotificationProvider.enabled.is_(True), NotificationProvider.on_app_message.is_(True))
         .where(NotificationProvider.enabled.is_(True), NotificationProvider.on_app_message.is_(True))
         .order_by(NotificationProvider.name)
         .order_by(NotificationProvider.name)
     )
     )
-    return [AppMessageChannel(name=p.name, provider_type=p.provider_type) for p in rows.scalars()]
+    return [
+        AppMessageChannel(name=p.name, provider_type=p.provider_type)
+        for p in rows.scalars()
+        if _provider_in_scope(p, printer_scope)
+    ]
 
 
 
 
 @router.get("/{provider_id}", response_model=NotificationProviderResponse)
 @router.get("/{provider_id}", response_model=NotificationProviderResponse)
 async def get_notification_provider(
 async def get_notification_provider(
     provider_id: int,
     provider_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get a specific notification provider."""
     """Get a specific notification provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
-
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    provider = await _load_provider(db, provider_id, printer_scope)
 
 
     return _provider_to_dict(provider)
     return _provider_to_dict(provider)
 
 
@@ -528,18 +709,39 @@ async def get_notification_provider(
 async def update_notification_provider(
 async def update_notification_provider(
     provider_id: int,
     provider_id: int,
     update_data: NotificationProviderUpdate,
     update_data: NotificationProviderUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Update a notification provider."""
     """Update a notification provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
+    provider = await _load_provider(db, provider_id, printer_scope)
 
 
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    old_config = _stored_config(provider)
+    old_type = provider.provider_type
 
 
     # Update only provided fields
     # Update only provided fields
     update_dict = update_data.model_dump(exclude_unset=True)
     update_dict = update_data.model_dump(exclude_unset=True)
+    if "printer_id" in update_dict:
+        _ensure_target_in_scope(update_dict["printer_id"], printer_scope)
+    await ensure_printers_exist(db, [update_dict.get("printer_id")])
+
+    effective_type = update_dict.get("provider_type") or provider.provider_type
+    effective_type = getattr(effective_type, "value", effective_type)
+    if effective_type != old_type and update_dict.get("config") is None:
+        raise HTTPException(status_code=400, detail="A new provider type needs its own configuration")
+    if update_dict.get("config") is not None:
+        try:
+            update_dict["config"] = merge_config(old_type, old_config, effective_type, update_dict["config"])
+        except SecretConfigError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from None
+    if effective_type == "notify":
+        try:
+            effective_config = update_dict.get("config", old_config)
+            if not isinstance(effective_config, dict):
+                raise NotifyError("Notify! configuration must be an object")
+            notify_credentials(effective_config)
+        except NotifyError as exc:
+            raise HTTPException(status_code=422, detail=str(exc)) from None
 
 
     for key, value in update_dict.items():
     for key, value in update_dict.items():
         if key == "config" and value is not None:
         if key == "config" and value is not None:
@@ -549,34 +751,66 @@ async def update_notification_provider(
         else:
         else:
             setattr(provider, key, value)
             setattr(provider, key, value)
 
 
+    if old_type == "notify":
+        effective_config = _stored_config(provider)
+        remove_widgets = (
+            provider.provider_type != "notify"
+            or not provider.enabled
+            or effective_config.get("lock_screen_widgets") is not True
+        )
+        if remove_widgets or provider.printer_id is not None:
+            condition = NotificationLockScreenWidget.provider_id == provider_id
+            if not remove_widgets:
+                condition = condition & (NotificationLockScreenWidget.printer_id != provider.printer_id)
+            # Persist opt-out before returning, even if the user immediately
+            # opts back in. The worker finishes cleanup before creating anew.
+            await db.execute(
+                update(NotificationLockScreenWidget)
+                .where(condition)
+                .values(
+                    state="deleting",
+                    failures=0,
+                    next_attempt_at=None,
+                )
+            )
+
     await db.commit()
     await db.commit()
     await db.refresh(provider)
     await db.refresh(provider)
 
 
+    response = _provider_to_dict(provider)
+    new_config = _stored_config(provider)
+    changed = old_type != provider.provider_type or any(
+        old_config.get(key) != new_config.get(key) for key in ("device_id", "token")
+    )
+    # Refresh opened a new read transaction; close it before remote cleanup.
+    await db.commit()
+    if old_type == "notify" and changed:
+        await notify_live_activities.schedule_cleanup(provider_id, old_config)
+        await notify_widgets.schedule_cleanup(provider_id, old_config)
+    if old_type == "notify" or provider.provider_type == "notify":
+        _notify_providers_changed()
     logger.info("Updated notification provider: %s", provider.name)
     logger.info("Updated notification provider: %s", provider.name)
     await _resync_reaction_poller()
     await _resync_reaction_poller()
 
 
-    return _provider_to_dict(provider)
+    return response
 
 
 
 
 @router.delete("/{provider_id}")
 @router.delete("/{provider_id}")
 async def delete_notification_provider(
 async def delete_notification_provider(
     provider_id: int,
     provider_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Delete a notification provider."""
     """Delete a notification provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
-
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    provider = await _load_provider(db, provider_id, printer_scope)
 
 
     name = provider.name
     name = provider.name
-    await db.delete(provider)
+    notify = await delete_provider(db, provider)
     await db.commit()
     await db.commit()
+    await after_providers_deleted(notify=notify)
 
 
     logger.info("Deleted notification provider: %s", name)
     logger.info("Deleted notification provider: %s", name)
-    await _resync_reaction_poller()
 
 
     return {"message": f"Notification provider '{name}' deleted"}
     return {"message": f"Notification provider '{name}' deleted"}
 
 
@@ -584,15 +818,12 @@ async def delete_notification_provider(
 @router.post("/{provider_id}/test", response_model=NotificationTestResponse)
 @router.post("/{provider_id}/test", response_model=NotificationTestResponse)
 async def test_notification_provider(
 async def test_notification_provider(
     provider_id: int,
     provider_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Send a test notification using an existing provider."""
     """Send a test notification using an existing provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
-
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    provider = await _load_provider(db, provider_id, printer_scope)
 
 
     config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
     config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
     success, message = await notification_service.send_test_notification(
     success, message = await notification_service.send_test_notification(

+ 23 - 9
backend/app/api/routes/obico.py

@@ -2,13 +2,18 @@
 
 
 import logging
 import logging
 
 
-from fastapi import APIRouter, HTTPException, Response
+from fastapi import APIRouter, Depends, HTTPException, Response
 from pydantic import BaseModel
 from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.api.routes.settings import get_setting
+from backend.app.core.auth import ApiKeyActor, RequestActor, RequestPrinterScope, RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.obico_detection import obico_detection_service, pop_frame
 from backend.app.services.obico_detection import obico_detection_service, pop_frame
+from backend.app.services.secret_fields import SECRET_PLACEHOLDER, same_value
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -17,7 +22,8 @@ router = APIRouter(prefix="/obico", tags=["obico"])
 
 
 class TestConnectionRequest(BaseModel):
 class TestConnectionRequest(BaseModel):
     url: str
     url: str
-    # Omitted entirely = test with the saved token; "" = test with no token.
+    # Omitted entirely or the placeholder = test with the saved token (against
+    # the saved URL only); "" = test with no token.
     token: str | None = None
     token: str | None = None
 
 
 
 
@@ -35,13 +41,15 @@ async def get_status(
         "sensitivity": settings["sensitivity"],
         "sensitivity": settings["sensitivity"],
         "action": settings["action"],
         "action": settings["action"],
         "poll_interval": settings["poll_interval"],
         "poll_interval": settings["poll_interval"],
-        "external_url_configured": bool(settings["external_url"]),
+        "external_url_configured": bool(settings["snapshot_base_url"]),
     }
     }
 
 
 
 
 @router.get("/printer-status")
 @router.get("/printer-status")
 async def get_printer_status(
 async def get_printer_status(
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
     """Per-printer live classification for the printer cards (#1546).
     """Per-printer live classification for the printer cards (#1546).
 
 
@@ -52,13 +60,17 @@ async def get_printer_status(
     enabled_printers = settings["enabled_printers"]
     enabled_printers = settings["enabled_printers"]
     # Error strings can embed configured URLs (ML API base, external URL), so
     # Error strings can embed configured URLs (ML API base, external URL), so
     # they stay behind settings:read like the rest of the configuration.
     # they stay behind settings:read like the rest of the configuration.
-    can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value)
+    can_see_error = actor is None or actor.has_permission(Permission.SETTINGS_READ.value)
     per_printer = obico_detection_service.get_per_printer()
     per_printer = obico_detection_service.get_per_printer()
     if not can_see_error:
     if not can_see_error:
         # The "error" *class* is not configuration — a printers:read user still
         # The "error" *class* is not configuration — a printers:read user still
         # needs to know their print is not being watched. Only the reason, which
         # needs to know their print is not being watched. Only the reason, which
         # can name a URL, is withheld.
         # can name a URL, is withheld.
         per_printer = {pid: {**entry, "error": None} for pid, entry in per_printer.items()}
         per_printer = {pid: {**entry, "error": None} for pid, entry in per_printer.items()}
+    # Only printers the caller may see (#1727)
+    per_printer = {pid: entry for pid, entry in per_printer.items() if printer_scope.allows(int(pid))}
+    if enabled_printers is not None:
+        enabled_printers = [pid for pid in enabled_printers if printer_scope.allows(int(pid))]
     return {
     return {
         "enabled": settings["enabled"],
         "enabled": settings["enabled"],
         # None = all printers are monitored
         # None = all printers are monitored
@@ -71,16 +83,18 @@ async def get_printer_status(
 @router.post("/test-connection")
 @router.post("/test-connection")
 async def test_connection(
 async def test_connection(
     req: TestConnectionRequest,
     req: TestConnectionRequest,
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""
     """Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""
     if not req.url:
     if not req.url:
         return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None}
         return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None}
     token = req.token
     token = req.token
-    if token is None:
-        # Field omitted entirely — test what the service actually uses.
-        settings = await obico_detection_service._load_settings()
-        token = settings.get("ml_token") or ""
+    if token is None or token == SECRET_PLACEHOLDER:
+        # The saved token, which only goes to the saved URL.
+        if not same_value(req.url, await get_setting(db, "obico_ml_url")):
+            raise HTTPException(status_code=400, detail="Enter the token again: the URL changed")
+        token = await get_setting(db, "obico_ml_token") or ""
     return await obico_detection_service.test_connection(req.url, token)
     return await obico_detection_service.test_connection(req.url, token)
 
 
 
 

+ 97 - 0
backend/app/api/routes/octoeverywhere.py

@@ -0,0 +1,97 @@
+"""API routes for OctoEverywhere AI failure detection."""
+
+from typing import Literal
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import ApiKeyActor, RequestActor, RequestPrinterScope, RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
+from backend.app.models.printer import Printer
+from backend.app.models.user import User
+from backend.app.services.notification_service import notification_service
+from backend.app.services.octoeverywhere_detection import octoeverywhere_detection_service
+
+router = APIRouter(prefix="/octoeverywhere", tags=["octoeverywhere"])
+
+
+class TestConnectionRequest(BaseModel):
+    # Omitted = test the saved key; empty = test with no key.
+    api_key: str | None = None
+    confidence: Literal["lowest", "low", "medium", "high", "highest"] | None = None
+
+
+@router.get("/status")
+async def get_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Scheduler status, print quality, and recent detection history."""
+    settings = await octoeverywhere_detection_service._load_settings()
+    providers = await notification_service._get_providers_for_event(db, "on_ai_failure_detection", fleet_wide=True)
+    providers = [provider for provider in providers if printer_scope.allows(provider.printer_id)]
+    printers = select(Printer.id).where(Printer.is_active.is_(True))
+    scope_filter = printer_scope.where_strict(Printer.id)
+    if scope_filter is not None:
+        printers = printers.where(scope_filter)
+    if settings["enabled_printers"] is not None:
+        printers = printers.where(Printer.id.in_(settings["enabled_printers"]))
+    printer_ids = (await db.execute(printers)).scalars().all()
+    # Use the same per-printer subscription rules as notification delivery.
+    covered_printers = {provider.printer_id for provider in providers}
+    uncovered_printers = (
+        sorted(pid for pid in printer_ids if pid not in covered_printers) if None not in covered_printers else []
+    )
+    status = octoeverywhere_detection_service.get_status()
+    return {
+        **status,
+        "per_printer": {pid: entry for pid, entry in status["per_printer"].items() if printer_scope.allows(int(pid))},
+        "history": [entry for entry in status["history"] if printer_scope.allows(entry["printer_id"])],
+        "enabled": settings["enabled"],
+        "api_key_configured": bool(settings["api_key"]),
+        "confidence": settings["confidence"],
+        "action": settings["action"],
+        "poll_interval": settings["poll_interval"],
+        "notifications": {"configured": bool(providers), "uncovered_printers": uncovered_printers},
+    }
+
+
+@router.get("/printer-status")
+async def get_printer_status(
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
+):
+    """Live printer-card status without exposing provider configuration."""
+    settings = await octoeverywhere_detection_service._load_settings()
+    enabled_printers = settings["enabled_printers"]
+    can_see_error = actor is None or actor.has_permission(Permission.SETTINGS_READ.value)
+    per_printer = octoeverywhere_detection_service.get_per_printer()
+    if not can_see_error:
+        per_printer = {pid: {**entry, "error": None, "error_code": None} for pid, entry in per_printer.items()}
+    per_printer = {pid: entry for pid, entry in per_printer.items() if printer_scope.allows(int(pid))}
+    if enabled_printers is not None:
+        enabled_printers = [pid for pid in enabled_printers if printer_scope.allows(int(pid))]
+    return {
+        "enabled": settings["enabled"],
+        "monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
+        "per_printer": per_printer,
+        "last_error": octoeverywhere_detection_service._last_error if can_see_error else None,
+        "last_error_code": octoeverywhere_detection_service._last_error_code if can_see_error else None,
+    }
+
+
+@router.post("/test-connection")
+async def test_connection(
+    req: TestConnectionRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Validate the key by creating a context, without uploading a camera image."""
+    settings = await octoeverywhere_detection_service._load_settings()
+    api_key = settings["api_key"] if req.api_key is None else req.api_key
+    return await octoeverywhere_detection_service.test_connection(api_key, req.confidence or settings["confidence"])

+ 7 - 7
backend/app/api/routes/orca_cloud.py

@@ -516,7 +516,7 @@ async def _build_authenticated_service(
 @router.post("/device/start", response_model=OrcaDeviceStartResponse)
 @router.post("/device/start", response_model=OrcaDeviceStartResponse)
 async def device_start(
 async def device_start(
     request: Request,
     request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
 ):
     """Begin device pairing. Requests a device code from Orca, stores it
     """Begin device pairing. Requests a device code from Orca, stores it
@@ -555,7 +555,7 @@ async def device_start(
 
 
 @router.post("/device/poll", response_model=OrcaDevicePollResponse)
 @router.post("/device/poll", response_model=OrcaDevicePollResponse)
 async def device_poll(
 async def device_poll(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
 ):
     """Poll the token endpoint once for the in-flight pairing. Returns an
     """Poll the token endpoint once for the in-flight pairing. Returns an
@@ -608,7 +608,7 @@ async def device_poll(
 
 
 @router.get("/status", response_model=OrcaAuthStatusResponse)
 @router.get("/status", response_model=OrcaAuthStatusResponse)
 async def get_status(
 async def get_status(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
 ):
     """Return whether the caller has an Orca Cloud session stored, plus
     """Return whether the caller has an Orca Cloud session stored, plus
@@ -623,7 +623,7 @@ async def get_status(
 
 
 @router.post("/logout")
 @router.post("/logout")
 async def logout(
 async def logout(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
 ):
     """Clear stored Orca Cloud credentials. Does not call Orca's disconnect
     """Clear stored Orca Cloud credentials. Does not call Orca's disconnect
@@ -635,7 +635,7 @@ async def logout(
 
 
 @router.get("/profiles", response_model=OrcaProfileListResponse)
 @router.get("/profiles", response_model=OrcaProfileListResponse)
 async def list_profiles(
 async def list_profiles(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
 ):
     """Return profile metadata grouped by type (``filament`` / ``printer``
     """Return profile metadata grouped by type (``filament`` / ``printer``
@@ -675,11 +675,11 @@ async def list_profiles(
 @router.get("/profiles/{profile_id}", response_model=OrcaProfileDetail)
 @router.get("/profiles/{profile_id}", response_model=OrcaProfileDetail)
 async def get_profile(
 async def get_profile(
     profile_id: str,
     profile_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
 ):
     """Fetch a single profile's full content, shaped like
     """Fetch a single profile's full content, shaped like
-    ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
+    the frontend's ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
     unchanged. The inner ``setting`` field is the raw slicer-format JSON
     unchanged. The inner ``setting`` field is the raw slicer-format JSON
     Orca stores — same shape Bambu Cloud uses since OrcaSlicer is a
     Orca stores — same shape Bambu Cloud uses since OrcaSlicer is a
     BambuStudio fork."""
     BambuStudio fork."""

+ 86 - 0
backend/app/api/routes/overlay_branding.py

@@ -0,0 +1,86 @@
+"""Server-stored streaming overlay logo. Mutation requires settings permission."""
+
+import io
+import os
+import tempfile
+import warnings
+
+from fastapi import APIRouter, File, HTTPException, UploadFile
+from fastapi.responses import Response
+from PIL import Image, UnidentifiedImageError
+from starlette.concurrency import run_in_threadpool
+
+from backend.app.core.auth import RequireOverlayTokenAnyPrinterIfAuthEnabled, RequirePermissionIfAuthEnabled
+from backend.app.core.config import settings
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+
+router = APIRouter(tags=["overlay-branding"])
+MAX_BYTES = 2 * 1024 * 1024
+MAX_PIXELS = 4_000_000
+
+
+def _read_logo() -> Response:
+    try:
+        content = (settings.base_dir / "overlay-branding" / "logo.png").read_bytes()
+    except FileNotFoundError:
+        raise HTTPException(404, "No overlay logo saved") from None
+    return Response(content, media_type="image/png", headers={"Cache-Control": "no-store"})
+
+
+def _save_logo(content: bytes) -> None:
+    try:
+        with warnings.catch_warnings():
+            warnings.simplefilter("error", Image.DecompressionBombWarning)
+            with Image.open(io.BytesIO(content)) as image:
+                if image.format not in ("PNG", "WEBP") or image.width * image.height > MAX_PIXELS:
+                    raise HTTPException(400, "Use a PNG or WebP image with at most 4 million pixels")
+                if getattr(image, "is_animated", False):
+                    raise HTTPException(400, "Animated logos are not supported")
+                image.load()
+                image = image.convert("RGBA")
+                image.thumbnail((512, 512))
+                output = io.BytesIO()
+                image.save(output, format="PNG")
+    except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError, Image.DecompressionBombWarning):
+        raise HTTPException(400, "Invalid PNG or WebP image") from None
+    directory = settings.base_dir / "overlay-branding"
+    directory.mkdir(parents=True, exist_ok=True)
+    # Replace atomically so concurrent readers never see a partially written PNG.
+    with tempfile.NamedTemporaryFile(dir=directory, delete=False) as temporary:
+        temporary_path = temporary.name
+        try:
+            temporary.write(output.getvalue())
+            temporary.close()
+            os.replace(temporary_path, directory / "logo.png")
+        finally:
+            if os.path.exists(temporary_path):
+                os.unlink(temporary_path)
+
+
+@router.get("/settings/overlay-logo")
+def get_logo(_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ)):
+    return _read_logo()
+
+
+@router.get("/overlay-branding/logo")
+def get_stream_logo(_: None = RequireOverlayTokenAnyPrinterIfAuthEnabled):
+    return _read_logo()
+
+
+@router.post("/settings/overlay-logo")
+async def upload_logo(
+    file: UploadFile = File(...),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    content = await file.read(MAX_BYTES + 1)
+    if len(content) > MAX_BYTES:
+        raise HTTPException(413, "Logo must be 2 MiB or smaller")
+    await run_in_threadpool(_save_logo, content)
+    return {"status": "ok"}
+
+
+@router.delete("/settings/overlay-logo")
+def delete_logo(_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE)):
+    (settings.base_dir / "overlay-branding" / "logo.png").unlink(missing_ok=True)
+    return {"status": "ok"}

+ 20 - 14
backend/app/api/routes/pending_uploads.py

@@ -8,10 +8,11 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
+from backend.app.core.auth import ApiKeyActor, RequirePermissionIfAuthEnabled, require_ownership_permission
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.project import Project
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.archive import ArchiveService, resolve_display_stem
 from backend.app.services.archive import ArchiveService, resolve_display_stem
 
 
@@ -90,8 +91,8 @@ async def _augment_with_display_name(
 
 
 @router.get("/", response_model=list[PendingUploadResponse])
 @router.get("/", response_model=list[PendingUploadResponse])
 async def list_pending_uploads(
 async def list_pending_uploads(
-    db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
             Permission.QUEUE_READ_OWN,
@@ -108,8 +109,8 @@ async def list_pending_uploads(
 
 
 @router.get("/count")
 @router.get("/count")
 async def get_pending_count(
 async def get_pending_count(
-    db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
             Permission.QUEUE_READ_OWN,
@@ -129,7 +130,7 @@ async def get_pending_count(
 
 
 @router.post("/archive-all")
 @router.post("/archive-all")
 async def archive_all_pending(
 async def archive_all_pending(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
 ):
     """Archive all pending uploads."""
     """Archive all pending uploads."""
@@ -189,7 +190,7 @@ async def archive_all_pending(
 
 
 @router.delete("/discard-all")
 @router.delete("/discard-all")
 async def discard_all_pending(
 async def discard_all_pending(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
 ):
 ):
     """Discard all pending uploads."""
     """Discard all pending uploads."""
@@ -217,8 +218,8 @@ async def discard_all_pending(
 @router.get("/{upload_id}", response_model=PendingUploadResponse)
 @router.get("/{upload_id}", response_model=PendingUploadResponse)
 async def get_pending_upload(
 async def get_pending_upload(
     upload_id: int,
     upload_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
             Permission.QUEUE_READ_OWN,
@@ -239,7 +240,7 @@ async def get_pending_upload(
 async def archive_pending_upload(
 async def archive_pending_upload(
     upload_id: int,
     upload_id: int,
     request: ArchiveRequest = None,
     request: ArchiveRequest = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
 ):
     """Archive a pending upload."""
     """Archive a pending upload."""
@@ -256,6 +257,11 @@ async def archive_pending_upload(
     if not file_path.exists():
     if not file_path.exists():
         raise HTTPException(status_code=404, detail="Upload file not found on disk")
         raise HTTPException(status_code=404, detail="Upload file not found on disk")
 
 
+    # The project must exist before anything is archived; 0 means none.
+    project_id = (request.project_id or None) if request else None
+    if project_id is not None and await db.get(Project, project_id) is None:
+        raise HTTPException(status_code=400, detail="Project not found")
+
     # Archive the file
     # Archive the file
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
 
 
@@ -281,8 +287,8 @@ async def archive_pending_upload(
             archive.tags = request.tags
             archive.tags = request.tags
         if request.notes:
         if request.notes:
             archive.notes = request.notes
             archive.notes = request.notes
-        if request.project_id:
-            archive.project_id = request.project_id
+        if project_id is not None:
+            archive.project_id = project_id
 
 
     # Update pending record
     # Update pending record
     pending.status = "archived"
     pending.status = "archived"
@@ -291,7 +297,7 @@ async def archive_pending_upload(
     if request:
     if request:
         pending.tags = request.tags
         pending.tags = request.tags
         pending.notes = request.notes
         pending.notes = request.notes
-        pending.project_id = request.project_id
+        pending.project_id = project_id
 
 
     await db.commit()
     await db.commit()
 
 
@@ -311,7 +317,7 @@ async def archive_pending_upload(
 @router.delete("/{upload_id}")
 @router.delete("/{upload_id}")
 async def discard_pending_upload(
 async def discard_pending_upload(
     upload_id: int,
     upload_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
 ):
 ):
     """Discard a pending upload without archiving."""
     """Discard a pending upload without archiving."""

+ 276 - 84
backend/app/api/routes/pipeline_runs.py

@@ -34,10 +34,20 @@ from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.api.routes.slicer_presets import listed_ref, load_stock_aliases
+from backend.app.core.auth import (
+    ApiKeyActor,
+    QueueReviewRequired,
+    RequestActor,
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    is_auth_enabled,
+)
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope, ensure_model_target_allowed
+from backend.app.core.row_visibility import may_reach_row
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
@@ -56,12 +66,14 @@ from backend.app.schemas.pipeline_run import (
     PipelineRunListResponse,
     PipelineRunListResponse,
     PipelineRunResponse,
     PipelineRunResponse,
 )
 )
-from backend.app.schemas.slicer import PresetRef, SliceRequest
+from backend.app.schemas.slicer import PresetRef, SliceRequest, checked_bed_type
 from backend.app.services.pipeline_eligibility import (
 from backend.app.services.pipeline_eligibility import (
     EligibilityReport,
     EligibilityReport,
     check_pipeline_eligibility,
     check_pipeline_eligibility,
 )
 )
 from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
 from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
+from backend.app.services.print_defaults import default_print_options
+from backend.app.services.queue_announcements import announce_queue_jobs_added
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -154,25 +166,59 @@ def _make_status_lookup():
     return _lookup
     return _lookup
 
 
 
 
-def _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
+def _slice_request_from_pipeline(
+    pipeline: SlicerPipeline, stock_aliases: dict[str, PresetRef] | None = None
+) -> SliceRequest:
+    """``stock_aliases`` is the preset listing's map (``load_stock_aliases``):
+    a ref the listing hides behind its Bambu Cloud twin slices with that twin,
+    as it does from the Slice dialog (#3386)."""
+    aliases = stock_aliases or {}
     try:
     try:
         raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
         raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
     except (json.JSONDecodeError, TypeError):
     except (json.JSONDecodeError, TypeError):
         raw_filaments = []
         raw_filaments = []
     filament_presets = [
     filament_presets = [
-        PresetRef(source=r["source"], id=r["id"])
+        _stored_ref(aliases, r["source"], r["id"])
         for r in raw_filaments
         for r in raw_filaments
         if isinstance(r, dict) and "source" in r and "id" in r
         if isinstance(r, dict) and "source" in r and "id" in r
     ]
     ]
+    # Rows saved before #3359 can hold a name no slicer knows; say so instead
+    # of failing the run with a validation dump.
+    try:
+        bed_type = checked_bed_type(pipeline.bed_type)
+    except ValueError:
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                f"The pipeline's build plate {pipeline.bed_type!r} is not one the slicers know. "
+                "Save the pipeline again from the Slice dialog with another plate."
+            ),
+        ) from None
     return SliceRequest(
     return SliceRequest(
-        printer_preset=PresetRef(source=pipeline.printer_preset_source, id=pipeline.printer_preset_id),
-        process_preset=PresetRef(source=pipeline.process_preset_source, id=pipeline.process_preset_id),
+        printer_preset=_stored_ref(aliases, pipeline.printer_preset_source, pipeline.printer_preset_id),
+        process_preset=_stored_ref(aliases, pipeline.process_preset_source, pipeline.process_preset_id),
         filament_presets=filament_presets,
         filament_presets=filament_presets,
-        bed_type=pipeline.bed_type,
+        bed_type=bed_type,
         export_3mf=True,
         export_3mf=True,
     )
     )
 
 
 
 
+def _stored_ref(aliases: dict[str, PresetRef], source: str, preset_id: str) -> PresetRef:
+    """A ref as the pipeline row stores it, pointed at the twin the listing shows."""
+    return listed_ref(aliases, PresetRef.model_validate({"source": source, "id": preset_id}))
+
+
+async def _pipeline_slice_request(
+    db: AsyncSession, pipeline: SlicerPipeline, cloud_user_id: int | None
+) -> SliceRequest:
+    """The run's slice request, with the aliases of the user who resolves its
+    cloud presets (``current_user_id`` on ``slice_and_persist``), not the
+    run's owner: a key without the cloud scope resolves none, so a hidden
+    Standard ref mapped to its cloud twin would be refused (#3386)."""
+    cloud_user = await db.get(User, cloud_user_id) if cloud_user_id is not None else None
+    return _slice_request_from_pipeline(pipeline, await load_stock_aliases(db, cloud_user))
+
+
 def _compute_job_status(
 def _compute_job_status(
     persisted: str,
     persisted: str,
     queue_entry: PrintQueueItem | None,
     queue_entry: PrintQueueItem | None,
@@ -180,7 +226,10 @@ def _compute_job_status(
     if persisted in ("failed", "cancelled", "completed"):
     if persisted in ("failed", "cancelled", "completed"):
         return persisted
         return persisted
     if queue_entry is None:
     if queue_entry is None:
-        return persisted
+        # Dispatched, but its queue entry is gone without a final status stored
+        # (deleted before jobs stored it, or removed by the database itself):
+        # printed or not cannot be told, so neither failed nor cancelled.
+        return "unknown" if persisted in ("queued", "printing") else persisted
     qs = queue_entry.status
     qs = queue_entry.status
     if qs == "completed":
     if qs == "completed":
         return "completed"
         return "completed"
@@ -213,26 +262,32 @@ def _roll_up_run_status(
         return persisted
         return persisted
 
 
     completed = sum(1 for s in job_statuses if s == "completed")
     completed = sum(1 for s in job_statuses if s == "completed")
+    # A copy whose outcome is unknown ended without failing anything to retry.
+    unknown = sum(1 for s in job_statuses if s == "unknown")
     failed = sum(1 for s in job_statuses if s == "failed")
     failed = sum(1 for s in job_statuses if s == "failed")
     cancelled = sum(1 for s in job_statuses if s == "cancelled")
     cancelled = sum(1 for s in job_statuses if s == "cancelled")
     in_flight = sum(1 for s in job_statuses if s in ("printing", "queued", "awaiting_printer", "pending"))
     in_flight = sum(1 for s in job_statuses if s in ("printing", "queued", "awaiting_printer", "pending"))
     total = len(job_statuses)
     total = len(job_statuses)
 
 
-    if completed == total:
+    if completed + unknown == total:
         return "completed"
         return "completed"
     if in_flight > 0:
     if in_flight > 0:
         return "in_progress" if persisted not in ("queued", "slicing", "dispatching") else persisted
         return "in_progress" if persisted not in ("queued", "slicing", "dispatching") else persisted
     # All copies are in terminal states.
     # All copies are in terminal states.
     if failed == 0 and cancelled == total:
     if failed == 0 and cancelled == total:
         return "cancelled"
         return "cancelled"
-    if completed > 0 and (failed > 0 or cancelled > 0):
+    if completed + unknown > 0 and (failed > 0 or cancelled > 0):
         return "partial_failure"
         return "partial_failure"
     if failed > 0:
     if failed > 0:
         return "failed"
         return "failed"
     return persisted
     return persisted
 
 
 
 
-async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunResponse:
+async def _materialise_run(
+    db: AsyncSession, run: PipelineRun, actor: User | ApiKeyActor | None = None
+) -> PipelineRunResponse:
+    """The run as ``actor`` sees it: its source and queue jobs are named only
+    when ``actor`` may see them where they live. No actor names everything."""
     pipeline_name: str | None = None
     pipeline_name: str | None = None
     target_kind = None
     target_kind = None
     target_printer_id = None
     target_printer_id = None
@@ -250,16 +305,28 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
             fanout_strategy = pipeline.fanout_strategy  # type: ignore[assignment]
             fanout_strategy = pipeline.fanout_strategy  # type: ignore[assignment]
 
 
     source_filename: str | None = None
     source_filename: str | None = None
+    source_library_file_id = run.source_library_file_id
+    source_archive_id = run.source_archive_id
     if run.source_library_file_id:
     if run.source_library_file_id:
         src = (
         src = (
             await db.execute(select(LibraryFile).where(LibraryFile.id == run.source_library_file_id))
             await db.execute(select(LibraryFile).where(LibraryFile.id == run.source_library_file_id))
         ).scalar_one_or_none()
         ).scalar_one_or_none()
-        source_filename = src.filename if src else None
+        if src is not None and may_reach_row(
+            src.created_by_id, actor, Permission.LIBRARY_READ_ALL, Permission.LIBRARY_READ_OWN
+        ):
+            source_filename = src.filename
+        elif src is not None:
+            source_library_file_id = None
     elif run.source_archive_id:
     elif run.source_archive_id:
         arc = (
         arc = (
             await db.execute(select(PrintArchive).where(PrintArchive.id == run.source_archive_id))
             await db.execute(select(PrintArchive).where(PrintArchive.id == run.source_archive_id))
         ).scalar_one_or_none()
         ).scalar_one_or_none()
-        source_filename = (arc.print_name or arc.filename) if arc else None
+        if arc is not None and may_reach_row(
+            arc.created_by_id, actor, Permission.ARCHIVES_READ_ALL, Permission.ARCHIVES_READ_OWN
+        ):
+            source_filename = arc.print_name or arc.filename
+        elif arc is not None:
+            source_archive_id = None
 
 
     job_rows = (
     job_rows = (
         (
         (
@@ -286,16 +353,6 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
             printer_name = p.name if p else None
             printer_name = p.name if p else None
 
 
         live_job_status = _compute_job_status(job.status, queue_entry)
         live_job_status = _compute_job_status(job.status, queue_entry)
-        # If the job WAS dispatched (had a queue_entry_id) but the entry has
-        # since been deleted from the queue page, the user's intent was
-        # cancellation. Otherwise the run would stay forever showing as
-        # ``queued`` because the persisted job.status hasn't been updated.
-        if (
-            job.queue_entry_id is not None
-            and queue_entry is None
-            and live_job_status not in ("completed", "failed", "cancelled")
-        ):
-            live_job_status = "cancelled"
         job_live_statuses.append(live_job_status)
         job_live_statuses.append(live_job_status)
         job_responses.append(
         job_responses.append(
             PipelineJobResponse(
             PipelineJobResponse(
@@ -304,7 +361,10 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
                 copy_index=job.copy_index,
                 copy_index=job.copy_index,
                 assigned_printer_id=job.assigned_printer_id,
                 assigned_printer_id=job.assigned_printer_id,
                 assigned_printer_name=printer_name,
                 assigned_printer_name=printer_name,
-                queue_entry_id=job.queue_entry_id,
+                queue_entry_id=job.queue_entry_id
+                if queue_entry is None
+                or may_reach_row(queue_entry.created_by_id, actor, Permission.QUEUE_READ_ALL, Permission.QUEUE_READ_OWN)
+                else None,
                 status=live_job_status,  # type: ignore[arg-type]
                 status=live_job_status,  # type: ignore[arg-type]
                 error_message=job.error_message,
                 error_message=job.error_message,
                 dispatched_at=job.dispatched_at,
                 dispatched_at=job.dispatched_at,
@@ -318,8 +378,8 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
         id=run.id,
         id=run.id,
         pipeline_id=run.pipeline_id,
         pipeline_id=run.pipeline_id,
         pipeline_name=pipeline_name,
         pipeline_name=pipeline_name,
-        source_library_file_id=run.source_library_file_id,
-        source_archive_id=run.source_archive_id,
+        source_library_file_id=source_library_file_id,
+        source_archive_id=source_archive_id,
         source_filename=source_filename,
         source_filename=source_filename,
         parent_run_id=run.parent_run_id,
         parent_run_id=run.parent_run_id,
         copies=run.copies,
         copies=run.copies,
@@ -349,8 +409,12 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
 async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
 async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
     """Broadcast a ``pipeline_run_updated`` event with the full materialised
     """Broadcast a ``pipeline_run_updated`` event with the full materialised
     run. Per-user routing via ``broadcast_to_user`` falls back to a global
     run. Per-user routing via ``broadcast_to_user`` falls back to a global
-    broadcast when ``created_by`` is None (auth-disabled installs)."""
+    broadcast when ``created_by`` is None on auth-disabled installs."""
     try:
     try:
+        # Without a user, broadcast_to_user would send it to everyone. With auth
+        # on, a run whose user was deleted is nobody's to watch (the runs page polls).
+        if run.created_by is None and await is_auth_enabled(db):
+            return
         payload = await _materialise_run(db, run)
         payload = await _materialise_run(db, run)
         await ws_manager.broadcast_to_user(
         await ws_manager.broadcast_to_user(
             run.created_by,
             run.created_by,
@@ -376,14 +440,15 @@ async def _resolve_source(
     *,
     *,
     library_file_id: int | None,
     library_file_id: int | None,
     archive_id: int | None,
     archive_id: int | None,
-    user: User | None,
+    user: User | ApiKeyActor | None,
+    printer_scope: PrinterScope,
 ) -> tuple[SourceKind, int, str, Path]:
 ) -> tuple[SourceKind, int, str, Path]:
     # Per-row ownership gate (IDOR fix): a caller may only run a pipeline on a
     # Per-row ownership gate (IDOR fix): a caller may only run a pipeline on a
     # source they can see. Without this a READ_OWN caller could reference
     # source they can see. Without this a READ_OWN caller could reference
     # another user's library file / archive by raw id and have it sliced (and,
     # another user's library file / archive by raw id and have it sliced (and,
     # via /run, printed) even though a direct GET on that id returned 404.
     # via /run, printed) even though a direct GET on that id returned 404.
-    # Auth-disabled and API-key callers (user is None) keep can_read_all=True —
-    # no per-row identity, matching the library/archive read helpers.
+    # Only auth off (user is None) keeps can_read_all=True. An API key arrives
+    # as its owner's RequestActor, matching the library/archive read helpers.
     from backend.app.api.routes.archives import _ensure_archive_visible
     from backend.app.api.routes.archives import _ensure_archive_visible
     from backend.app.api.routes.library import _ensure_library_file_visible
     from backend.app.api.routes.library import _ensure_library_file_visible
 
 
@@ -401,7 +466,12 @@ async def _resolve_source(
     assert archive_id is not None
     assert archive_id is not None
     arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
     arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
     can_read_all = user is None or user.has_permission(Permission.ARCHIVES_READ_ALL.value)
     can_read_all = user is None or user.has_permission(Permission.ARCHIVES_READ_ALL.value)
-    arc = _ensure_archive_visible(arc, user, can_read_all)
+    arc = await _ensure_archive_visible(arc, user, can_read_all, printer_scope)
+    # Running a pipeline on an archive prints it again: the same rules as
+    # queueing it, including the right to reprint it.
+    from backend.app.api.routes.print_queue import _assert_can_queue_archive
+
+    await _assert_can_queue_archive(arc, user, printer_scope)
     rel = arc.source_3mf_path or arc.file_path
     rel = arc.source_3mf_path or arc.file_path
     if not rel:
     if not rel:
         raise HTTPException(400, "Archive has no source file to slice")
         raise HTTPException(400, "Archive has no source file to slice")
@@ -418,11 +488,13 @@ async def _pick_assignments(
     db: AsyncSession,
     db: AsyncSession,
     pipeline: SlicerPipeline,
     pipeline: SlicerPipeline,
     copies: int,
     copies: int,
+    printer_scope: PrinterScope = ALL_PRINTERS,
 ) -> list[tuple[int | None, str | None]]:
 ) -> list[tuple[int | None, str | None]]:
     """Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
     """Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
     ``copies`` per the pipeline's fanout strategy. ``target_model_class``
     ``copies`` per the pipeline's fanout strategy. ``target_model_class``
     items leave ``printer_id`` None so the scheduler picks any free matching
     items leave ``printer_id`` None so the scheduler picks any free matching
-    printer; specific assignments fill ``printer_id``."""
+    printer; specific assignments fill ``printer_id``. Class targeting only
+    pins copies to printers in the runner's ``printer_scope`` (#1727)."""
     target_kind = pipeline.target_kind or "specific_printer"
     target_kind = pipeline.target_kind or "specific_printer"
     if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
     if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
         assert pipeline.target_printer_id is not None
         assert pipeline.target_printer_id is not None
@@ -433,7 +505,7 @@ async def _pick_assignments(
         (
         (
             await db.execute(
             await db.execute(
                 select(Printer)
                 select(Printer)
-                .where(Printer.model == pipeline.target_model_class)
+                .where(func.lower(Printer.model) == (pipeline.target_model_class or "").lower())
                 .where(Printer.is_active.is_(True))
                 .where(Printer.is_active.is_(True))
                 .order_by(Printer.id)
                 .order_by(Printer.id)
             )
             )
@@ -441,6 +513,7 @@ async def _pick_assignments(
         .scalars()
         .scalars()
         .all()
         .all()
     )
     )
+    matching = [p for p in matching if printer_scope.allows(p.id)]
     if not matching:
     if not matching:
         # Shouldn't reach here when eligibility passes, but failing gracefully
         # Shouldn't reach here when eligibility passes, but failing gracefully
         # is better than a TypeError on next-slot pick.
         # is better than a TypeError on next-slot pick.
@@ -457,8 +530,9 @@ async def _pick_assignments(
         # ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
         # ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
         return [(matching[i % len(matching)].id, None) for i in range(copies)]
         return [(matching[i % len(matching)].id, None) for i in range(copies)]
     # max_parallel — leave printer_id=None, set target_model so the scheduler
     # max_parallel — leave printer_id=None, set target_model so the scheduler
-    # picks any free X1C / P1S / … for each item independently.
-    return [(None, pipeline.target_model_class)] * copies
+    # picks any free X1C / P1S / … for each item independently. In the
+    # printers' own spelling: the class is matched without regard to case.
+    return [(None, matching[0].model)] * copies
 
 
 
 
 def _make_orchestration_callable(
 def _make_orchestration_callable(
@@ -471,6 +545,8 @@ def _make_orchestration_callable(
     src_path: Path,
     src_path: Path,
     creator_user_id: int | None,
     creator_user_id: int | None,
     copies: int,
     copies: int,
+    printer_scope: PrinterScope = ALL_PRINTERS,
+    review_required: bool = False,
 ):
 ):
     """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
     """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
     background slice job. Wraps slice + multi-copy enqueue + state update."""
     background slice job. Wraps slice + multi-copy enqueue + state update."""
@@ -502,7 +578,6 @@ def _make_orchestration_callable(
             await session.commit()
             await session.commit()
             await _publish_run_event(session, run)
             await _publish_run_event(session, run)
 
 
-            slice_request = _slice_request_from_pipeline(pipeline)
             model_bytes = src_path.read_bytes()
             model_bytes = src_path.read_bytes()
 
 
             folder_id: int | None = None
             folder_id: int | None = None
@@ -512,6 +587,9 @@ def _make_orchestration_callable(
                     folder_id = lib.folder_id
                     folder_id = lib.folder_id
 
 
             try:
             try:
+                # Inside the try: a row with a bed type no slicer knows fails
+                # the run instead of leaving it in "slicing" (#3359).
+                slice_request = await _pipeline_slice_request(session, pipeline, creator_user_id)
                 slice_response = await slice_and_persist(
                 slice_response = await slice_and_persist(
                     session,
                     session,
                     model_bytes=model_bytes,
                     model_bytes=model_bytes,
@@ -525,6 +603,9 @@ def _make_orchestration_callable(
                     request=slice_request,
                     request=slice_request,
                     current_user_id=creator_user_id,
                     current_user_id=creator_user_id,
                     job_id=slice_job_id,
                     job_id=slice_job_id,
+                    # The run's owner, also when a key without the cloud scope
+                    # started it and no one resolves cloud presets.
+                    owner_id=run.created_by,
                 )
                 )
             except HTTPException as exc:
             except HTTPException as exc:
                 run.status = "failed"
                 run.status = "failed"
@@ -556,7 +637,7 @@ def _make_orchestration_callable(
                 return slice_response.model_dump()
                 return slice_response.model_dump()
 
 
             # PR C: enqueue N copies per the picked assignment strategy.
             # PR C: enqueue N copies per the picked assignment strategy.
-            assignments = await _pick_assignments(session, pipeline, copies)
+            assignments = await _pick_assignments(session, pipeline, copies, printer_scope)
 
 
             jobs = (
             jobs = (
                 (
                 (
@@ -574,15 +655,32 @@ def _make_orchestration_callable(
 
 
             # No per-job ask-for-outcome toggle on a pipeline run either (#1898).
             # No per-job ask-for-outcome toggle on a pipeline run either (#1898).
             confirm_outcome = await confirm_outcome_for_new_queue_item(session)
             confirm_outcome = await confirm_outcome_for_new_queue_item(session)
+            # Nor print options: the install's defaults.
+            print_options = await default_print_options(session)
 
 
             for job, (printer_id, target_model) in zip(jobs, assignments, strict=False):
             for job, (printer_id, target_model) in zip(jobs, assignments, strict=False):
                 queue_item = PrintQueueItem(
                 queue_item = PrintQueueItem(
                     printer_id=printer_id,
                     printer_id=printer_id,
                     target_model=target_model,
                     target_model=target_model,
                     library_file_id=slice_response.library_file_id,
                     library_file_id=slice_response.library_file_id,
-                    created_by_id=creator_user_id,
+                    # From the run as refreshed after the slice, not the id the
+                    # request started with: a user deleted meanwhile is gone
+                    # from it, and SQLite may have given the id to someone else.
+                    created_by_id=run.created_by,
                     status="pending",
                     status="pending",
                     confirm_outcome=confirm_outcome,
                     confirm_outcome=confirm_outcome,
+                    # The copies wait for review like any other job of theirs (#1620)
+                    # A restricted user deleted while it sliced: as delete_user
+                    # stages their ownerless "any model" jobs, or these would run
+                    # on every printer of the model, not only on theirs (#1727).
+                    manual_start=review_required
+                    or (
+                        run.created_by is None
+                        and creator_user_id is not None
+                        and printer_id is None
+                        and not printer_scope.is_unrestricted
+                    ),
+                    **print_options,
                 )
                 )
                 session.add(queue_item)
                 session.add(queue_item)
                 await session.flush()
                 await session.flush()
@@ -614,6 +712,7 @@ def _make_orchestration_callable(
                         ).scalar_one_or_none()
                         ).scalar_one_or_none()
                         if qe is not None and qe.status in ("pending", "queued"):
                         if qe is not None and qe.status in ("pending", "queued"):
                             qe.status = "cancelled"
                             qe.status = "cancelled"
+                            qe.completed_at = datetime.now(timezone.utc)
                     if job.status not in ("completed", "failed", "cancelled"):
                     if job.status not in ("completed", "failed", "cancelled"):
                         job.status = "cancelled"
                         job.status = "cancelled"
                         job.completed_at = datetime.now(timezone.utc)
                         job.completed_at = datetime.now(timezone.utc)
@@ -625,6 +724,7 @@ def _make_orchestration_callable(
                 job.status = "queued"
                 job.status = "queued"
             run.status = "dispatching"
             run.status = "dispatching"
             await session.commit()
             await session.commit()
+            await announce_queue_jobs_added(session, [job.queue_entry_id for job in jobs if job.queue_entry_id])
             await _publish_run_event(session, run)
             await _publish_run_event(session, run)
 
 
             return slice_response.model_dump()
             return slice_response.model_dump()
@@ -642,14 +742,18 @@ async def check_eligibility(
     pipeline_id: int,
     pipeline_id: int,
     body: CheckEligibilityRequest,
     body: CheckEligibilityRequest,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
-    db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
     pipeline = await _load_pipeline(db, pipeline_id)
     pipeline = await _load_pipeline(db, pipeline_id)
+    printer_scope.ensure(pipeline.target_printer_id)
     await _resolve_source(
     await _resolve_source(
         db,
         db,
         library_file_id=body.source_library_file_id,
         library_file_id=body.source_library_file_id,
         archive_id=body.source_archive_id,
         archive_id=body.source_archive_id,
-        user=current_user,
+        user=actor,
+        printer_scope=printer_scope,
     )
     )
     if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
     if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
         report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
         report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
@@ -670,20 +774,26 @@ async def run_pipeline(
     body: PipelineRunCreateRequest,
     body: PipelineRunCreateRequest,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
-    db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_dispatch import slice_dispatch
     from backend.app.services.slice_dispatch import slice_dispatch
 
 
     pipeline = await _load_pipeline(db, pipeline_id)
     pipeline = await _load_pipeline(db, pipeline_id)
-    # ``user=current_user`` deliberately, not the cloud owner below: an API-key
-    # caller has no per-row identity and must keep can_read_all, the same as
-    # every other read helper.
+    # The pipeline is shared config; running it is limited to what the caller
+    # may print on (#1727)
+    printer_scope.ensure(pipeline.target_printer_id)
+    # An API key is checked as its owner (RequestActor), like its owner's own
+    # session; ``creator`` below is a separate question.
     src_kind, src_id, src_filename, src_path = await _resolve_source(
     src_kind, src_id, src_filename, src_path = await _resolve_source(
         db,
         db,
         library_file_id=body.source_library_file_id,
         library_file_id=body.source_library_file_id,
         archive_id=body.source_archive_id,
         archive_id=body.source_archive_id,
-        user=current_user,
+        user=actor,
+        printer_scope=printer_scope,
     )
     )
 
 
     # The permission gate answers an API-keyed request with current_user=None,
     # The permission gate answers an API-keyed request with current_user=None,
@@ -693,6 +803,13 @@ async def run_pipeline(
     # Only keys with the cloud scope resolve to an owner here; everything else
     # Only keys with the cloud scope resolve to an owner here; everything else
     # stays None and slices against local presets exactly as before.
     # stays None and slices against local presets exactly as before.
     creator = current_user or api_key_cloud_owner
     creator = current_user or api_key_cloud_owner
+    # Copies left to the scheduler run within their creator's printers. A
+    # limited API key can't be held to its own printers that way -- its cloud
+    # owner may see more -- and even a pinning strategy falls back to "any
+    # printer of the class" when none of the class is in scope, so a limited
+    # key may only run pipelines aimed at one printer (#1727).
+    if pipeline.target_printer_id is None:
+        ensure_model_target_allowed(current_user, printer_scope)
 
 
     # Cap copies against the configured ceiling.
     # Cap copies against the configured ceiling.
     raw_cap = await get_setting(db, "pipeline_max_copies")
     raw_cap = await get_setting(db, "pipeline_max_copies")
@@ -730,7 +847,10 @@ async def run_pipeline(
         copies=body.copies,
         copies=body.copies,
         status="queued",
         status="queued",
         eligibility_overridden=(not report.ok and body.force),
         eligibility_overridden=(not report.ok and body.force),
-        created_by=creator.id if creator else None,
+        # The run, and the queue jobs it adds, are the key's owner's when a key
+        # starts it, as a job the webhook queues is (``creator`` is only who
+        # resolves cloud presets).
+        created_by=creator.id if creator else (actor.id if isinstance(actor, ApiKeyActor) else None),
     )
     )
     db.add(run)
     db.add(run)
     await db.flush()
     await db.flush()
@@ -757,12 +877,14 @@ async def run_pipeline(
         src_path=src_path,
         src_path=src_path,
         creator_user_id=creator.id if creator else None,
         creator_user_id=creator.id if creator else None,
         copies=body.copies,
         copies=body.copies,
+        printer_scope=printer_scope,
+        review_required=review_required,
     )
     )
     slice_job = await slice_dispatch.enqueue(
     slice_job = await slice_dispatch.enqueue(
         kind="library_file" if src_kind == "library_file" else "archive",
         kind="library_file" if src_kind == "library_file" else "archive",
         source_id=src_id,
         source_id=src_id,
         source_name=src_filename,
         source_name=src_filename,
-        owner_id=creator.id if creator else None,
+        owner_id=run.created_by,
         run=orchestrate,
         run=orchestrate,
     )
     )
 
 
@@ -770,7 +892,7 @@ async def run_pipeline(
     await db.commit()
     await db.commit()
     await db.refresh(run)
     await db.refresh(run)
 
 
-    return await _materialise_run(db, run)
+    return await _materialise_run(db, run, actor)
 
 
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
@@ -778,31 +900,54 @@ async def run_pipeline(
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 
 
 
 
+def _run_scope_clause(printer_scope: PrinterScope):
+    """Runs the caller may see (#1727): their pipeline targets no printer out of
+    scope, and no copy was assigned to one. None when unrestricted."""
+    if printer_scope.is_unrestricted:
+        return None
+    allowed = printer_scope.printer_ids
+    hidden_by_job = select(PipelineJob.pipeline_run_id).where(
+        PipelineJob.assigned_printer_id.is_not(None), PipelineJob.assigned_printer_id.not_in(allowed)
+    )
+    hidden_pipelines = select(SlicerPipeline.id).where(
+        SlicerPipeline.target_printer_id.is_not(None), SlicerPipeline.target_printer_id.not_in(allowed)
+    )
+    return PipelineRun.id.not_in(hidden_by_job) & (
+        PipelineRun.pipeline_id.is_(None) | PipelineRun.pipeline_id.not_in(hidden_pipelines)
+    )
+
+
+async def _load_run_in_scope(db: AsyncSession, run_id: int, printer_scope: PrinterScope) -> PipelineRun:
+    query = select(PipelineRun).where(PipelineRun.id == run_id)
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        query = query.where(clause)
+    run = (await db.execute(query)).scalar_one_or_none()
+    if run is None:
+        raise HTTPException(404, "Pipeline run not found")
+    return run
+
+
 @pipeline_run_create_router.get("/{pipeline_id}/runs", response_model=PipelineRunListResponse)
 @pipeline_run_create_router.get("/{pipeline_id}/runs", response_model=PipelineRunListResponse)
 async def list_runs_for_pipeline(
 async def list_runs_for_pipeline(
     pipeline_id: int,
     pipeline_id: int,
     limit: int = 10,
     limit: int = 10,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
     limit = max(1, min(limit, 100))
     limit = max(1, min(limit, 100))
+    conditions = [PipelineRun.pipeline_id == pipeline_id]
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        conditions.append(clause)
     rows = (
     rows = (
-        (
-            await db.execute(
-                select(PipelineRun)
-                .where(PipelineRun.pipeline_id == pipeline_id)
-                .order_by(PipelineRun.id.desc())
-                .limit(limit)
-            )
-        )
+        (await db.execute(select(PipelineRun).where(*conditions).order_by(PipelineRun.id.desc()).limit(limit)))
         .scalars()
         .scalars()
         .all()
         .all()
     )
     )
-    total = (
-        await db.execute(select(func.count()).select_from(PipelineRun).where(PipelineRun.pipeline_id == pipeline_id))
-    ).scalar() or 0
+    total = (await db.execute(select(func.count()).select_from(PipelineRun).where(*conditions))).scalar() or 0
     return PipelineRunListResponse(
     return PipelineRunListResponse(
-        runs=[await _materialise_run(db, r) for r in rows],
+        runs=[await _materialise_run(db, r, actor) for r in rows],
         total=total,
         total=total,
     )
     )
 
 
@@ -816,7 +961,9 @@ async def list_all_runs(
     target_printer_id: int | None = None,
     target_printer_id: int | None = None,
     target_model_class: str | None = None,
     target_model_class: str | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
     """Dashboard list. Newest first; filters on pipeline_id + status +
     """Dashboard list. Newest first; filters on pipeline_id + status +
     target_printer_id + target_model_class. The ``status`` filter matches
     target_printer_id + target_model_class. The ``status`` filter matches
@@ -829,6 +976,9 @@ async def list_all_runs(
 
 
     stmt = select(PipelineRun)
     stmt = select(PipelineRun)
     count_stmt = select(func.count()).select_from(PipelineRun)
     count_stmt = select(func.count()).select_from(PipelineRun)
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        stmt = stmt.where(clause)
+        count_stmt = count_stmt.where(clause)
     if pipeline_id is not None:
     if pipeline_id is not None:
         stmt = stmt.where(PipelineRun.pipeline_id == pipeline_id)
         stmt = stmt.where(PipelineRun.pipeline_id == pipeline_id)
         count_stmt = count_stmt.where(PipelineRun.pipeline_id == pipeline_id)
         count_stmt = count_stmt.where(PipelineRun.pipeline_id == pipeline_id)
@@ -849,7 +999,7 @@ async def list_all_runs(
     total = (await db.execute(count_stmt)).scalar() or 0
     total = (await db.execute(count_stmt)).scalar() or 0
 
 
     return PipelineRunListResponse(
     return PipelineRunListResponse(
-        runs=[await _materialise_run(db, r) for r in rows],
+        runs=[await _materialise_run(db, r, actor) for r in rows],
         total=total,
         total=total,
     )
     )
 
 
@@ -860,9 +1010,11 @@ _TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
 @pipeline_run_router.post("/clear")
 @pipeline_run_router.post("/clear")
 async def clear_terminal_runs(
 async def clear_terminal_runs(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
-    """Delete every terminal pipeline run (completed / failed / cancelled /
+    """Delete every terminal pipeline run the caller could cancel (completed / failed / cancelled /
     partial_failure). In-flight runs (queued / slicing / dispatching /
     partial_failure). In-flight runs (queued / slicing / dispatching /
     in_progress) are preserved — clearing those mid-flight would lose the
     in_progress) are preserved — clearing those mid-flight would lose the
     operator's intent. Cascades to PipelineJob via the ondelete='CASCADE'
     operator's intent. Cascades to PipelineJob via the ondelete='CASCADE'
@@ -871,10 +1023,18 @@ async def clear_terminal_runs(
     # Count first so the response can report how many got cleared. Done
     # Count first so the response can report how many got cleared. Done
     # under the same session/transaction as the delete so the numbers can't
     # under the same session/transaction as the delete so the numbers can't
     # drift if another caller races in.
     # drift if another caller races in.
-    count_stmt = select(func.count()).select_from(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES))
+    # Runs on printers the caller can't see are left alone (#1727)
+    conditions: list = [PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)]
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        conditions.append(clause)
+    # Other users' runs only for someone who may change everyone's queue jobs
+    user = actor.owner if isinstance(actor, ApiKeyActor) else actor
+    if actor is not None and user is not None and not user.has_permission(Permission.QUEUE_UPDATE_ALL.value):
+        conditions.append(PipelineRun.created_by == user.id)
+    count_stmt = select(func.count()).select_from(PipelineRun).where(*conditions)
     n = (await db.execute(count_stmt)).scalar() or 0
     n = (await db.execute(count_stmt)).scalar() or 0
     if n > 0:
     if n > 0:
-        await db.execute(delete(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)))
+        await db.execute(delete(PipelineRun).where(*conditions))
         await db.commit()
         await db.commit()
     return {"deleted": n}
     return {"deleted": n}
 
 
@@ -883,50 +1043,78 @@ async def clear_terminal_runs(
 async def get_run(
 async def get_run(
     run_id: int,
     run_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
-    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
-    if run is None:
-        raise HTTPException(404, "Pipeline run not found")
-    return await _materialise_run(db, run)
+    run = await _load_run_in_scope(db, run_id, printer_scope)
+    return await _materialise_run(db, run, actor)
 
 
 
 
 @pipeline_run_router.post("/{run_id}/cancel", response_model=PipelineRunResponse)
 @pipeline_run_router.post("/{run_id}/cancel", response_model=PipelineRunResponse)
 async def cancel_run(
 async def cancel_run(
     run_id: int,
     run_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
     """Cancel a queued / in-flight run. Cascades to all non-terminal queue
     """Cancel a queued / in-flight run. Cascades to all non-terminal queue
-    entries; in-flight prints continue on the printer (operator must Stop)."""
-    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
-    if run is None:
-        raise HTTPException(404, "Pipeline run not found")
+    entries; in-flight prints continue on the printer (operator must Stop).
+
+    The run's queue entries are its starter's: cancelling it takes the right to
+    change their queue jobs, as cancelling one of them on the queue does."""
+    run = await _load_run_in_scope(db, run_id, printer_scope)
+    if not may_reach_row(run.created_by, actor, Permission.QUEUE_UPDATE_ALL, Permission.QUEUE_UPDATE_OWN):
+        raise HTTPException(403, "Only the user who started this run can cancel it")
 
 
     if run.status in ("completed", "failed", "cancelled", "partial_failure"):
     if run.status in ("completed", "failed", "cancelled", "partial_failure"):
-        return await _materialise_run(db, run)
+        return await _materialise_run(db, run, actor)
 
 
     run.status = "cancelled"
     run.status = "cancelled"
     run.completed_at = datetime.now(timezone.utc)
     run.completed_at = datetime.now(timezone.utc)
     if not run.error_message:
     if not run.error_message:
         run.error_message = "Cancelled by user"
         run.error_message = "Cancelled by user"
 
 
+    from backend.app.services.finance_budget import release_budget_reservation
+
+    cancelled_entries: list[int] = []
     job_rows = (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id))).scalars().all()
     job_rows = (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id))).scalars().all()
     for job in job_rows:
     for job in job_rows:
+        queue_entry = None
         if job.queue_entry_id:
         if job.queue_entry_id:
             queue_entry = (
             queue_entry = (
                 await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
                 await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
             ).scalar_one_or_none()
             ).scalar_one_or_none()
             if queue_entry is not None and queue_entry.status in ("pending", "queued"):
             if queue_entry is not None and queue_entry.status in ("pending", "queued"):
                 queue_entry.status = "cancelled"
                 queue_entry.status = "cancelled"
+                queue_entry.completed_at = datetime.now(timezone.utc)
+                # As a cancel from the queue does: its budget hold is freed
+                await release_budget_reservation(
+                    db, source_type="print_queue", source_id=queue_entry.id, status="released"
+                )
+                cancelled_entries.append(queue_entry.id)
+        if job.status in ("queued", "printing") and queue_entry is None:
+            # Dispatched, its entry gone: whether it printed is unknown, and
+            # calling it cancelled would make retry print it again.
+            continue
         if job.status not in ("completed", "failed", "cancelled"):
         if job.status not in ("completed", "failed", "cancelled"):
             job.status = "cancelled"
             job.status = "cancelled"
             job.completed_at = datetime.now(timezone.utc)
             job.completed_at = datetime.now(timezone.utc)
 
 
     await db.commit()
     await db.commit()
+    if cancelled_entries:
+        from backend.app.services.print_batch import refresh_batches_after
+        from backend.app.services.print_scheduler import scheduler
+
+        await refresh_batches_after(cancelled_entries)
+        # A preheating entry's dispatch is parked in a sleep and cannot see the
+        # status written above (#2727): told, it stops heating the printer.
+        for entry_id in cancelled_entries:
+            scheduler.notify_dispatch_cancelled(entry_id)
     await db.refresh(run)
     await db.refresh(run)
     await _publish_run_event(db, run)
     await _publish_run_event(db, run)
-    return await _materialise_run(db, run)
+    return await _materialise_run(db, run, actor)
 
 
 
 
 @pipeline_run_router.post("/{run_id}/retry-failed", response_model=PipelineRunResponse, status_code=202)
 @pipeline_run_router.post("/{run_id}/retry-failed", response_model=PipelineRunResponse, status_code=202)
@@ -934,14 +1122,15 @@ async def retry_failed(
     run_id: int,
     run_id: int,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
-    db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
 ):
     """Create a new run with copies = (failed + cancelled count) from the
     """Create a new run with copies = (failed + cancelled count) from the
     parent. Same pipeline, same source. Eligibility re-checked at run time
     parent. Same pipeline, same source. Eligibility re-checked at run time
     (it might pass this time — operator may have fixed the issue)."""
     (it might pass this time — operator may have fixed the issue)."""
-    parent = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
-    if parent is None:
-        raise HTTPException(404, "Pipeline run not found")
+    parent = await _load_run_in_scope(db, run_id, printer_scope)
     if parent.pipeline_id is None:
     if parent.pipeline_id is None:
         raise HTTPException(400, "Original pipeline was deleted; cannot retry")
         raise HTTPException(400, "Original pipeline was deleted; cannot retry")
     if parent.source_library_file_id is None and parent.source_archive_id is None:
     if parent.source_library_file_id is None and parent.source_archive_id is None:
@@ -983,6 +1172,9 @@ async def retry_failed(
         body,
         body,
         current_user=current_user,
         current_user=current_user,
         api_key_cloud_owner=api_key_cloud_owner,
         api_key_cloud_owner=api_key_cloud_owner,
+        actor=actor,
+        printer_scope=printer_scope,
+        review_required=review_required,
         db=db,
         db=db,
     )
     )
 
 
@@ -992,5 +1184,5 @@ async def retry_failed(
         new_row.parent_run_id = parent.id
         new_row.parent_run_id = parent.id
         await db.commit()
         await db.commit()
         await db.refresh(new_row)
         await db.refresh(new_row)
-        return await _materialise_run(db, new_row)
+        return await _materialise_run(db, new_row, actor)
     return new_run_response
     return new_run_response

+ 55 - 13
backend/app/api/routes/print_log.py

@@ -7,6 +7,9 @@ from sqlalchemy import delete, func, nullslast, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    ApiKeyActor,
+    MediaOrRequestPrinterScope,
+    RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     require_media_token_ownership,
     require_media_token_ownership,
     require_ownership_permission,
     require_ownership_permission,
@@ -14,6 +17,7 @@ from backend.app.core.auth import (
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogEntryUpdate, PrintLogResponse
 from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogEntryUpdate, PrintLogResponse
@@ -44,6 +48,7 @@ _SORTABLE_COLUMNS = {
     "cost": PrintLogEntry.cost,
     "cost": PrintLogEntry.cost,
     "energy": PrintLogEntry.energy_kwh,
     "energy": PrintLogEntry.energy_kwh,
     "energy_cost": PrintLogEntry.energy_cost,
     "energy_cost": PrintLogEntry.energy_cost,
+    "wear_cost": PrintLogEntry.wear_cost,
 }
 }
 
 
 
 
@@ -59,13 +64,14 @@ async def get_print_log(
     offset: int = Query(default=0, ge=0),
     offset: int = Query(default=0, ge=0),
     sort_by: str = Query(default="date"),
     sort_by: str = Query(default="date"),
     sort_dir: str = Query(default="desc", pattern="^(asc|desc)$"),
     sort_dir: str = Query(default="desc", pattern="^(asc|desc)$"),
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get the print log."""
     """Get the print log."""
     user, can_read_all = auth_result
     user, can_read_all = auth_result
@@ -74,6 +80,10 @@ async def get_print_log(
     if user is not None and not can_read_all:
     if user is not None and not can_read_all:
         query = query.where(PrintLogEntry.created_by_id == user.id)
         query = query.where(PrintLogEntry.created_by_id == user.id)
         count_query = count_query.where(PrintLogEntry.created_by_id == user.id)
         count_query = count_query.where(PrintLogEntry.created_by_id == user.id)
+    # Only prints on printers the caller may see (#1727)
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        query = query.where(clause)
+        count_query = count_query.where(clause)
 
 
     if printer_id is not None:
     if printer_id is not None:
         query = query.where(PrintLogEntry.printer_id == printer_id)
         query = query.where(PrintLogEntry.printer_id == printer_id)
@@ -126,22 +136,36 @@ async def get_print_log(
     # It lost failure_reason that way (#1687 part 4), then cost / energy_kwh /
     # It lost failure_reason that way (#1687 part 4), then cost / energy_kwh /
     # energy_cost, which were written to the table but never sent — so the
     # energy_cost, which were written to the table but never sent — so the
     # Print Log's cost and energy columns read empty for every run (#2636).
     # Print Log's cost and energy columns read empty for every run (#2636).
+    # Archives of these prints that were deleted (soft, #1343: their prints
+    # stay logged), so the log doesn't link to an archive page that is gone.
+    from backend.app.models.archive import PrintArchive
+
+    archive_ids = {e.archive_id for e in entries if e.archive_id is not None}
+    deleted = (
+        await db.scalars(
+            select(PrintArchive.id).where(PrintArchive.id.in_(archive_ids), PrintArchive.deleted_at.is_not(None))
+        )
+        if archive_ids
+        else None
+    )
     return PrintLogResponse(
     return PrintLogResponse(
         items=[PrintLogEntrySchema.model_validate(e) for e in entries],
         items=[PrintLogEntrySchema.model_validate(e) for e in entries],
         total=total,
         total=total,
+        deleted_archive_ids=sorted(deleted.all()) if deleted is not None else [],
     )
     )
 
 
 
 
 @router.get("/{entry_id}/thumbnail")
 @router.get("/{entry_id}/thumbnail")
 async def get_print_log_thumbnail(
 async def get_print_log_thumbnail(
     entry_id: int,
     entry_id: int,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get the thumbnail for a print log entry.
     """Get the thumbnail for a print log entry.
 
 
@@ -159,7 +183,7 @@ async def get_print_log_thumbnail(
     """
     """
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     entry = await db.get(PrintLogEntry, entry_id)
     entry = await db.get(PrintLogEntry, entry_id)
-    if not entry or not entry.thumbnail_path:
+    if not entry or not entry.thumbnail_path or not printer_scope.allows(entry.printer_id):
         raise HTTPException(404, "Thumbnail not found")
         raise HTTPException(404, "Thumbnail not found")
     if not can_read_all and (user is None or entry.created_by_id != user.id):
     if not can_read_all and (user is None or entry.created_by_id != user.id):
         raise HTTPException(404, "Thumbnail not found")
         raise HTTPException(404, "Thumbnail not found")
@@ -179,14 +203,19 @@ async def get_print_log_thumbnail(
 
 
 @router.delete("/")
 @router.delete("/")
 async def clear_print_log(
 async def clear_print_log(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Clear the print log.
     """Clear the print log.
 
 
     Only deletes log entries. Archives and queue items are never touched.
     Only deletes log entries. Archives and queue items are never touched.
     """
     """
-    result = await db.execute(delete(PrintLogEntry))
+    # Entries for printers the caller can't see stay (#1727)
+    statement = delete(PrintLogEntry)
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        statement = statement.where(clause)
+    result = await db.execute(statement)
     deleted = result.rowcount
     deleted = result.rowcount
     await db.commit()
     await db.commit()
 
 
@@ -197,13 +226,14 @@ async def clear_print_log(
 @router.delete("/{entry_id}")
 @router.delete("/{entry_id}")
 async def delete_print_log_entry(
 async def delete_print_log_entry(
     entry_id: int,
     entry_id: int,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
             Permission.ARCHIVES_DELETE_ALL,
             Permission.ARCHIVES_DELETE_OWN,
             Permission.ARCHIVES_DELETE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Delete a single print-log entry (#1687).
     """Delete a single print-log entry (#1687).
 
 
@@ -216,7 +246,7 @@ async def delete_print_log_entry(
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
 
 
     entry = await db.get(PrintLogEntry, entry_id)
     entry = await db.get(PrintLogEntry, entry_id)
-    if not entry:
+    if not entry or not printer_scope.allows(entry.printer_id):
         raise HTTPException(404, "Print log entry not found")
         raise HTTPException(404, "Print log entry not found")
 
 
     if not can_modify_all:
     if not can_modify_all:
@@ -254,6 +284,8 @@ _FAILURE_REASON_KEYS = frozenset(
         # recognise, so leaving it out would delete the classification on the
         # recognise, so leaving it out would delete the classification on the
         # next save of such an archive.
         # next save of such an archive.
         "noStatusUpdate",
         "noStatusUpdate",
+        # A print the printer kill switch stopped (written at its completion).
+        "killSwitch",
         "other",
         "other",
     }
     }
 )
 )
@@ -266,13 +298,14 @@ _STATUS_KEYS = frozenset({"completed", "failed", "stopped", "cancelled", "skippe
 async def update_print_log_entry(
 async def update_print_log_entry(
     entry_id: int,
     entry_id: int,
     update: PrintLogEntryUpdate,
     update: PrintLogEntryUpdate,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
             Permission.ARCHIVES_UPDATE_ALL,
             Permission.ARCHIVES_UPDATE_OWN,
             Permission.ARCHIVES_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Edit a single Print Log row's classification (#1687 part 4, reporter
     """Edit a single Print Log row's classification (#1687 part 4, reporter
     IndividualGhost1905).
     IndividualGhost1905).
@@ -290,7 +323,7 @@ async def update_print_log_entry(
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
 
 
     entry = await db.get(PrintLogEntry, entry_id)
     entry = await db.get(PrintLogEntry, entry_id)
-    if not entry:
+    if not entry or not printer_scope.allows(entry.printer_id):
         raise HTTPException(404, "Print log entry not found")
         raise HTTPException(404, "Print log entry not found")
 
 
     if not can_modify_all:
     if not can_modify_all:
@@ -317,6 +350,15 @@ async def update_print_log_entry(
             raise HTTPException(400, f"Unknown status: {new_status!r}")
             raise HTTPException(400, f"Unknown status: {new_status!r}")
         entry.status = new_status
         entry.status = new_status
 
 
+    if "notes" in payload:
+        entry.notes = (payload["notes"] or "").strip() or None
+
+    if entry.archive_id is not None:
+        from backend.app.services.print_log import mirror_latest_run
+
+        # The archive card shows its latest run; this may be it.
+        await mirror_latest_run(db, entry.archive_id)
+
     await db.commit()
     await db.commit()
     await db.refresh(entry)
     await db.refresh(entry)
 
 

File diff suppressed because it is too large
+ 521 - 142
backend/app/api/routes/print_queue.py


+ 348 - 0
backend/app/api/routes/printer_locations.py

@@ -0,0 +1,348 @@
+"""Printer locations (groups): list, create, rename, restyle, delete, assign (#2962).
+
+A printer's location is the free-text ``printers.location`` column, which the
+scheduler's model-based targeting (``print_queue.target_location``) and the
+printers filter match exactly. ``printer_locations`` adds the rest: a location
+with no printers yet, and its icon and colour. The list is the union of both,
+so a location typed into the printer dialog shows up here without a row.
+
+Every write is one transaction on the server. The page used to send one PATCH
+per printer from its cached printer list: a failure halfway left a location
+split under two names, and a printer another user had moved in the meantime
+was moved back.
+
+Locations are also an access setting (#1727): a restricted group can be given
+a location, and then reaches every printer in it. So these routes follow the
+same rules as editing a printer: moving printers into or out of a granted
+location is for admins only, a rename carries the grants along to the new
+name, and a caller limited to some printers sees and changes only locations
+made of their own printers.
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import delete, func, insert, select, update
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled, is_auth_enabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope, location_grantees
+from backend.app.core.websocket import ws_manager
+from backend.app.models.group import group_locations
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.printer_location import PrinterLocation
+from backend.app.models.user import User
+from backend.app.schemas.printer_location import (
+    PrinterLocationAssign,
+    PrinterLocationAssignResult,
+    PrinterLocationCreate,
+    PrinterLocationDelete,
+    PrinterLocationDeleteResult,
+    PrinterLocationResponse,
+    PrinterLocationUpdate,
+)
+from backend.app.utils.natural_sort import natural_sort_key
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/printer-locations", tags=["printer-locations"])
+
+DUPLICATE_NAME = "A location with this name already exists"
+
+
+def _blank_location():
+    """Printers with no location: NULL, or "" from before blanks were folded."""
+    return (Printer.location.is_(None)) | (func.trim(Printer.location) == "")
+
+
+async def _printer_counts(db: AsyncSession, scope: PrinterScope | None = None) -> dict[str, int]:
+    """Printers per location; with ``scope``, only the printers in it."""
+    query = select(Printer.location, func.count(Printer.id)).where(~_blank_location())
+    limit = scope.where_strict(Printer.id) if scope is not None else None
+    if limit is not None:
+        query = query.where(limit)
+    result = await db.execute(query.group_by(Printer.location))
+    return dict(result.all())
+
+
+async def _ensure_all_in_scope(db: AsyncSession, scope: PrinterScope, names: set[str]) -> None:
+    """Refuse a limited caller a location that also holds printers they can't see.
+
+    Renaming or deleting it would change those printers too.
+    """
+    if scope.is_unrestricted or not names:
+        return
+    ids = (await db.execute(select(Printer.id).where(Printer.location.in_(names)))).scalars().all()
+    if any(not scope.allows(pid) for pid in ids):
+        raise HTTPException(
+            status_code=403,
+            detail="This location also holds printers you can't access, so only someone who can see them all may change it.",
+        )
+
+
+async def _ensure_admin_for_access_change(db: AsyncSession, user: User | None, grantees: list[str]) -> None:
+    """Moving printers into or out of a granted location changes who reaches them (#1727)."""
+    if grantees and await is_auth_enabled(db) and not (user is not None and user.is_admin):
+        raise HTTPException(
+            status_code=403,
+            detail=(
+                "This changes which printers the groups "
+                + ", ".join(grantees)
+                + " can access through their locations. Only an admin can do that."
+            ),
+        )
+
+
+async def _row(db: AsyncSession, name: str) -> PrinterLocation | None:
+    result = await db.execute(select(PrinterLocation).where(PrinterLocation.name == name))
+    return result.scalar_one_or_none()
+
+
+async def _name_taken(db: AsyncSession, name: str, *, ignore: str | None = None) -> bool:
+    """Whether ``name`` is a location already, ignoring case.
+
+    Matching elsewhere is exact, so "Workshop" and "workshop" would be two
+    locations that look like one. Refusing the second keeps them apart.
+    """
+    key = name.casefold()
+    rows = (await db.execute(select(PrinterLocation.name))).scalars().all()
+    used = (await db.execute(select(Printer.location).where(~_blank_location()).distinct())).scalars().all()
+    return any(n.casefold() == key and n != ignore for n in (*rows, *used))
+
+
+async def _conflicts(db: AsyncSession, name: str) -> bool:
+    """Whether using ``name`` would add a case variant of a location.
+
+    A name that already exists exactly is never a conflict, even when an older
+    install also holds a case variant of it: refusing would leave no way to
+    move a printer into, or style, a location that is plainly there.
+    """
+    if await _row(db, name) is not None:
+        return False
+    if name in await _printer_counts(db):
+        return False
+    return await _name_taken(db, name)
+
+
+async def _move_grants(db: AsyncSession, old: str, new: str) -> None:
+    """Re-point the groups' grants of ``old`` to ``new`` (#1727).
+
+    A group already granted ``new`` keeps one grant: the pair is the primary key.
+    """
+    holders = (
+        (await db.execute(select(group_locations.c.group_id).where(group_locations.c.location == old))).scalars().all()
+    )
+    if not holders:
+        return
+    already = set(
+        (
+            await db.execute(
+                select(group_locations.c.group_id).where(
+                    group_locations.c.location == new, group_locations.c.group_id.in_(holders)
+                )
+            )
+        )
+        .scalars()
+        .all()
+    )
+    await db.execute(delete(group_locations).where(group_locations.c.location == old))
+    rows = [{"group_id": gid, "location": new} for gid in holders if gid not in already]
+    if rows:
+        await db.execute(insert(group_locations), rows)
+
+
+async def _broadcast() -> None:
+    await ws_manager.broadcast({"type": "printer_locations_changed"})
+
+
+@router.get("/", response_model=list[PrinterLocationResponse])
+async def list_printer_locations(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Every location: those with a row, and those only printers carry.
+
+    A caller limited to some printers (#1727) sees only the locations their
+    printers are in, counted over those printers.
+    """
+    counts = await _printer_counts(db, printer_scope)
+    rows = (await db.execute(select(PrinterLocation))).scalars().all()
+    if not printer_scope.is_unrestricted:
+        rows = [row for row in rows if row.name in counts]
+    out = {
+        row.name: PrinterLocationResponse(
+            id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
+        )
+        for row in rows
+    }
+    for name, count in counts.items():
+        if name not in out:
+            out[name] = PrinterLocationResponse(name=name, printer_count=count)
+    return sorted(out.values(), key=lambda loc: natural_sort_key(loc.name))
+
+
+@router.post("/", response_model=PrinterLocationResponse, status_code=201)
+async def create_printer_location(
+    data: PrinterLocationCreate,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+):
+    """Create a location, with or without printers in it yet."""
+    if await _row(db, data.name) is not None or await _conflicts(db, data.name):
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
+    # A location printers already use but that has no row yet gets one, which is
+    # how it gets an icon and colour.
+    row = PrinterLocation(name=data.name, icon=data.icon, color=data.color)
+    db.add(row)
+    try:
+        await db.commit()
+    except IntegrityError as exc:
+        await db.rollback()
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
+    await db.refresh(row)
+    await _broadcast()
+    counts = await _printer_counts(db)
+    return PrinterLocationResponse(
+        id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
+    )
+
+
+@router.patch("/", response_model=PrinterLocationResponse)
+async def update_printer_location(
+    data: PrinterLocationUpdate,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Rename a location and/or change its icon and colour.
+
+    A rename moves its printers and the queue items that target it in the same
+    transaction, so an "any printer in <location>" job, and the next run of a
+    batch, keep finding their printers.
+    """
+    row = await _row(db, data.name)
+    counts = await _printer_counts(db)
+    if row is None and data.name not in counts:
+        raise HTTPException(status_code=404, detail="Location not found")
+    if not printer_scope.is_unrestricted and data.name not in await _printer_counts(db, printer_scope):
+        raise HTTPException(status_code=404, detail="Location not found")
+    await _ensure_all_in_scope(db, printer_scope, {data.name})
+
+    name = data.name
+    new_name = data.new_name if "new_name" in data.model_fields_set else None
+    if new_name is not None and new_name != name:
+        if await _name_taken(db, new_name, ignore=name):
+            raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
+        # A grant left on the new name (#1727) would hand these printers to
+        # another group: that is an access change.
+        await _ensure_admin_for_access_change(db, user, await location_grantees(db, [new_name]))
+        await db.execute(update(Printer).where(Printer.location == name).values(location=new_name))
+        # The groups given this location keep it under its new name, so the
+        # rename changes no one's access.
+        await _move_grants(db, name, new_name)
+        # Every row, not only pending ones: a batch clones its next run from
+        # its newest row whatever that row's status, so a finished run still
+        # pointing at the old name would send future runs nowhere. A rename is
+        # the same place under a new name, so history follows it too.
+        await db.execute(
+            update(PrintQueueItem).where(PrintQueueItem.target_location == name).values(target_location=new_name)
+        )
+        name = new_name
+
+    if row is None:
+        row = PrinterLocation(name=name)
+        db.add(row)
+    else:
+        row.name = name
+    if "icon" in data.model_fields_set:
+        row.icon = data.icon
+    if "color" in data.model_fields_set:
+        row.color = data.color
+
+    try:
+        await db.commit()
+    except IntegrityError as exc:
+        await db.rollback()
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
+    await db.refresh(row)
+    await _broadcast()
+    counts = await _printer_counts(db)
+    return PrinterLocationResponse(
+        id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
+    )
+
+
+@router.post("/delete", response_model=PrinterLocationDeleteResult)
+async def delete_printer_locations(
+    data: PrinterLocationDelete,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Delete locations; their printers end up with no location.
+
+    Pending queue items that target a deleted location are left alone: changing
+    them to "any location" would let them start on printers they were meant to
+    stay off.
+
+    A location given to a group (#1727) can only be deleted by an admin, and
+    its grants go with it: left behind, they would hand a later location of
+    the same name to that group.
+    """
+    names = set(data.names)
+    if not printer_scope.is_unrestricted:
+        # A limited caller can only reach locations made of their own printers.
+        names &= set(await _printer_counts(db, printer_scope))
+    await _ensure_all_in_scope(db, printer_scope, names)
+    grantees = await location_grantees(db, names)
+    await _ensure_admin_for_access_change(db, user, grantees)
+    existing = set((await db.execute(select(PrinterLocation.name).where(PrinterLocation.name.in_(names)))).scalars())
+    existing |= set(await _printer_counts(db)) & names
+    await db.execute(delete(PrinterLocation).where(PrinterLocation.name.in_(names)))
+    moved = await db.execute(update(Printer).where(Printer.location.in_(names)).values(location=None))
+    if names:
+        await db.execute(delete(group_locations).where(group_locations.c.location.in_(names)))
+    await db.commit()
+    if grantees:
+        await ws_manager.refresh_printer_scopes()
+    await _broadcast()
+    return PrinterLocationDeleteResult(deleted=len(existing), printers_ungrouped=moved.rowcount or 0)
+
+
+@router.post("/assign", response_model=PrinterLocationAssignResult)
+async def assign_printer_location(
+    data: PrinterLocationAssign,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Move printers into a location, or out of any with null.
+
+    By printer id, on the server: a printer someone else moved meanwhile is
+    moved again only if it is in this request. A printer outside the caller's
+    scope (#1727) is "not found", and moving printers into or out of a
+    location given to a group is for admins only, as when editing a printer.
+    """
+    # Moving into a new case variant of an existing location would split it.
+    if data.location is not None and await _conflicts(db, data.location):
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
+    ids = list(dict.fromkeys(data.printer_ids))
+    rows = (await db.execute(select(Printer.id, Printer.location).where(Printer.id.in_(ids)))).all()
+    found = {pid for pid, _ in rows if printer_scope.allows(pid)}
+    missing = sorted(set(ids) - found)
+    if missing:
+        raise HTTPException(status_code=404, detail=f"Printer not found: {', '.join(map(str, missing))}")
+    leaving = {location for _, location in rows if location != data.location}
+    grantees = await location_grantees(db, [*leaving, data.location]) if leaving else []
+    await _ensure_admin_for_access_change(db, user, grantees)
+    result = await db.execute(update(Printer).where(Printer.id.in_(ids)).values(location=data.location))
+    await db.commit()
+    if grantees:
+        await ws_manager.refresh_printer_scopes()
+    await _broadcast()
+    return PrinterLocationAssignResult(moved=result.rowcount or 0)

+ 3 - 36
backend/app/api/routes/printer_sensor_history.py

@@ -7,7 +7,7 @@ from pydantic import BaseModel
 from sqlalchemy import and_, func, select
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePrinterPermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
@@ -45,8 +45,8 @@ async def get_printer_sensor_history(
         default=None,
         default=None,
         description="Comma-separated list of sensor kinds (nozzle, nozzle_2, bed, chamber). All by default.",
         description="Comma-separated list of sensor kinds (nozzle, nozzle_2, bed, chamber). All by default.",
     ),
     ),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
 ):
 ):
     """Return per-sensor heater history for a printer."""
     """Return per-sensor heater history for a printer."""
     since = datetime.now(timezone.utc) - timedelta(hours=hours)
     since = datetime.now(timezone.utc) - timedelta(hours=hours)
@@ -105,36 +105,3 @@ async def get_printer_sensor_history(
         )
         )
 
 
     return PrinterSensorHistoryResponse(printer_id=printer_id, series=series_out)
     return PrinterSensorHistoryResponse(printer_id=printer_id, series=series_out)
-
-
-@router.delete("/{printer_id}")
-async def delete_old_history(
-    printer_id: int,
-    days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
-):
-    """Delete old printer sensor history for a printer."""
-    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
-
-    result = await db.execute(
-        select(func.count(PrinterSensorHistory.id)).where(
-            and_(
-                PrinterSensorHistory.printer_id == printer_id,
-                PrinterSensorHistory.recorded_at < cutoff,
-            )
-        )
-    )
-    count = result.scalar()
-
-    await db.execute(
-        PrinterSensorHistory.__table__.delete().where(
-            and_(
-                PrinterSensorHistory.printer_id == printer_id,
-                PrinterSensorHistory.recorded_at < cutoff,
-            )
-        )
-    )
-    await db.commit()
-
-    return {"deleted": count, "message": f"Deleted {count} records older than {days} days"}

File diff suppressed because it is too large
+ 289 - 240
backend/app/api/routes/printers.py


File diff suppressed because it is too large
+ 277 - 383
backend/app/api/routes/projects.py


+ 12 - 5
backend/app/api/routes/scheduled_dryings.py

@@ -4,9 +4,10 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.scheduled_drying import ScheduledDrying
 from backend.app.models.scheduled_drying import ScheduledDrying
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -29,8 +30,10 @@ LISTED_STATUSES = (*ACTIVE_STATUSES, "failed")
 async def create_scheduled_drying(
 async def create_scheduled_drying(
     payload: ScheduledDryingCreate,
     payload: ScheduledDryingCreate,
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
+    printer_scope.ensure(payload.printer_id)
     result = await db.execute(select(Printer).where(Printer.id == payload.printer_id))
     result = await db.execute(select(Printer).where(Printer.id == payload.printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
     if not printer:
     if not printer:
@@ -70,9 +73,12 @@ async def create_scheduled_drying(
 async def list_scheduled_dryings(
 async def list_scheduled_dryings(
     printer_id: int | None = None,
     printer_id: int | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     query = select(ScheduledDrying).where(ScheduledDrying.status.in_(LISTED_STATUSES))
     query = select(ScheduledDrying).where(ScheduledDrying.status.in_(LISTED_STATUSES))
+    if (clause := printer_scope.where_strict(ScheduledDrying.printer_id)) is not None:
+        query = query.where(clause)
     if printer_id is not None:
     if printer_id is not None:
         query = query.where(ScheduledDrying.printer_id == printer_id)
         query = query.where(ScheduledDrying.printer_id == printer_id)
     result = await db.execute(query.order_by(ScheduledDrying.start_after.asc().nullsfirst(), ScheduledDrying.id.asc()))
     result = await db.execute(query.order_by(ScheduledDrying.start_after.asc().nullsfirst(), ScheduledDrying.id.asc()))
@@ -83,11 +89,12 @@ async def list_scheduled_dryings(
 async def cancel_scheduled_drying(
 async def cancel_scheduled_drying(
     scheduled_drying_id: int,
     scheduled_drying_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     result = await db.execute(select(ScheduledDrying).where(ScheduledDrying.id == scheduled_drying_id))
     result = await db.execute(select(ScheduledDrying).where(ScheduledDrying.id == scheduled_drying_id))
     row = result.scalar_one_or_none()
     row = result.scalar_one_or_none()
-    if not row:
+    if not row or not printer_scope.allows(row.printer_id):
         raise HTTPException(404, "Scheduled drying not found")
         raise HTTPException(404, "Scheduled drying not found")
     if row.status == "failed":
     if row.status == "failed":
         # Terminal and now acknowledged; drop it so it stops being listed.
         # Terminal and now acknowledged; drop it so it stops being listed.

+ 348 - 213
backend/app/api/routes/settings.py

@@ -1,4 +1,5 @@
 import io
 import io
+import json
 import logging
 import logging
 import os
 import os
 import zipfile
 import zipfile
@@ -12,18 +13,24 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     ScopedCaller,
     ScopedCaller,
     caller_is_api_key,
     caller_is_api_key,
+    is_auth_enabled,
     require_auth_if_enabled,
     require_auth_if_enabled,
     require_energy_cost_update,
     require_energy_cost_update,
 )
 )
 from backend.app.core.config import APP_VERSION, settings as app_settings
 from backend.app.core.config import APP_VERSION, settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
+from backend.app.schemas.settings import ADMIN_ONLY_SETTING_KEYS, AppSettings, AppSettingsUpdate
+from backend.app.services import camera_light
+from backend.app.services.secret_fields import SETTINGS_SECRETS, SecretConfigError, mask_fields, merge_partial
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -31,14 +38,9 @@ router = APIRouter(prefix="/settings", tags=["settings"])
 
 
 DEFAULT_SETTINGS = AppSettings()
 DEFAULT_SETTINGS = AppSettings()
 
 
-# Sensitive credential fields blanked for API-key callers
-_SENSITIVE_FIELDS_FOR_API_KEY = (
-    "mqtt_password",
-    "ha_token",
-    "prometheus_token",
-    "virtual_printer_access_code",
-    "ldap_bind_password",
-)
+# Sensitive credential fields blanked for API-key callers. Everyone else sees
+# them as the placeholder (write-only secrets, see services/secret_fields).
+_SENSITIVE_FIELDS_FOR_API_KEY = (*SETTINGS_SECRETS, "octoeverywhere_api_key")
 
 
 
 
 async def get_setting(db: AsyncSession, key: str) -> str | None:
 async def get_setting(db: AsyncSession, key: str) -> str | None:
@@ -188,7 +190,6 @@ _BOOL_SETTING_KEYS = frozenset(
         "include_beta_updates",
         "include_beta_updates",
         "announcements_enabled",
         "announcements_enabled",
         "announcements_all_users",
         "announcements_all_users",
-        "virtual_printer_enabled",
         "ftp_retry_enabled",
         "ftp_retry_enabled",
         "mqtt_enabled",
         "mqtt_enabled",
         "mqtt_use_tls",
         "mqtt_use_tls",
@@ -218,6 +219,8 @@ _BOOL_SETTING_KEYS = frozenset(
         "local_login_enabled",
         "local_login_enabled",
         "preheat_enabled",
         "preheat_enabled",
         "queue_keep_bed_warm",
         "queue_keep_bed_warm",
+        "obico_enabled",
+        "octoeverywhere_enabled",
     }
     }
 )
 )
 
 
@@ -225,6 +228,7 @@ _FLOAT_SETTING_KEYS = frozenset(
     {
     {
         "default_filament_cost",
         "default_filament_cost",
         "energy_cost_per_kwh",
         "energy_cost_per_kwh",
+        "camera_light_delay",
         "ams_temp_good",
         "ams_temp_good",
         "ams_temp_fair",
         "ams_temp_fair",
         "library_disk_warning_gb",
         "library_disk_warning_gb",
@@ -232,6 +236,12 @@ _FLOAT_SETTING_KEYS = frozenset(
     }
     }
 )
 )
 
 
+# String settings limited to a fixed set of values, which a stored "None"
+# would break when the settings response is built.
+_ENUM_SETTING_KEYS = frozenset(
+    {"energy_price_source", "obico_sensitivity", "obico_action", "octoeverywhere_confidence", "octoeverywhere_action"}
+)
+
 _INT_SETTING_KEYS = frozenset(
 _INT_SETTING_KEYS = frozenset(
     {
     {
         "ams_humidity_good",
         "ams_humidity_good",
@@ -255,12 +265,19 @@ _INT_SETTING_KEYS = frozenset(
         "queue_keep_warm_max_minutes",
         "queue_keep_warm_max_minutes",
         "queue_max_concurrent_uploads",
         "queue_max_concurrent_uploads",
         "ambient_drying_sustained_minutes",
         "ambient_drying_sustained_minutes",
+        "pause_warning_minutes",
+        "obico_poll_interval",
+        "octoeverywhere_poll_interval",
     }
     }
 )
 )
 
 
 
 
-async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -> AppSettings:
-    """Build the full settings response, scrubbing secrets for API-key callers."""
+async def _build_settings_response(db: AsyncSession, is_api_key: bool = False, mask: bool = True) -> AppSettings:
+    """Build the full settings response.
+
+    Secrets are blank for API-key callers and the placeholder for everyone else;
+    ``mask=False`` is for server-side use only (comparing with an update).
+    """
     settings_dict = DEFAULT_SETTINGS.model_dump()
     settings_dict = DEFAULT_SETTINGS.model_dump()
 
 
     result = await db.execute(select(Settings))
     result = await db.execute(select(Settings))
@@ -301,13 +318,21 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
     ha_settings = await get_homeassistant_settings(db)
     ha_settings = await get_homeassistant_settings(db)
     settings_dict.update(ha_settings)
     settings_dict.update(ha_settings)
 
 
-    # ldap_bind_password is never returned to any caller
-    settings_dict["ldap_bind_password"] = ""
+    # ldap_bind_password is never returned to any caller (mask=False is
+    # server-side only)
+    if mask or is_api_key:
+        settings_dict["ldap_bind_password"] = ""
+
+    # The saved key is write-only, including for authenticated administrators.
+    settings_dict["octoeverywhere_api_key_configured"] = bool(settings_dict["octoeverywhere_api_key"].strip())
+    settings_dict["octoeverywhere_api_key"] = ""
 
 
     if is_api_key:
     if is_api_key:
         for field in _SENSITIVE_FIELDS_FOR_API_KEY:
         for field in _SENSITIVE_FIELDS_FOR_API_KEY:
             if field in settings_dict:
             if field in settings_dict:
                 settings_dict[field] = ""
                 settings_dict[field] = ""
+    elif mask:
+        settings_dict = mask_fields(settings_dict, SETTINGS_SECRETS)
 
 
     return AppSettings(**settings_dict)
     return AppSettings(**settings_dict)
 
 
@@ -315,7 +340,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
 @router.get("", response_model=AppSettings)
 @router.get("", response_model=AppSettings)
 @router.get("/", response_model=AppSettings)
 @router.get("/", response_model=AppSettings)
 async def get_settings(
 async def get_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _is_api_key: bool = Depends(caller_is_api_key),
     _is_api_key: bool = Depends(caller_is_api_key),
 ):
 ):
@@ -323,25 +348,98 @@ async def get_settings(
     return await _build_settings_response(db, is_api_key=_is_api_key)
     return await _build_settings_response(db, is_api_key=_is_api_key)
 
 
 
 
+def _as_stored_setting(value: object) -> str:
+    """The text the settings writer below stores for ``value``."""
+    if isinstance(value, bool):
+        return "true" if value else "false"
+    if value is None:
+        return "None"
+    return str(value)
+
+
+def _same_setting_value(new: object, old: object) -> bool:
+    """Whether saving ``new`` stores exactly what ``old`` is stored as."""
+    return _as_stored_setting(new) == _as_stored_setting(old)
+
+
 @router.put("/", response_model=AppSettings)
 @router.put("/", response_model=AppSettings)
 async def update_settings(
 async def update_settings(
     settings_update: AppSettingsUpdate,
     settings_update: AppSettingsUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Update application settings."""
     """Update application settings."""
     update_data = settings_update.model_dump(exclude_unset=True)
     update_data = settings_update.model_dump(exclude_unset=True)
 
 
-    # An explicit null for a boolean or numeric setting has no meaning -- these
-    # are not clearable -- and would be stored as the literal "None".
+    # Write-only secrets: the placeholder keeps the stored value, and a kept
+    # secret does not follow a changed destination.
+    stored = (await _build_settings_response(db, mask=False)).model_dump()
+    secrets = dict(SETTINGS_SECRETS)
+    if stored.get("ha_token_from_env"):
+        # The deployment's own token, not a stored secret to enter again: the
+        # URL may change (the placeholder sent back still writes nothing).
+        secrets["ha_token"] = ()
+    try:
+        update_data = merge_partial(stored, update_data, secrets)
+    except SecretConfigError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from None
+
+    # Omitted/null preserves the saved key; an explicit empty string removes it.
+    if update_data.get("octoeverywhere_api_key") is None:
+        update_data.pop("octoeverywhere_api_key", None)
+
+    # These typed settings are not clearable: null would be stored as "None".
+    # Check before normalizing provider flags so enabling one cannot hide a
+    # null submitted for the other.
     null_keys = sorted(
     null_keys = sorted(
         key
         key
         for key, value in update_data.items()
         for key, value in update_data.items()
-        if value is None and key in (_BOOL_SETTING_KEYS | _FLOAT_SETTING_KEYS | _INT_SETTING_KEYS)
+        if value is None and key in (_BOOL_SETTING_KEYS | _FLOAT_SETTING_KEYS | _INT_SETTING_KEYS | _ENUM_SETTING_KEYS)
     )
     )
     if null_keys:
     if null_keys:
         raise HTTPException(status_code=422, detail=f"These settings cannot be null: {', '.join(null_keys)}")
         raise HTTPException(status_code=422, detail=f"These settings cannot be null: {', '.join(null_keys)}")
 
 
+    # Only one failure detection provider may control a print at a time.
+    if update_data.get("obico_enabled") and update_data.get("octoeverywhere_enabled"):
+        raise HTTPException(status_code=400, detail="Enable only one AI failure detection provider at a time.")
+    if update_data.get("octoeverywhere_enabled"):
+        update_data["obico_enabled"] = False
+    elif update_data.get("obico_enabled"):
+        update_data["octoeverywhere_enabled"] = False
+
+    # Settings that decide who can sign in are admin-only, like the user routes.
+    # Only a real change counts: the settings page sends every field on each
+    # save, so an unchanged value from a non-admin must pass.
+    if current_user is not None and not current_user.is_admin:
+        changed = sorted(
+            key
+            for key in update_data
+            if key in ADMIN_ONLY_SETTING_KEYS and not _same_setting_value(update_data[key], stored.get(key))
+        )
+        if changed:
+            raise HTTPException(
+                status_code=403,
+                detail=f"Only an administrator can change these settings: {', '.join(changed)}",
+            )
+
+    # Like an API key's or a provider's printers: a page still open from before
+    # a printer delete would save the deleted id, which SQLite gives to the next
+    # printer created (the validator has already checked it is a list of ints).
+    # Only ids not stored yet are checked: the page sends the whole list, and an
+    # id already there must not block every save.
+    for key in ("obico_enabled_printers", "octoeverywhere_enabled_printers"):
+        if not update_data.get(key):
+            continue
+        from backend.app.core.printer_scope import ensure_printers_exist
+
+        try:
+            stored_printers = json.loads(await get_setting(db, key) or "[]")
+        except ValueError:
+            stored_printers = []
+        stored_printers = set(stored_printers) if isinstance(stored_printers, list) else set()
+        added = [i for i in json.loads(update_data[key]) if i not in stored_printers]
+        await ensure_printers_exist(db, added)
+
     # Safety refusals on disabling local login (#1589). Two failure modes
     # Safety refusals on disabling local login (#1589). Two failure modes
     # would otherwise lock everyone out of the install:
     # would otherwise lock everyone out of the install:
     #   1. No enabled OIDC provider exists — nobody could authenticate.
     #   1. No enabled OIDC provider exists — nobody could authenticate.
@@ -382,20 +480,44 @@ async def update_settings(
     }
     }
     mqtt_updated = bool(mqtt_keys & set(update_data.keys()))
     mqtt_updated = bool(mqtt_keys & set(update_data.keys()))
 
 
+    # The page saves every field it shows on each change, so compare against
+    # what is stored: only a real change of price source is worth a read.
+    price_source_changed = False
+    for key in ("energy_price_source", "energy_price_ha_entity"):
+        if key in update_data and str(update_data[key]) != (await get_setting(db, key) or ""):
+            price_source_changed = True
+
     for key, value in update_data.items():
     for key, value in update_data.items():
         # Convert value to string for storage
         # Convert value to string for storage
-        if isinstance(value, bool):
-            str_value = "true" if value else "false"
-        elif value is None:
-            str_value = "None"
-        else:
-            str_value = str(value)
-        await set_setting(db, key, str_value)
+        await set_setting(db, key, _as_stored_setting(value))
 
 
     await db.commit()
     await db.commit()
     # Expire all objects to ensure fresh reads after commit
     # Expire all objects to ensure fresh reads after commit
     db.expire_all()
     db.expire_all()
 
 
+    if update_data.get("obico_enabled") is False:
+        from backend.app.services.obico_detection import obico_detection_service
+
+        await obico_detection_service.refresh_settings()
+    if any(key.startswith("octoeverywhere_") for key in update_data):
+        from backend.app.services.octoeverywhere_detection import octoeverywhere_detection_service
+
+        await octoeverywhere_detection_service.refresh_settings()
+
+    if {"camera_light_mode", "camera_light_delay"} & set(update_data.keys()):
+        camera_light.invalidate_settings()
+
+    if price_source_changed:
+        # Read a newly chosen price sensor now, so the saved settings show its
+        # price instead of the old one until the next hourly read (#1251).
+        from backend.app.services.energy_price import current_price
+
+        try:
+            await current_price(db, remember=True)
+            await db.commit()
+        except Exception as e:
+            logger.warning("Could not read the electricity price after saving its source: %s", e)
+
     # Reconfigure MQTT relay if any MQTT settings changed
     # Reconfigure MQTT relay if any MQTT settings changed
     if mqtt_updated:
     if mqtt_updated:
         try:
         try:
@@ -414,7 +536,7 @@ async def update_settings(
         except Exception:
         except Exception:
             pass  # Don't fail the settings update if MQTT reconfiguration fails
             pass  # Don't fail the settings update if MQTT reconfiguration fails
 
 
-    # Return updated settings (never scrub secrets on PUT — caller has SETTINGS_UPDATE permission)
+    # Return updated settings, secrets as the placeholder
     return await _build_settings_response(db, is_api_key=False)
     return await _build_settings_response(db, is_api_key=False)
 
 
 
 
@@ -422,7 +544,7 @@ async def update_settings(
 @router.patch("", response_model=AppSettings)
 @router.patch("", response_model=AppSettings)
 async def patch_settings(
 async def patch_settings(
     settings_update: AppSettingsUpdate,
     settings_update: AppSettingsUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Partially update application settings (same as PUT, for REST compatibility)."""
     """Partially update application settings (same as PUT, for REST compatibility)."""
@@ -444,7 +566,7 @@ class ElectricityPriceUpdate(BaseModel):
 @router.post("/electricity-price", response_model=AppSettings)
 @router.post("/electricity-price", response_model=AppSettings)
 async def update_electricity_price(
 async def update_electricity_price(
     payload: ElectricityPriceUpdate,
     payload: ElectricityPriceUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: ScopedCaller = Depends(require_energy_cost_update()),
     _: ScopedCaller = Depends(require_energy_cost_update()),
     _is_api_key: bool = Depends(caller_is_api_key),
     _is_api_key: bool = Depends(caller_is_api_key),
 ):
 ):
@@ -465,7 +587,8 @@ async def update_electricity_price(
 
 
 @router.post("/reset", response_model=AppSettings)
 @router.post("/reset", response_model=AppSettings)
 async def reset_settings(
 async def reset_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Reset all settings to defaults."""
     """Reset all settings to defaults."""
@@ -475,13 +598,14 @@ async def reset_settings(
         await db.delete(setting)
         await db.delete(setting)
 
 
     await db.commit()
     await db.commit()
+    camera_light.invalidate_settings()
 
 
     return DEFAULT_SETTINGS
     return DEFAULT_SETTINGS
 
 
 
 
 @router.get("/default-sidebar-order")
 @router.get("/default-sidebar-order")
 async def get_default_sidebar_order(
 async def get_default_sidebar_order(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Get the admin-set default sidebar order.
     """Get the admin-set default sidebar order.
 
 
@@ -526,7 +650,7 @@ _UI_PREFERENCE_FIELDS: tuple[str, ...] = (
 
 
 
 
 @router.get("/ui-preferences")
 @router.get("/ui-preferences")
-async def get_ui_preferences(db: AsyncSession = Depends(get_db)):
+async def get_ui_preferences(db: AsyncSession = Depends(get_db, scope="function")):
     """Get the curated subset of settings that any page needs to render correctly.
     """Get the curated subset of settings that any page needs to render correctly.
 
 
     Intentionally not gated on SETTINGS_READ — every authenticated user (and
     Intentionally not gated on SETTINGS_READ — every authenticated user (and
@@ -568,13 +692,54 @@ _UI_FLAG_FIELDS: tuple[str, ...] = (
     # update check ran even where it had been switched off.
     # update check ran even where it had been switched off.
     "currency",
     "currency",
     "check_updates",
     "check_updates",
+    # Read by the print dialog, queue, file manager, inventory and SpoolBuddy
+    # pages, which users without SETTINGS_READ open too: the 403 dropped them
+    # all to their defaults (with billing on, the print dialog hid the cost
+    # centre picker and the print was then refused for lacking one).
+    "queue_shortest_first",
+    "use_slicer_api",
+    "open_in_slicer",
+    "preferred_slicer",
+    "disable_filament_warnings",
+    "prefer_lowest_filament",
+    "per_printer_mapping_expanded",
+    "default_filament_cost",
+    "default_bed_levelling",
+    "default_flow_cali",
+    "default_vibration_cali",
+    "default_layer_inspect",
+    "default_timelapse",
+    "default_nozzle_offset_cali",
+    "default_confirm_outcome",
+    "stagger_group_size",
+    "stagger_interval_minutes",
+    "library_disk_warning_gb",
+    "low_stock_threshold",
+    "location_sensor_poll_interval",
+    "location_sensor_alert_defaults",
+    "pipeline_max_copies",
+    "language",
+    "dark_style",
+    "dark_background",
+    "dark_accent",
+    "light_style",
+    "light_background",
+    "light_accent",
+    # Which inventory a new spool goes to (unknown-tag prompt), and the
+    # forecast's default lead time.
+    "spoolman_enabled",
+    "forecast_global_lead_time_days",
+    # Preselected in the print dialog; withheld below from a caller who may
+    # not use that printer.
+    "default_printer_id",
 )
 )
 
 
 
 
 @router.get("/ui-flags")
 @router.get("/ui-flags")
 async def get_ui_flags(
 async def get_ui_flags(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_auth_if_enabled),
     _: User | None = Depends(require_auth_if_enabled),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Install configuration the app shell needs, for any signed-in user.
     """Install configuration the app shell needs, for any signed-in user.
 
 
@@ -591,7 +756,12 @@ async def get_ui_flags(
     """
     """
     full = await _build_settings_response(db, is_api_key=False)
     full = await _build_settings_response(db, is_api_key=False)
     dumped = full.model_dump()
     dumped = full.model_dump()
-    return {key: dumped[key] for key in _UI_FLAG_FIELDS if key in dumped}
+    flags = {key: dumped[key] for key in _UI_FLAG_FIELDS if key in dumped}
+    if flags.get("default_printer_id") is not None and not printer_scope.allows(flags["default_printer_id"]):
+        flags["default_printer_id"] = None
+    # Whether G-code snippets are set up, not the snippets themselves.
+    flags["has_gcode_snippets"] = bool(dumped.get("gcode_snippets"))
+    return flags
 
 
 
 
 @router.get("/check-ffmpeg")
 @router.get("/check-ffmpeg")
@@ -617,7 +787,7 @@ async def check_ffmpeg(
 
 
 @router.get("/spoolman")
 @router.get("/spoolman")
 async def get_spoolman_settings(
 async def get_spoolman_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
 ):
     """Get Spoolman integration settings."""
     """Get Spoolman integration settings."""
@@ -641,7 +811,7 @@ async def get_spoolman_settings(
 @router.put("/spoolman")
 @router.put("/spoolman")
 async def update_spoolman_settings(
 async def update_spoolman_settings(
     settings: dict,
     settings: dict,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Update Spoolman integration settings.
     """Update Spoolman integration settings.
@@ -827,6 +997,7 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
             ("plate_calibration", app_settings.plate_calibration_dir),
             ("plate_calibration", app_settings.plate_calibration_dir),
             ("icons", base_dir / "icons"),
             ("icons", base_dir / "icons"),
             ("projects", base_dir / "projects"),
             ("projects", base_dir / "projects"),
+            ("overlay-branding", base_dir / "overlay-branding"),
         ]
         ]
 
 
         for name, src_dir in dirs_to_backup:
         for name, src_dir in dirs_to_backup:
@@ -893,7 +1064,8 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
 
 
 @router.get("/backup")
 @router.get("/backup")
 async def create_backup(
 async def create_backup(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
 ):
 ):
     """Create a complete backup (database + all files) as a ZIP download."""
     """Create a complete backup (database + all files) as a ZIP download."""
@@ -1028,7 +1200,21 @@ def _read_backup_manifest(temp_path: Path) -> dict:
     return data if isinstance(data, dict) else {}
     return data if isinstance(data, dict) else {}
 
 
 
 
-async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
+def _restored_value_fixes(table_name: str) -> dict[str, tuple[tuple[str, ...], str]]:
+    """Columns of ``table_name`` whose backed-up value must be one of a set, with its fallback.
+
+    Older versions stored any project priority or status; one longer than the
+    column fails the whole import on PostgreSQL. The fallbacks are the ones
+    project import and the start-up repair use.
+    """
+    if table_name != "projects":
+        return {}
+    from backend.app.models.project import PROJECT_PRIORITIES, PROJECT_STATUSES
+
+    return {"priority": (PROJECT_PRIORITIES, "normal"), "status": (PROJECT_STATUSES, "active")}
+
+
+async def _import_sqlite_to_postgres(sqlite_path: Path):
     """Import data from a SQLite database file into the current PostgreSQL database.
     """Import data from a SQLite database file into the current PostgreSQL database.
 
 
     Used for cross-database restore (SQLite backup → PostgreSQL).
     Used for cross-database restore (SQLite backup → PostgreSQL).
@@ -1038,7 +1224,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
 
 
     from sqlalchemy import text
     from sqlalchemy import text
 
 
-    from backend.app.core.database import Base, _create_engine
+    from backend.app.core.database import AUTOINCREMENT_TABLES, Base, _create_engine, highest_referenced_id
 
 
     # Before anything is dropped. The route checks this too, earlier and with
     # Before anything is dropped. The route checks this too, earlier and with
     # the backup's version in the message; this call is what makes the guarantee
     # the backup's version in the message; this call is what makes the guarantee
@@ -1059,6 +1245,11 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
             "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'archive_fts%'"
             "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'archive_fts%'"
         )
         )
         src_tables = {row["name"] for row in cursor.fetchall()}
         src_tables = {row["name"] for row in cursor.fetchall()}
+        # Where SQLite's own counters stand: ids freed there are not given out
+        # again on PostgreSQL either (a backup from before they were kept has none).
+        src_counters: dict[str, int] = {}
+        if src.execute("SELECT 1 FROM sqlite_master WHERE name = 'sqlite_sequence'").fetchone():
+            src_counters = {row["name"]: row["seq"] for row in src.execute("SELECT name, seq FROM sqlite_sequence")}
 
 
         # Get Postgres tables from our ORM models
         # Get Postgres tables from our ORM models
         metadata = Base.metadata
         metadata = Base.metadata
@@ -1176,7 +1367,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
 
 
                 # Identify columns that need type conversion (SQLite stores booleans
                 # Identify columns that need type conversion (SQLite stores booleans
                 # as int and datetimes as str — asyncpg requires native Python types)
                 # as int and datetimes as str — asyncpg requires native Python types)
-                from datetime import datetime as dt
+                from datetime import datetime as dt, timezone
 
 
                 bool_columns = set()
                 bool_columns = set()
                 datetime_columns = set()
                 datetime_columns = set()
@@ -1208,7 +1399,10 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
                                     if sd is not None:
                                     if sd is not None:
                                         not_null_defaults[col.name] = sd
                                         not_null_defaults[col.name] = sd
 
 
-                now = dt.now()
+                # Naive UTC, as every stored timestamp is: the host's local
+                # time put backfilled rows hours off (DB-C4).
+                now = dt.now(timezone.utc).replace(tzinfo=None)
+                value_fixes = _restored_value_fixes(table_name)
 
 
                 def _convert_row(
                 def _convert_row(
                     row,
                     row,
@@ -1218,6 +1412,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
                     nn_defaults=not_null_defaults,
                     nn_defaults=not_null_defaults,
                     _now=now,
                     _now=now,
                     inject=injected,
                     inject=injected,
+                    fixes=value_fixes,
                 ):
                 ):
                     result = dict(inject)
                     result = dict(inject)
                     for c in cols:
                     for c in cols:
@@ -1233,19 +1428,30 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
                                 except ValueError:
                                 except ValueError:
                                     pass
                                     pass
                         result[c] = val
                         result[c] = val
+                    for c, (allowed, fallback) in fixes.items():
+                        if c in result and result[c] not in allowed:
+                            result[c] = fallback
                     return result
                     return result
 
 
                 batch = [_convert_row(row) for row in rows]
                 batch = [_convert_row(row) for row in rows]
                 await conn.execute(insert_sql, batch)
                 await conn.execute(insert_sql, batch)
                 logger.info("Imported %d rows into %s", len(batch), table_name)
                 logger.info("Imported %d rows into %s", len(batch), table_name)
 
 
-            # Reset sequences to max(id) + 1 for each table with an id column
+            # Reset sequences to max(id) + 1 for each table with an id column;
+            # for AUTOINCREMENT_TABLES past SQLite's counter and every id still
+            # referred to, so a deleted row's id is not given to a new one.
             for table_name in sorted_tables:
             for table_name in sorted_tables:
                 try:
                 try:
                     async with conn.begin_nested():
                     async with conn.begin_nested():
                         result = await conn.execute(text(f"SELECT MAX(id) FROM {table_name}"))  # noqa: S608  # nosec B608
                         result = await conn.execute(text(f"SELECT MAX(id) FROM {table_name}"))  # noqa: S608  # nosec B608
                         max_id = result.scalar()
                         max_id = result.scalar()
-                        if max_id is not None:
+                        if table_name in AUTOINCREMENT_TABLES:
+                            max_id = max(
+                                max_id or 0,
+                                src_counters.get(table_name) or 0,
+                                await highest_referenced_id(conn, table_name),
+                            )
+                        if max_id:
                             seq_name = f"{table_name}_id_seq"
                             seq_name = f"{table_name}_id_seq"
                             await conn.execute(text(f"SELECT setval('{seq_name}', {max_id})"))  # noqa: S608
                             await conn.execute(text(f"SELECT setval('{seq_name}', {max_id})"))  # noqa: S608
                 except Exception:
                 except Exception:
@@ -1297,7 +1503,8 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
 @router.post("/restore")
 @router.post("/restore")
 async def restore_backup(
 async def restore_backup(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
 ):
 ):
     """Restore from a complete backup ZIP.
     """Restore from a complete backup ZIP.
@@ -1378,11 +1585,16 @@ async def restore_backup(
         try:
         try:
             import asyncio
             import asyncio
 
 
-            # 3. Stop virtual printer if running (releases file locks)
+            # 3. Stop the virtual printers if running (releases file locks).
+            # stop_all, not the legacy configure(): that ignores its arguments
+            # and re-syncs from the database not yet replaced, so their FTP,
+            # MQTT and SSDP services ran on through the restore (vp-2). Like
+            # the services below, they come back with the restart a
+            # successful restore asks for.
             try:
             try:
                 if virtual_printer_manager.is_enabled:
                 if virtual_printer_manager.is_enabled:
-                    logger.info("Stopping virtual printer for restore...")
-                    await virtual_printer_manager.configure(enabled=False)
+                    logger.info("Stopping virtual printers for restore...")
+                    await virtual_printer_manager.stop_all()
                     await asyncio.sleep(1)
                     await asyncio.sleep(1)
             except Exception as e:
             except Exception as e:
                 logger.warning("Failed to stop virtual printer: %s", e)
                 logger.warning("Failed to stop virtual printer: %s", e)
@@ -1477,7 +1689,7 @@ async def restore_backup(
                 # checkpointed yet (seed_default_groups + init_db on first
                 # checkpointed yet (seed_default_groups + init_db on first
                 # start, plus whatever background heartbeats wrote during
                 # start, plus whatever background heartbeats wrote during
                 # the request window) sits in bambuddy.db-wal with valid
                 # the request window) sits in bambuddy.db-wal with valid
-                # checksums. The route handler's own `db: Depends(get_db)`
+                # checksums. The route handler's own `db: Depends(get_db, scope="function")`
                 # session also keeps a connection checked out across
                 # session also keeps a connection checked out across
                 # engine.dispose(), holding fds to the WAL inode. With
                 # engine.dispose(), holding fds to the WAL inode. With
                 # `shutil.copy2` SQLite finds the stale WAL on the next
                 # `shutil.copy2` SQLite finds the stale WAL on the next
@@ -1505,7 +1717,7 @@ async def restore_backup(
             else:
             else:
                 # Import SQLite backup into PostgreSQL
                 # Import SQLite backup into PostgreSQL
                 logger.info("Importing SQLite backup into PostgreSQL...")
                 logger.info("Importing SQLite backup into PostgreSQL...")
-                await _import_sqlite_to_postgres(backup_db, app_settings.database_url)
+                await _import_sqlite_to_postgres(backup_db)
 
 
             # 6. Replace data directories
             # 6. Replace data directories
             # For Docker compatibility: clear contents then copy (don't delete mount points)
             # For Docker compatibility: clear contents then copy (don't delete mount points)
@@ -1515,6 +1727,7 @@ async def restore_backup(
                 ("plate_calibration", app_settings.plate_calibration_dir),
                 ("plate_calibration", app_settings.plate_calibration_dir),
                 ("icons", base_dir / "icons"),
                 ("icons", base_dir / "icons"),
                 ("projects", base_dir / "projects"),
                 ("projects", base_dir / "projects"),
+                ("overlay-branding", base_dir / "overlay-branding"),
             ]
             ]
 
 
             skipped_dirs = []
             skipped_dirs = []
@@ -1564,6 +1777,35 @@ async def restore_backup(
             await reinitialize_database()
             await reinitialize_database()
             await init_db()
             await init_db()
 
 
+            # 9. Sign everyone out. The restored users and auth tokens come from
+            # another point in time, possibly written by older code, so no
+            # session issued before the restore should stay valid.
+            from backend.app.core import database as _db_mod
+
+            async with _db_mod.engine.begin() as conn:
+                await _db_mod.invalidate_all_sessions(conn)
+            # Open dashboards of signed-in users too: they are never signed in
+            # again. API-key and anonymous ones are not closed outright (nothing
+            # ended theirs), but the restored data decides what they may still see: the anonymous ones
+            # go if the backup turns authentication on, and the refresh closes
+            # those of a key the backup does not have (or has under another key).
+            from backend.app.core.auth import invalidate_auth_enabled_cache
+            from backend.app.core.websocket import ws_manager
+
+            # Read from the restored data: this request has just cached the
+            # setting it had before.
+            invalidate_auth_enabled_cache()
+            # Cloud presets were cached per user of the replaced user table.
+            from backend.app.api.routes.cloud import forget_all_cloud_presets
+
+            forget_all_cloud_presets()
+            async with _db_mod.async_session() as auth_db:
+                auth_on = await is_auth_enabled(auth_db)
+            if auth_on:
+                await ws_manager.close_signed_in()
+                await ws_manager.close_anonymous()
+            await ws_manager.refresh_printer_scopes()
+
             logger.info("Restore complete - restart required")
             logger.info("Restore complete - restart required")
             message = "Backup restored successfully. Please restart Bambuddy for changes to take effect."
             message = "Backup restored successfully. Please restart Bambuddy for changes to take effect."
             if skipped_dirs:
             if skipped_dirs:
@@ -1614,40 +1856,33 @@ async def get_virtual_printer_models(
     }
     }
 
 
 
 
+def _legacy_vp_status(status: dict, printer_scope: PrinterScope) -> dict:
+    """The first virtual printer's status, without what names its target for a limited caller."""
+    if printer_scope.is_unrestricted:
+        return status
+    return {key: value for key, value in status.items() if key not in ("name", "serial", "target_printer_ip", "proxy")}
+
+
 @router.get("/virtual-printer")
 @router.get("/virtual-printer")
 async def get_virtual_printer_settings(
 async def get_virtual_printer_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
-    """Get virtual printer settings and status."""
-    from backend.app.services.virtual_printer import (
-        DEFAULT_VIRTUAL_PRINTER_MODEL,
-        virtual_printer_manager,
-    )
+    """Get the settings this route still sets, and the virtual printers' status.
+
+    Every other virtual printer setting lives on its own row
+    (``GET /virtual-printers``).
+    """
+    from backend.app.services.virtual_printer import virtual_printer_manager
 
 
-    enabled = await get_setting(db, "virtual_printer_enabled")
-    access_code = await get_setting(db, "virtual_printer_access_code")
-    mode = await get_setting(db, "virtual_printer_mode")
-    model = await get_setting(db, "virtual_printer_model")
-    target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
-    remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
     tailscale_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
     tailscale_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
     archive_name_source = await get_setting(db, "virtual_printer_archive_name_source")
     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 {
     return {
-        "enabled": enabled == "true" if enabled else False,
-        "access_code_set": bool(access_code),
-        # 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 "",
         "tailscale_disabled": tailscale_disabled_raw == "true" if tailscale_disabled_raw else True,
         "tailscale_disabled": tailscale_disabled_raw == "true" if tailscale_disabled_raw else True,
         "archive_name_source": archive_name_source if archive_name_source in ("metadata", "filename") else "metadata",
         "archive_name_source": archive_name_source if archive_name_source in ("metadata", "filename") else "metadata",
-        "status": virtual_printer_manager.get_status(),
+        "status": _legacy_vp_status(virtual_printer_manager.get_status(), printer_scope),
     }
     }
 
 
 
 
@@ -1661,176 +1896,76 @@ async def update_virtual_printer_settings(
     remote_interface_ip: str = None,
     remote_interface_ip: str = None,
     tailscale_disabled: bool = None,
     tailscale_disabled: bool = None,
     archive_name_source: str = None,
     archive_name_source: str = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
-    """Update virtual printer settings and restart services if needed.
+    """Legacy single-printer settings: the archive name source and the first
+    virtual printer's Tailscale switch.
 
 
-    For proxy mode with SSDP proxy (dual-homed setup):
-    - remote_interface_ip: IP of interface on slicer's network (LAN B)
-    - Local interface is auto-detected based on target printer IP
+    Every other virtual printer setting lives on its own row, set with
+    ``PUT /virtual-printers/{id}``; the rest of this route's parameters are
+    refused (vp-3).
     """
     """
     from sqlalchemy import select
     from sqlalchemy import select
 
 
-    from backend.app.models.printer import Printer
-    from backend.app.services.virtual_printer import (
-        DEFAULT_VIRTUAL_PRINTER_MODEL,
-        VIRTUAL_PRINTER_MODELS,
-        virtual_printer_manager,
-    )
+    from backend.app.models.virtual_printer import VirtualPrinter as FirstVP
 
 
-    # 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 ""
-    # 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_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
     current_target_id = int(current_target_id_str) if current_target_id_str else None
-    current_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
-    current_ts_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
-    # Default True (opt-in) when the setting has never been saved — matches the model default.
-    current_ts_disabled = current_ts_disabled_raw == "true" if current_ts_disabled_raw else True
-
-    # Apply updates
-    new_enabled = enabled if enabled is not None else current_enabled
-    new_access_code = access_code if access_code is not None else current_access_code
-    new_mode = mode if mode is not None else current_mode
-    new_model = model if model is not None else current_model
-    new_target_id = target_printer_id if target_printer_id is not None else current_target_id
-    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. 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:
+    # One bridging to a printer the caller can't use is not theirs to change,
+    # and they can't aim it at such a printer.
+    # The first virtual printer is the one these settings reach.
+    first_vp = (await db.execute(select(FirstVP).order_by(FirstVP.position, FirstVP.id).limit(1))).scalar_one_or_none()
+    if not printer_scope.allows(current_target_id) or (
+        first_vp is not None and not printer_scope.allows(first_vp.target_printer_id)
+    ):
+        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    if target_printer_id is not None and not printer_scope.allows(target_printer_id):
+        return JSONResponse(status_code=400, content={"detail": f"Printer with ID {target_printer_id} not found"})
+    # Each virtual printer keeps these itself now; this route wrote them to
+    # settings keys nothing reads and answered 200 as if applied (vp-3). The
+    # stored legacy values are not checked either: an old install's stale
+    # mode or deleted target printer refused the settings this still serves.
+    unread = [
+        name
+        for name, value in (
+            ("enabled", enabled),
+            ("access_code", access_code),
+            ("mode", mode),
+            ("model", model),
+            ("target_printer_id", target_printer_id),
+            ("remote_interface_ip", remote_interface_ip),
+        )
+        if value is not None
+    ]
+    if unread:
         return JSONResponse(
         return JSONResponse(
             status_code=400,
             status_code=400,
             content={
             content={
-                "detail": f"Mode must be one of: {', '.join(VP_MODE_VALUES)}",
+                "detail": f"{', '.join(unread)} cannot be set here any more: set them for each virtual printer "
+                "with PUT /api/v1/virtual-printers/{id}"
             },
             },
         )
         )
-    new_mode = canonical_mode
 
 
-    # Validate archive_name_source
     if archive_name_source is not None and archive_name_source not in ("metadata", "filename"):
     if archive_name_source is not None and archive_name_source not in ("metadata", "filename"):
         return JSONResponse(
         return JSONResponse(
             status_code=400,
             status_code=400,
             content={"detail": "archive_name_source must be 'metadata' or 'filename'"},
             content={"detail": "archive_name_source must be 'metadata' or 'filename'"},
         )
         )
 
 
-    # Validate model
-    if model is not None and model not in VIRTUAL_PRINTER_MODELS:
-        return JSONResponse(
-            status_code=400,
-            content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
-        )
-
-    # Mode-specific validation and printer lookup
-    target_printer_ip = ""
-    target_printer_serial = ""
-    if new_mode == "proxy":
-        # Proxy mode requires target printer when enabling
-        if new_enabled and not new_target_id:
-            # If just switching to proxy mode (not explicitly enabling), auto-disable
-            if enabled is None:
-                new_enabled = False
-            else:
-                return JSONResponse(
-                    status_code=400,
-                    content={"detail": "Target printer is required for proxy mode"},
-                )
-
-        # Look up printer IP and serial if we have a target
-        if new_target_id:
-            result = await db.execute(select(Printer).where(Printer.id == new_target_id))
-            printer = result.scalar_one_or_none()
-            if not printer:
-                return JSONResponse(
-                    status_code=400,
-                    content={"detail": f"Printer with ID {new_target_id} not found"},
-                )
-            target_printer_ip = printer.ip_address
-            target_printer_serial = printer.serial_number
-        # Access code not required for proxy mode
-    else:
-        # Non-proxy modes require access code when enabling
-        if new_enabled and not new_access_code:
-            # If just switching modes (not explicitly enabling), auto-disable
-            if enabled is None:
-                new_enabled = False
-            else:
-                return JSONResponse(
-                    status_code=400,
-                    content={"detail": "Access code is required when enabling virtual printer"},
-                )
-
-        # Validate access code length (Bambu Studio requires exactly 8 characters)
-        if access_code is not None and access_code and len(access_code) != 8:
-            return JSONResponse(
-                status_code=400,
-                content={"detail": "Access code must be exactly 8 characters"},
-            )
-
-    # Save settings
-    await set_setting(db, "virtual_printer_enabled", "true" if new_enabled else "false")
-    if access_code is not None:
-        await set_setting(db, "virtual_printer_access_code", access_code)
-    await set_setting(db, "virtual_printer_mode", new_mode)
-    if model is not None:
-        await set_setting(db, "virtual_printer_model", model)
-    if target_printer_id is not None:
-        await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
-    if remote_interface_ip is not None:
-        await set_setting(db, "virtual_printer_remote_interface_ip", remote_interface_ip)
     if tailscale_disabled is not None:
     if tailscale_disabled is not None:
         await set_setting(db, "virtual_printer_tailscale_disabled", "true" if tailscale_disabled else "false")
         await set_setting(db, "virtual_printer_tailscale_disabled", "true" if tailscale_disabled else "false")
+        # The first virtual printer's card shows its Tailscale address by it.
+        if first_vp is not None:
+            first_vp.tailscale_disabled = tailscale_disabled
     if archive_name_source is not None:
     if archive_name_source is not None:
         await set_setting(db, "virtual_printer_archive_name_source", archive_name_source)
         await set_setting(db, "virtual_printer_archive_name_source", archive_name_source)
 
 
-    # Propagate tailscale_disabled to the first VirtualPrinter row so sync_from_db() picks it up
-    if tailscale_disabled is not None:
-        from backend.app.models.virtual_printer import VirtualPrinter as VPModel
-
-        vp_result = await db.execute(select(VPModel).order_by(VPModel.position).limit(1))
-        first_vp = vp_result.scalar_one_or_none()
-        if first_vp is not None:
-            first_vp.tailscale_disabled = new_ts_disabled
-
     await db.commit()
     await db.commit()
     db.expire_all()
     db.expire_all()
 
 
-    # Reconfigure virtual printer
-    try:
-        await virtual_printer_manager.configure(
-            enabled=new_enabled,
-            access_code=new_access_code,
-            mode=new_mode,
-            model=new_model,
-            target_printer_ip=target_printer_ip,
-            target_printer_serial=target_printer_serial,
-            remote_interface_ip=new_remote_iface,
-        )
-    except ValueError as e:
-        logger.warning("Virtual printer configuration validation error: %s", e)
-        return JSONResponse(
-            status_code=400,
-            content={"detail": "Invalid virtual printer configuration. Check the provided values."},
-        )
-    except Exception as e:
-        logger.error("Failed to configure virtual printer: %s", e, exc_info=True)
-        return JSONResponse(
-            status_code=500,
-            content={"detail": "Failed to configure virtual printer. Check server logs for details."},
-        )
-
-    return await get_virtual_printer_settings(db)
+    return await get_virtual_printer_settings(db, printer_scope=printer_scope)
 
 
 
 
 # =============================================================================
 # =============================================================================

+ 6 - 6
backend/app/api/routes/slice_jobs.py

@@ -7,7 +7,7 @@ status flips to `completed` or `failed`.
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 
 
-from backend.app.core.auth import require_ownership_permission
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.slice_dispatch import slice_dispatch
 from backend.app.services.slice_dispatch import slice_dispatch
@@ -22,7 +22,7 @@ async def get_slice_job(
     # the resulting library_file_id / archive_id. Gate on the library read
     # the resulting library_file_id / archive_id. Gate on the library read
     # permission family (own/all) and then scope per-row: a READ_OWN caller may
     # permission family (own/all) and then scope per-row: a READ_OWN caller may
     # only poll jobs they started (SliceJob.owner_id).
     # only poll jobs they started (SliceJob.owner_id).
-    auth: tuple[User | None, bool] = Depends(
+    auth: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
             Permission.LIBRARY_READ_OWN,
@@ -33,10 +33,10 @@ async def get_slice_job(
     job = slice_dispatch.get(job_id)
     job = slice_dispatch.get(job_id)
     if job is None:
     if job is None:
         raise HTTPException(status_code=404, detail="Slice job not found or expired")
         raise HTTPException(status_code=404, detail="Slice job not found or expired")
-    # Per-row scoping. Jobs started by API-key / auth-disabled callers have
-    # owner_id=None and are visible only to READ_ALL pollers (fail-closed,
-    # mirrors the library ownerless-row rule). 404 not 403 to avoid job-id
-    # enumeration.
+    # Per-row scoping. Jobs recorded without an owner (auth off, a key without
+    # an owner, an archive slice through a key) are visible only to READ_ALL
+    # pollers (fail-closed, mirrors the library ownerless-row rule). 404 not
+    # 403 to avoid job-id enumeration.
     if not can_read_all and (user is None or job.owner_id != user.id):
     if not can_read_all and (user is None or job.owner_id != user.id):
         raise HTTPException(status_code=404, detail="Slice job not found or expired")
         raise HTTPException(status_code=404, detail="Slice job not found or expired")
     body: dict = {
     body: dict = {

+ 7 - 5
backend/app/api/routes/slicer_pipelines.py

@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import ensure_printers_exist
 from backend.app.models.slicer_pipeline import SlicerPipeline
 from backend.app.models.slicer_pipeline import SlicerPipeline
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.slicer import PresetRef
 from backend.app.schemas.slicer import PresetRef
@@ -65,7 +66,7 @@ def _to_response(row: SlicerPipeline) -> SlicerPipelineResponse:
 @router.get("/", response_model=SlicerPipelineListResponse)
 @router.get("/", response_model=SlicerPipelineListResponse)
 async def list_pipelines(
 async def list_pipelines(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """List all pipelines, newest first. Soft-deleted rows are hidden."""
     """List all pipelines, newest first. Soft-deleted rows are hidden."""
     result = await db.execute(
     result = await db.execute(
@@ -79,7 +80,7 @@ async def list_pipelines(
 async def create_pipeline(
 async def create_pipeline(
     data: SlicerPipelineCreate,
     data: SlicerPipelineCreate,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Create a new pipeline."""
     """Create a new pipeline."""
     row = SlicerPipeline(
     row = SlicerPipeline(
@@ -103,7 +104,7 @@ async def create_pipeline(
 async def get_pipeline(
 async def get_pipeline(
     pipeline_id: int,
     pipeline_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Read one pipeline by id."""
     """Read one pipeline by id."""
     result = await db.execute(
     result = await db.execute(
@@ -123,7 +124,7 @@ async def update_pipeline(
     pipeline_id: int,
     pipeline_id: int,
     data: SlicerPipelineUpdate,
     data: SlicerPipelineUpdate,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Update a pipeline. Only fields present in the payload are written."""
     """Update a pipeline. Only fields present in the payload are written."""
     result = await db.execute(
     result = await db.execute(
@@ -165,6 +166,7 @@ async def update_pipeline(
         if data.target_printer_id == 0:
         if data.target_printer_id == 0:
             row.target_printer_id = None
             row.target_printer_id = None
         else:
         else:
+            await ensure_printers_exist(db, [data.target_printer_id])
             row.target_printer_id = data.target_printer_id
             row.target_printer_id = data.target_printer_id
     # PR C — class targeting + fanout strategy. Empty string from the frontend
     # PR C — class targeting + fanout strategy. Empty string from the frontend
     # also clears the class (radio toggled away).
     # also clears the class (radio toggled away).
@@ -182,7 +184,7 @@ async def update_pipeline(
 async def delete_pipeline(
 async def delete_pipeline(
     pipeline_id: int,
     pipeline_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Soft-delete a pipeline (sets is_deleted=True so PR B+ run history can
     """Soft-delete a pipeline (sets is_deleted=True so PR B+ run history can
     still resolve pipeline metadata)."""
     still resolve pipeline metadata)."""

+ 303 - 40
backend/app/api/routes/slicer_presets.py

@@ -14,6 +14,7 @@ from __future__ import annotations
 import hashlib
 import hashlib
 import json
 import json
 import logging
 import logging
+import re
 import time
 import time
 
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
@@ -26,14 +27,28 @@ from backend.app.api.routes.orca_cloud import (
     _build_authenticated_service as _build_orca_service,
     _build_authenticated_service as _build_orca_service,
     _load_credentials as _load_orca_credentials,
     _load_credentials as _load_orca_credentials,
 )
 )
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
+from backend.app.core.auth import (
+    ApiKeyActor,
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    is_auth_enabled,
+    require_ownership_permission,
+)
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.local_preset import LocalPreset
+from backend.app.models.printer import Printer
+from backend.app.models.slot_preset import SlotPresetMapping
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.slicer import PresetRef
 from backend.app.schemas.slicer import PresetRef
 from backend.app.schemas.slicer_presets import (
 from backend.app.schemas.slicer_presets import (
+    LoadedSpoolPreset,
+    LoadedSpoolPrinter,
+    LoadedSpoolsResponse,
+    LoadedSpoolTray,
+    LoadedSpoolUnit,
     UnifiedPreset,
     UnifiedPreset,
     UnifiedPresetsBySlot,
     UnifiedPresetsBySlot,
     UnifiedPresetsResponse,
     UnifiedPresetsResponse,
@@ -49,12 +64,18 @@ from backend.app.services.orca_cloud import (
     OrcaCloudError,
     OrcaCloudError,
 )
 )
 from backend.app.services.preset_resolver import resolve_preset_ref
 from backend.app.services.preset_resolver import resolve_preset_ref
+from backend.app.services.printer_manager import printer_manager
 from backend.app.services.slicer_api import (
 from backend.app.services.slicer_api import (
     SlicerApiError,
     SlicerApiError,
     SlicerApiService,
     SlicerApiService,
     SlicerApiUnavailableError,
     SlicerApiUnavailableError,
 )
 )
-from backend.app.utils.printer_models import PRINTER_MODEL_MAP
+from backend.app.utils.printer_models import (
+    PRINTER_MODEL_MAP,
+    normalize_printer_model,
+    normalize_printer_model_id,
+)
+from backend.app.utils.slicer_strings import first_slicer_string, slicer_strings
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -119,6 +140,10 @@ async def _fetch_cloud_presets(
     """
     """
     if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
     if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
         return _empty_slots(), "not_authenticated"
         return _empty_slots(), "not_authenticated"
+    # The sign-in stored without a user is the auth-off install's, not one for
+    # a caller who has none while auth is on.
+    if user is None and await is_auth_enabled(db):
+        return _empty_slots(), "not_authenticated"
 
 
     token, _email, region = await get_stored_token(db, user)
     token, _email, region = await get_stored_token(db, user)
     if not token:
     if not token:
@@ -158,13 +183,14 @@ async def _fetch_cloud_presets(
             # that share names with private ones get deduped out within the cloud
             # that share names with private ones get deduped out within the cloud
             # tier itself.
             # tier itself.
             seen_names: set[str] = set()
             seen_names: set[str] = set()
-            for entry in type_data.get("private", []) + type_data.get("public", []):
-                name = entry.get("name")
-                setting_id = entry.get("setting_id") or entry.get("id")
-                if not name or not setting_id or name in seen_names:
-                    continue
-                seen_names.add(name)
-                slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud"))
+            for stock, entries in ((False, type_data.get("private", [])), (True, type_data.get("public", []))):
+                for entry in entries:
+                    name = entry.get("name")
+                    setting_id = entry.get("setting_id") or entry.get("id")
+                    if not name or not setting_id or name in seen_names:
+                        continue
+                    seen_names.add(name)
+                    slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud", stock=stock))
 
 
         # Cloud filament presets carry no metadata in this response on
         # Cloud filament presets carry no metadata in this response on
         # purpose: the per-preset detail endpoint
         # purpose: the per-preset detail endpoint
@@ -199,6 +225,8 @@ async def _fetch_orca_cloud_presets(
     """
     """
     if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
     if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
         return _empty_slots(), "not_authenticated"
         return _empty_slots(), "not_authenticated"
+    if user is None and await is_auth_enabled(db):
+        return _empty_slots(), "not_authenticated"
 
 
     creds = await _load_orca_credentials(db, user)
     creds = await _load_orca_credentials(db, user)
     if not creds.token:
     if not creds.token:
@@ -251,18 +279,10 @@ async def _fetch_orca_cloud_presets(
             filament_colour: str | None = None
             filament_colour: str | None = None
             if slot == "filament":
             if slot == "filament":
                 # Bambu/Orca filament profiles store these as single-element
                 # Bambu/Orca filament profiles store these as single-element
-                # arrays (the historical multi-extruder shape). Extract the
-                # first non-empty element for both.
-                ft = content.get("filament_type")
-                if isinstance(ft, list) and ft and isinstance(ft[0], str):
-                    filament_type = ft[0]
-                elif isinstance(ft, str):
-                    filament_type = ft
-                fc = content.get("default_filament_colour")
-                if isinstance(fc, list) and fc and isinstance(fc[0], str):
-                    filament_colour = fc[0]
-                elif isinstance(fc, str):
-                    filament_colour = fc
+                # lists (the historical multi-extruder shape) — a JSON array or
+                # an INI-style string. Take the first non-empty element.
+                filament_type = first_slicer_string(content.get("filament_type"))
+                filament_colour = first_slicer_string(content.get("default_filament_colour"))
             preset = UnifiedPreset(
             preset = UnifiedPreset(
                 id=str(preset_id),
                 id=str(preset_id),
                 name=str(name),
                 name=str(name),
@@ -279,6 +299,10 @@ async def _fetch_orca_cloud_presets(
                 # which the picker treats as usable and auto-picks for a
                 # which the picker treats as usable and auto-picks for a
                 # printer the profile was never built for.
                 # printer the profile was never built for.
                 preset.compatible_printers = _content_compatible_printers(content)
                 preset.compatible_printers = _content_compatible_printers(content)
+            if slot in ("printer", "filament"):
+                # A filament's parent gives it the material its own content
+                # leaves out (#3403); see _enrich_cloud_metadata.
+                preset.inherits = _content_inherits(content)
             slots[slot].append(preset)
             slots[slot].append(preset)
         _orca_cloud_cache[cache_key] = (now, slots)
         _orca_cloud_cache[cache_key] = (now, slots)
         return slots, "ok"
         return slots, "ok"
@@ -305,10 +329,21 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
             # process / filament dropdowns by the selected printer without
             # process / filament dropdowns by the selected printer without
             # falling back to the @BBL name matcher.
             # falling back to the @BBL name matcher.
             preset.compatible_printers = _parse_compatible_printers(p.compatible_printers)
             preset.compatible_printers = _parse_compatible_printers(p.compatible_printers)
+        elif slot == "printer":
+            # The preset it was saved from (#3250); see UnifiedPreset.inherits.
+            preset.inherits = (p.inherits or "").strip() or None
         slots[slot].append(preset)
         slots[slot].append(preset)
     return slots
     return slots
 
 
 
 
+def _content_inherits(content: dict) -> str | None:
+    """The preset a profile was saved from, out of its content dict (#3250)."""
+    raw = content.get("inherits") if isinstance(content, dict) else None
+    if not isinstance(raw, str):
+        return None
+    return raw.strip() or None
+
+
 def _content_compatible_printers(content: dict) -> list[str] | None:
 def _content_compatible_printers(content: dict) -> list[str] | None:
     """Pull ``compatible_printers`` out of a profile content dict.
     """Pull ``compatible_printers`` out of a profile content dict.
 
 
@@ -316,19 +351,13 @@ def _content_compatible_printers(content: dict) -> list[str] | None:
     ``content``, and an entry of the sidecar's bundled listing.
     ``content``, and an entry of the sidecar's bundled listing.
 
 
     Orca profiles carry it as a list of printer-preset names (the same shape
     Orca profiles carry it as a list of printer-preset names (the same shape
-    ``orca_profiles.py`` stores on import); a single-printer profile may store
-    a bare string. Returns ``None`` for missing / empty / malformed values so
-    the caller leaves the field unset and the SliceModal falls back to the
-    name-based matcher, rather than treating "no data" as "compatible with
-    nothing".
+    ``orca_profiles.py`` stores on import) or as one INI-style string — see
+    :func:`slicer_strings`. Returns ``None`` for missing / empty / malformed
+    values so the caller leaves the field unset and the SliceModal falls back
+    to the name-based matcher, rather than treating "no data" as "compatible
+    with nothing".
     """
     """
-    raw = content.get("compatible_printers")
-    if isinstance(raw, str):
-        raw = [raw]
-    if not isinstance(raw, list):
-        return None
-    names = [s.strip() for s in raw if isinstance(s, str) and s.strip()]
-    return names or None
+    return slicer_strings(content.get("compatible_printers")) or None
 
 
 
 
 def _parse_compatible_printers(raw: str | None) -> list[str] | None:
 def _parse_compatible_printers(raw: str | None) -> list[str] | None:
@@ -343,8 +372,7 @@ def _parse_compatible_printers(raw: str | None) -> list[str] | None:
         return None
         return None
     if not isinstance(data, list):
     if not isinstance(data, list):
         return None
         return None
-    names = [s for s in data if isinstance(s, str) and s.strip()]
-    return names or None
+    return slicer_strings(data) or None
 
 
 
 
 def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]:
 def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]:
@@ -490,6 +518,19 @@ def _enrich_cloud_metadata(
     this function exists post-#1712 — without the enrich the Bambu Cloud
     this function exists post-#1712 — without the enrich the Bambu Cloud
     tier can't score in ``pickFilamentForSlot``.
     tier can't score in ``pickFilamentForSlot``.
 
 
+    A filament with no same-named twin still gets a material when one can be
+    told for sure (#3403), for both cloud tiers:
+      - an Orca Cloud profile saved with only the keys the user changed takes
+        it from the profile it inherits from;
+      - a stock ``<filament> @BBL <printer>`` profile takes it from the bundled
+        profiles of the same filament for other printers, when they all agree.
+        With OrcaSlicer preferred the bundle has no H2C profiles at all, so
+        without this every Bambu Cloud "@BBL H2C" filament is untyped and a
+        PETG-CF slot can pick Generic PETG. Only ``@BBL`` names count: Orca's
+        "Generic PETG-CF @System" states PETG.
+    Only ever fills a gap, and only the material: the colour of another
+    printer's profile is not this one's.
+
     Compatibility merge (#2628): the same name bridge carries
     Compatibility merge (#2628): the same name bridge carries
     ``compatible_printers`` onto any process / filament entry that lacks it.
     ``compatible_printers`` onto any process / filament entry that lacks it.
     Bambu Cloud never ships the list, so a profile whose NAME carries no
     Bambu Cloud never ships the list, so a profile whose NAME carries no
@@ -521,6 +562,42 @@ def _enrich_cloud_metadata(
             if p.filament_colour is None and c is not None:
             if p.filament_colour is None and c is not None:
                 p.filament_colour = c
                 p.filament_colour = c
 
 
+    # Material of the bundled "<filament> @BBL <printer>" profiles, per
+    # filament, kept only where every printer's profile states the same one.
+    bundled_types: dict[str, set[str]] = {}
+    for p in standard["filament"]:
+        base = _bbl_filament_base(p.name)
+        if base and p.filament_type:
+            bundled_types.setdefault(base, set()).add(p.filament_type)
+    type_by_base = {base: next(iter(types)) for base, types in bundled_types.items() if len(types) == 1}
+    # The parents an Orca Cloud profile can name. Of the bundle, only "@BBL"
+    # profiles: Orca's own "Generic PETG-CF @System" states PETG, and a
+    # user's PETG-CF saved from it would then be left out of PETG-CF slots.
+    type_by_name = {
+        p.name: p.filament_type
+        for tier in (local, orca_cloud, standard)
+        for p in tier["filament"]
+        if p.filament_type and (tier is not standard or _bbl_filament_base(p.name))
+    }
+    orca_parent = {p.name: p.inherits for p in orca_cloud["filament"] if p.inherits}
+
+    def _material_for(name: str) -> str | None:
+        seen: set[str] = set()
+        while name and name not in seen:
+            seen.add(name)
+            if name in type_by_name:
+                return type_by_name[name]
+            base = _bbl_filament_base(name)
+            if base in type_by_base:
+                return type_by_base[base]
+            name = orca_parent.get(name, "")
+        return None
+
+    for tier in (orca_cloud, cloud):
+        for p in tier["filament"]:
+            if p.filament_type is None:
+                p.filament_type = _material_for(p.name)
+
     # Compatibility bridge (#2628). Runs over both slots that carry the
     # Compatibility bridge (#2628). Runs over both slots that carry the
     # list, and in both directions between the cloud tiers — whichever copy
     # list, and in both directions between the cloud tiers — whichever copy
     # of a profile knows its printers teaches the ones that don't.
     # of a profile knows its printers teaches the ones that don't.
@@ -542,6 +619,22 @@ def _enrich_cloud_metadata(
     return orca_cloud, cloud, local, standard
     return orca_cloud, cloud, local, standard
 
 
 
 
+# What follows "@BBL " in a stock filament name: the printer, and maybe the
+# nozzle ("H2C", "H2C 0.4 nozzle"). True of every name in both bundles.
+_BBL_PRINTER_TAIL = re.compile(r"[A-Z0-9]+( \d\.\d nozzle)?")
+
+
+def _bbl_filament_base(name: str) -> str | None:
+    """The filament part of a stock ``<filament> @BBL <printer>`` name
+    ("Generic PETG-CF @BBL H2C 0.4 nozzle" -> "Generic PETG-CF"), else None.
+    A name with more after the printer is a user's copy ("... @BBL H2C -
+    Copy"), whose material may have been changed: None."""
+    base, sep, printer = name.partition(" @BBL ")
+    if not sep or not _BBL_PRINTER_TAIL.fullmatch(printer):
+        return None
+    return base.strip() or None
+
+
 @router.get("/printer-models")
 @router.get("/printer-models")
 def list_printer_models() -> dict[str, str]:
 def list_printer_models() -> dict[str, str]:
     """Canonical Bambu printer-model registry, surfaced for the SliceModal.
     """Canonical Bambu printer-model registry, surfaced for the SliceModal.
@@ -564,8 +657,9 @@ async def get_preset_values(
     source: str = Query(..., description="Preset tier: 'local', 'cloud', 'orca_cloud' or 'standard'."),
     source: str = Query(..., description="Preset tier: 'local', 'cloud', 'orca_cloud' or 'standard'."),
     id: str = Query(..., description="Preset id within that tier."),
     id: str = Query(..., description="Preset id within that tier."),
     slot: str = Query("process", description="Preset slot. Only 'process' is supported today."),
     slot: str = Query("process", description="Preset slot. Only 'process' is supported today."),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+    api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ) -> dict:
 ) -> dict:
     """Effective values of a preset, with its ``inherits:`` chain flattened.
     """Effective values of a preset, with its ``inherits:`` chain flattened.
 
 
@@ -597,7 +691,9 @@ async def get_preset_values(
         return {"resolved": False, "values": {}, "reason": reason}
         return {"resolved": False, "values": {}, "reason": reason}
 
 
     try:
     try:
-        profile_json = await resolve_preset_ref(db, current_user, ref, slot)
+        # A cloud preset resolves as the key's owner for a key with Allow
+        # Cloud Access, like the listing below.
+        profile_json = await resolve_preset_ref(db, current_user or api_key_cloud_owner, ref, slot)
     except HTTPException:
     except HTTPException:
         # A preset the caller can't resolve is not a reason to break the panel;
         # A preset the caller can't resolve is not a reason to break the panel;
         # the slice itself will report it properly if they go ahead.
         # the slice itself will report it properly if they go ahead.
@@ -623,7 +719,7 @@ async def get_preset_values(
 
 
 @router.get("/presets", response_model=UnifiedPresetsResponse)
 @router.get("/presets", response_model=UnifiedPresetsResponse)
 async def list_unified_presets(
 async def list_unified_presets(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     refresh: bool = Query(
     refresh: bool = Query(
@@ -657,6 +753,8 @@ async def list_unified_presets(
     standard = await _fetch_bundled_presets(db, refresh=refresh)
     standard = await _fetch_bundled_presets(db, refresh=refresh)
 
 
     orca_cloud, cloud, local, standard = _enrich_cloud_metadata(orca_cloud, cloud, local, standard)
     orca_cloud, cloud, local, standard = _enrich_cloud_metadata(orca_cloud, cloud, local, standard)
+    stock_aliases = _stock_aliases(cloud, standard)
+    standard = _drop_aliased(standard, stock_aliases)
 
 
     return UnifiedPresetsResponse(
     return UnifiedPresetsResponse(
         orca_cloud=UnifiedPresetsBySlot(**orca_cloud),
         orca_cloud=UnifiedPresetsBySlot(**orca_cloud),
@@ -665,14 +763,175 @@ async def list_unified_presets(
         standard=UnifiedPresetsBySlot(**standard),
         standard=UnifiedPresetsBySlot(**standard),
         cloud_status=cloud_status,
         cloud_status=cloud_status,
         orca_cloud_status=orca_cloud_status,
         orca_cloud_status=orca_cloud_status,
+        stock_aliases=stock_aliases,
     )
     )
 
 
 
 
+def _stock_aliases(
+    cloud: dict[str, list[UnifiedPreset]],
+    standard: dict[str, list[UnifiedPreset]],
+) -> dict[str, PresetRef]:
+    """Map each Standard preset with a Bambu Cloud stock twin to that twin (#3386).
+
+    Bambu's stock cloud presets are the bundled ones again, so a name is
+    listed once, under Bambu Cloud: Standard is the preferred sidecar's own
+    bundle (OrcaSlicer's re-authored ``@BBL`` profiles when Orca is
+    preferred), and the cloud copy is the one signed-in users already slice
+    with by default. Signed out there is no cloud list, so nothing is
+    aliased. The keys are ``"standard:<id>"``, so a pipeline or AMS slot
+    saved with a hidden Standard copy resolves to the cloud one.
+    """
+    aliases: dict[str, PresetRef] = {}
+    for slot, presets in cloud.items():
+        twins = {p.name: p for p in presets if p.stock}
+        for p in standard.get(slot, []):
+            twin = twins.get(p.name)
+            if twin is not None:
+                aliases[f"standard:{p.id}"] = PresetRef(source="cloud", id=twin.id)
+    return aliases
+
+
+def _drop_aliased(
+    presets: dict[str, list[UnifiedPreset]], aliases: dict[str, PresetRef]
+) -> dict[str, list[UnifiedPreset]]:
+    """``presets`` without those :func:`_stock_aliases` maps to a twin.
+
+    Runs after :func:`_enrich_cloud_metadata`, so the #2628 bridge has
+    already seen both copies.
+    """
+    return {
+        slot: [p for p in slot_presets if f"{p.source}:{p.id}" not in aliases] for slot, slot_presets in presets.items()
+    }
+
+
+async def load_stock_aliases(db: AsyncSession, user: User | None) -> dict[str, PresetRef]:
+    """The listing's ``stock_aliases`` for ``user``, from the cached tiers.
+
+    For callers that slice from stored refs without the Slice dialog, so a
+    pipeline slices with the profile the dialog shows for it. Both fetches
+    degrade to empty rather than raise, which leaves every ref as stored.
+    """
+    cloud, _status = await _fetch_cloud_presets(db, user)
+    standard = await _fetch_bundled_presets(db)
+    return _stock_aliases(cloud, standard)
+
+
+def listed_ref(aliases: dict[str, PresetRef], ref: PresetRef) -> PresetRef:
+    """The ref the listing shows for ``ref``: its cloud twin if hidden, else itself."""
+    return aliases.get(f"{ref.source}:{ref.id}", ref)
+
+
+def _loaded_spool_tray(raw: dict, ams_id: int, tray_id: int, saved: SlotPresetMapping | None) -> LoadedSpoolTray:
+    exists = raw.get("exists")
+    state = raw.get("state")
+    return LoadedSpoolTray(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_type=raw.get("tray_type") or None,
+        tray_sub_brands=raw.get("tray_sub_brands") or None,
+        tray_color=raw.get("tray_color") or None,
+        tray_info_idx=raw.get("tray_info_idx") or None,
+        exists=exists if isinstance(exists, bool) else None,
+        state=state if isinstance(state, int) else None,
+        saved_preset=(
+            LoadedSpoolPreset(
+                preset_id=saved.preset_id,
+                preset_name=saved.preset_name,
+                preset_source=saved.preset_source,
+                tray_info_idx=saved.tray_info_idx,
+            )
+            if saved is not None
+            else None
+        ),
+    )
+
+
+@router.get("/loaded-spools", response_model=LoadedSpoolsResponse)
+async def list_loaded_spools(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD, Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+) -> LoadedSpoolsResponse:
+    """What is loaded in each connected printer, for the SliceModal's filters (#3172).
+
+    One call rather than a status request per printer: the dialog needs every
+    connected printer at once, and a farm has a hundred of them. Gated on the
+    slice permission plus printers:read, because it shows what the printer
+    status does, and limited to the caller's printers (#1727).
+
+    Only printers with a live connection are listed: an offline printer can't
+    say what it has loaded, and its last-known trays may be long gone.
+    """
+    query = select(Printer).where(Printer.is_active == True).order_by(Printer.name)  # noqa: E712
+    scope_clause = printer_scope.where_strict(Printer.id)
+    if scope_clause is not None:
+        query = query.where(scope_clause)
+    printers = (await db.execute(query)).scalars().all()
+
+    connected: list[tuple[Printer, dict]] = []
+    for printer in printers:
+        state = printer_manager.get_status(printer.id)
+        if state is None or not state.connected:
+            continue
+        connected.append((printer, state.raw_data or {}))
+    if not connected:
+        return LoadedSpoolsResponse()
+
+    mappings = (
+        (
+            await db.execute(
+                select(SlotPresetMapping).where(SlotPresetMapping.printer_id.in_([p.id for p, _ in connected]))
+            )
+        )
+        .scalars()
+        .all()
+    )
+    saved = {(m.printer_id, m.ams_id, m.tray_id): m for m in mappings}
+
+    out: list[LoadedSpoolPrinter] = []
+    for printer, raw_data in connected:
+        units: list[LoadedSpoolUnit] = []
+        raw_ams = raw_data.get("ams")
+        for ams_data in raw_ams if isinstance(raw_ams, list) else []:
+            if not isinstance(ams_data, dict):
+                continue
+            ams_id = int(ams_data.get("id", 0))
+            raw_trays = [t for t in ams_data.get("tray") or [] if isinstance(t, dict)]
+            trays = [
+                _loaded_spool_tray(t, ams_id, int(t.get("id", 0)), saved.get((printer.id, ams_id, int(t.get("id", 0)))))
+                for t in raw_trays
+            ]
+            # Same rule as the printer status: an AMS-HT is a unit id 128-135.
+            units.append(LoadedSpoolUnit(id=ams_id, is_ams_ht=128 <= ams_id <= 135, trays=trays))
+
+        external: list[LoadedSpoolTray] = []
+        holders = [vt for vt in raw_data.get("vt_tray") or [] if isinstance(vt, dict)]
+        for vt in holders:
+            if not vt.get("tray_type"):
+                continue
+            # The holders are trays 254 / 255; slot presets key them as AMS 255,
+            # tray 0 / 1, the way the printer card saves them.
+            tray_id = int(vt.get("id", 254)) - 254
+            external.append(_loaded_spool_tray(vt, 255, tray_id, saved.get((printer.id, 255, tray_id))))
+
+        out.append(
+            LoadedSpoolPrinter(
+                id=printer.id,
+                name=printer.name,
+                model=normalize_printer_model_id(printer.model) or normalize_printer_model(printer.model),
+                ams=units,
+                external=external,
+                external_holders=len(holders),
+            )
+        )
+    return LoadedSpoolsResponse(printers=out)
+
+
 @router.get("/preview-progress/{request_id}")
 @router.get("/preview-progress/{request_id}")
 async def get_preview_slice_progress(
 async def get_preview_slice_progress(
     request_id: str,
     request_id: str,
-    db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
             Permission.LIBRARY_READ_OWN,
@@ -702,6 +961,10 @@ async def get_preview_slice_progress(
     try:
     try:
         async with httpx.AsyncClient(timeout=5.0) as client:
         async with httpx.AsyncClient(timeout=5.0) as client:
             response = await client.get(url)
             response = await client.get(url)
+    except httpx.TimeoutException:
+        # Busy, not gone: a heavy slice can hold the progress answer past 5 s
+        # (TO-C14). Still 503, so the poller keeps trying.
+        raise HTTPException(status_code=503, detail="Slicer sidecar did not answer in time") from None
     except httpx.RequestError:
     except httpx.RequestError:
         # Sidecar unreachable: surface as 503 instead of 500 so the
         # Sidecar unreachable: surface as 503 instead of 500 so the
         # frontend's poller can keep trying without flagging a hard error.
         # frontend's poller can keep trying without flagging a hard error.

+ 245 - 190
backend/app/api/routes/smart_plugs.py

@@ -1,17 +1,23 @@
 """API routes for smart plug management."""
 """API routes for smart plug management."""
 
 
 import logging
 import logging
-from datetime import timedelta
 
 
 from fastapi import APIRouter, Body, Depends, HTTPException
 from fastapi import APIRouter, Body, Depends, HTTPException
-from pydantic import BaseModel
+from fastapi.exceptions import RequestValidationError
+from pydantic import BaseModel, ValidationError
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.api.routes.settings import get_setting
 from backend.app.api.routes.settings import get_setting
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+    probe_permissions_if_auth_enabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.tasks import spawn_background_task
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
@@ -23,10 +29,12 @@ from backend.app.schemas.smart_plug import (
     HATestConnectionResponse,
     HATestConnectionResponse,
     RESTTestConnectionRequest,
     RESTTestConnectionRequest,
     RESTTestConnectionResponse,
     RESTTestConnectionResponse,
+    SmartPlugBase,
     SmartPlugControl,
     SmartPlugControl,
     SmartPlugCreate,
     SmartPlugCreate,
     SmartPlugEnergy,
     SmartPlugEnergy,
     SmartPlugResponse,
     SmartPlugResponse,
+    SmartPlugResponseWithSecret,
     SmartPlugStatus,
     SmartPlugStatus,
     SmartPlugTestConnection,
     SmartPlugTestConnection,
     SmartPlugUpdate,
     SmartPlugUpdate,
@@ -35,37 +43,86 @@ from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
 from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
-from backend.app.services.notification_service import notification_service
 from backend.app.services.plug_energy_history import fill_derived_energy
 from backend.app.services.plug_energy_history import fill_derived_energy
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
+from backend.app.services.secret_fields import (
+    REST_PLUG_URLS,
+    SECRET_PLACEHOLDER,
+    SMART_PLUG_SECRETS,
+    SecretConfigError,
+    merge_partial,
+    reject_placeholder,
+    same_value,
+)
+from backend.app.services.smart_plug_manager import publish_plug_state, run_linked_scripts, smart_plug_manager
 from backend.app.services.tasmota import tasmota_service
 from backend.app.services.tasmota import tasmota_service
-from backend.app.utils.local_time import to_naive_utc, utcnow_naive
+from backend.app.utils.local_time import utcnow_naive
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
 router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
 
 
+# Whether the caller may edit plugs, and so gets the REST URLs and bodies.
+CallerEditsPlugs = Depends(probe_permissions_if_auth_enabled(Permission.SMART_PLUGS_UPDATE))
+
 
 
-@router.get("/", response_model=list[SmartPlugResponse])
+def _serialize_plug(plug: SmartPlug, *, can_edit: bool) -> SmartPlugResponse:
+    """The response shape that matches the caller's authority.
+
+    Routes returning it declare no response_model: validating the reader shape
+    again would run the REST URL checks on the fields it leaves out.
+    """
+    if can_edit:
+        return SmartPlugResponseWithSecret.model_validate(plug)
+    return SmartPlugResponse.model_validate(plug)
+
+
+@router.get("/", response_model=None)
 async def list_smart_plugs(
 async def list_smart_plugs(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    can_edit: bool = CallerEditsPlugs,
 ):
 ):
-    """List all smart plugs."""
-    result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
-    return list(result.scalars().all())
+    """List all smart plugs, minus those powering printers the caller can't see (#1727)."""
+    query = select(SmartPlug).order_by(SmartPlug.name)
+    if (clause := printer_scope.where(SmartPlug.printer_id)) is not None:
+        query = query.where(clause)
+    result = await db.execute(query)
+    return [_serialize_plug(plug, can_edit=can_edit) for plug in result.scalars().all()]
+
+
+async def _printer_has_other_tasmota_plug(
+    db: AsyncSession, printer_id: int, exclude_plug_id: int | None = None
+) -> bool:
+    """Whether the printer already has a Tasmota plug (other than this one).
+
+    One per printer: a Tasmota plug is the printer's power device, while HA
+    entities may be several (different automations). Tolerates printers that
+    already hold more than one from before every path checked this.
+    """
+    query = select(SmartPlug.id).where(SmartPlug.printer_id == printer_id, SmartPlug.plug_type == "tasmota")
+    if exclude_plug_id is not None:
+        query = query.where(SmartPlug.id != exclude_plug_id)
+    return (await db.execute(query.limit(1))).first() is not None
 
 
 
 
-@router.post("/", response_model=SmartPlugResponse)
+@router.post("/", response_model=SmartPlugResponseWithSecret)
 async def create_smart_plug(
 async def create_smart_plug(
     data: SmartPlugCreate,
     data: SmartPlugCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Create a new smart plug."""
     """Create a new smart plug."""
-    # Validate printer_id if provided
-    if data.printer_id:
+    try:
+        reject_placeholder({field: getattr(data, field) for field in SMART_PLUG_SECRETS})
+    except SecretConfigError as exc:
+        raise HTTPException(400, str(exc)) from None
+    # Validate printer_id if provided (0 included: it names no printer either)
+    if data.printer_id is not None:
+        printer_scope.ensure(data.printer_id)
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
@@ -73,15 +130,8 @@ async def create_smart_plug(
         # Check if printer already has a plug assigned
         # Check if printer already has a plug assigned
         # Tasmota plugs: only one per printer (physical power device)
         # Tasmota plugs: only one per printer (physical power device)
         # HA entities: allow multiple per printer (for different automations)
         # HA entities: allow multiple per printer (for different automations)
-        if data.plug_type == "tasmota":
-            result = await db.execute(
-                select(SmartPlug).where(
-                    SmartPlug.printer_id == data.printer_id,
-                    SmartPlug.plug_type == "tasmota",
-                )
-            )
-            if result.scalar_one_or_none():
-                raise HTTPException(400, "This printer already has a Tasmota plug assigned")
+        if data.plug_type == "tasmota" and await _printer_has_other_tasmota_plug(db, data.printer_id):
+            raise HTTPException(400, "This printer already has a Tasmota plug assigned")
 
 
     # For MQTT plugs, ensure MQTT broker is configured and service is connected
     # For MQTT plugs, ensure MQTT broker is configured and service is connected
     if data.plug_type == "mqtt":
     if data.plug_type == "mqtt":
@@ -138,6 +188,20 @@ async def create_smart_plug(
     return plug
     return plug
 
 
 
 
+async def _plug_for_secret_test(
+    db: AsyncSession, plug_id: int | None, can_update: bool, printer_scope: PrinterScope
+) -> SmartPlug:
+    """The saved plug whose stored secret a connection test may use."""
+    if plug_id is None:
+        raise HTTPException(400, "Enter the secret; nothing is saved for a new plug")
+    if not can_update:
+        raise HTTPException(403, "Testing a saved plug's secret requires smart_plugs:update")
+    plug = await db.get(SmartPlug, plug_id)
+    if plug is None or not printer_scope.allows(plug.printer_id):
+        raise HTTPException(404, "Smart plug not found")
+    return plug
+
+
 def _is_script_plug(plug: SmartPlug) -> bool:
 def _is_script_plug(plug: SmartPlug) -> bool:
     """Whether the plug is a Home Assistant script rather than a switchable device."""
     """Whether the plug is a Home Assistant script rather than a switchable device."""
     return bool(plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."))
     return bool(plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."))
@@ -222,11 +286,21 @@ async def _plugs_for_printer(db: AsyncSession, printer_id: int) -> list[SmartPlu
     return list(result.scalars().all())
     return list(result.scalars().all())
 
 
 
 
-@router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
+class PrinterCardPlugs(BaseModel):
+    """A printer card's power plug and action buttons, in one answer."""
+
+    # As GET /by-printer/{printer_id}: the editor's shape for a caller who may edit plugs
+    plug: SmartPlugResponse | None
+    # As GET /by-printer/{printer_id}/scripts
+    scripts: list[SmartPlugResponse]
+
+
+@router.get("/by-printer/{printer_id}", response_model=None)
 async def get_smart_plug_by_printer(
 async def get_smart_plug_by_printer(
     printer_id: int,
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    can_edit: bool = CallerEditsPlugs,
 ):
 ):
     """Get the main smart plug assigned to a printer.
     """Get the main smart plug assigned to a printer.
 
 
@@ -234,14 +308,15 @@ async def get_smart_plug_by_printer(
     script -- returns the one that best fits the card's power controls. See
     script -- returns the one that best fits the card's power controls. See
     ``_main_plug_rank`` for the order and why.
     ``_main_plug_rank`` for the order and why.
     """
     """
-    return _pick_main_plug(await _plugs_for_printer(db, printer_id))
+    plug = _pick_main_plug(await _plugs_for_printer(db, printer_id))
+    return _serialize_plug(plug, can_edit=can_edit) if plug else None
 
 
 
 
 @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
 @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
 async def get_script_plugs_by_printer(
 async def get_script_plugs_by_printer(
     printer_id: int,
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
 ):
     """Get all HA entities assigned to a printer for display on printer card.
     """Get all HA entities assigned to a printer for display on printer card.
 
 
@@ -255,7 +330,11 @@ async def get_script_plugs_by_printer(
     falls back to showing one of them in the power row -- taking it out of this
     falls back to showing one of them in the power row -- taking it out of this
     row too would cost the one-click run it has always had there.
     row too would cost the one-click run it has always had there.
     """
     """
-    plugs = await _plugs_for_printer(db, printer_id)
+    return _card_script_plugs(await _plugs_for_printer(db, printer_id))
+
+
+def _card_script_plugs(plugs: list[SmartPlug]) -> list[SmartPlug]:
+    """The action buttons of one printer's card; see ``get_script_plugs_by_printer``."""
     main_plug = _pick_main_plug(plugs)
     main_plug = _pick_main_plug(plugs)
     duplicate_of_power_row = main_plug.id if main_plug and not _is_script_plug(main_plug) else None
     duplicate_of_power_row = main_plug.id if main_plug and not _is_script_plug(main_plug) else None
 
 
@@ -330,7 +409,7 @@ class DiscoveredTasmotaDevice(BaseModel):
 @router.post("/discover/scan", response_model=TasmotaScanStatus)
 @router.post("/discover/scan", response_model=TasmotaScanStatus)
 async def start_tasmota_scan(
 async def start_tasmota_scan(
     request: TasmotaScanRequest | None = Body(default=None),
     request: TasmotaScanRequest | None = Body(default=None),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
 ):
 ):
     """Start an IP range scan for Tasmota devices.
     """Start an IP range scan for Tasmota devices.
 
 
@@ -371,7 +450,7 @@ async def get_tasmota_scan_status(
 
 
 @router.post("/discover/stop", response_model=TasmotaScanStatus)
 @router.post("/discover/stop", response_model=TasmotaScanStatus)
 async def stop_tasmota_scan(
 async def stop_tasmota_scan(
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
 ):
 ):
     """Stop the current Tasmota scan."""
     """Stop the current Tasmota scan."""
     tasmota_scanner.stop()
     tasmota_scanner.stop()
@@ -406,26 +485,58 @@ async def get_discovered_tasmota_devices(
 @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
 @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
 async def test_ha_connection(
 async def test_ha_connection(
     request: HATestConnectionRequest,
     request: HATestConnectionRequest,
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    can_update_settings: bool = Depends(probe_permissions_if_auth_enabled(Permission.SETTINGS_UPDATE)),
 ):
 ):
-    """Test connection to Home Assistant."""
-    result = await homeassistant_service.test_connection(request.url, request.token)
+    """Test connection to Home Assistant.
+
+    The placeholder token tests the stored token, against the stored URL only.
+    """
+    token = request.token
+    if token == SECRET_PLACEHOLDER:
+        if not can_update_settings:
+            raise HTTPException(403, "Testing the saved token requires settings:update")
+        from backend.app.api.routes.settings import get_homeassistant_settings
+
+        stored = await get_homeassistant_settings(db)
+        if not stored.get("ha_token"):
+            raise HTTPException(400, "Enter the token; none is saved")
+        if not same_value(request.url, stored.get("ha_url")):
+            raise HTTPException(400, "Enter the token again: the URL changed")
+        token = stored["ha_token"]
+    result = await homeassistant_service.test_connection(request.url, token)
     return HATestConnectionResponse(**result)
     return HATestConnectionResponse(**result)
 
 
 
 
 @router.post("/rest/test-connection", response_model=RESTTestConnectionResponse)
 @router.post("/rest/test-connection", response_model=RESTTestConnectionResponse)
 async def test_rest_connection(
 async def test_rest_connection(
     request: RESTTestConnectionRequest,
     request: RESTTestConnectionRequest,
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.SMART_PLUGS_UPDATE)),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
-    """Test connection to a REST/HTTP endpoint."""
-    result = await rest_smart_plug_service.test_connection(request.url, request.method, request.headers)
+    """Test connection to a REST/HTTP endpoint.
+
+    With plug_id, the placeholder headers are the plug's stored headers, for
+    the plug's own URLs only.
+    """
+    headers = request.headers
+    if headers == SECRET_PLACEHOLDER:
+        plug = await _plug_for_secret_test(db, request.plug_id, can_update, printer_scope)
+        if not plug.rest_headers:
+            raise HTTPException(400, "Enter the headers; none are saved")
+        if not any(same_value(request.url, getattr(plug, field)) for field in REST_PLUG_URLS):
+            raise HTTPException(400, "Enter the headers again: the URL is not one of the plug's")
+        headers = plug.rest_headers
+    result = await rest_smart_plug_service.test_connection(request.url, request.method, headers)
     return RESTTestConnectionResponse(**result)
     return RESTTestConnectionResponse(**result)
 
 
 
 
 @router.get("/ha/entities", response_model=list[HAEntity])
 @router.get("/ha/entities", response_model=list[HAEntity])
 async def list_ha_entities(
 async def list_ha_entities(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     search: str | None = None,
     search: str | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
 ):
@@ -453,7 +564,7 @@ async def list_ha_entities(
 
 
 @router.get("/ha/sensors", response_model=list[HASensorEntity])
 @router.get("/ha/sensors", response_model=list[HASensorEntity])
 async def list_ha_sensor_entities(
 async def list_ha_sensor_entities(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
 ):
     """List available Home Assistant sensor entities for energy monitoring.
     """List available Home Assistant sensor entities for energy monitoring.
@@ -476,58 +587,84 @@ async def list_ha_sensor_entities(
     return [HASensorEntity(**s) for s in sensors]
     return [HASensorEntity(**s) for s in sensors]
 
 
 
 
-@router.get("/{plug_id}", response_model=SmartPlugResponse)
+@router.get("/{plug_id}", response_model=None)
 async def get_smart_plug(
 async def get_smart_plug(
     plug_id: int,
     plug_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    can_edit: bool = CallerEditsPlugs,
 ):
 ):
     """Get a specific smart plug."""
     """Get a specific smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
-    return plug
+    return _serialize_plug(plug, can_edit=can_edit)
 
 
 
 
-@router.patch("/{plug_id}", response_model=SmartPlugResponse)
+@router.patch("/{plug_id}", response_model=SmartPlugResponseWithSecret)
 async def update_smart_plug(
 async def update_smart_plug(
     plug_id: int,
     plug_id: int,
     data: SmartPlugUpdate,
     data: SmartPlugUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Update a smart plug."""
     """Update a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
     update_data = data.model_dump(exclude_unset=True)
     update_data = data.model_dump(exclude_unset=True)
 
 
+    # Write-only secrets: the placeholder keeps the stored value, and a kept
+    # secret does not follow a changed address or plug type.
+    stored = {
+        field: getattr(plug, field)
+        for field in {*SMART_PLUG_SECRETS, *(f for fs in SMART_PLUG_SECRETS.values() for f in fs)}
+    }
+    try:
+        update_data = merge_partial(stored, update_data, SMART_PLUG_SECRETS)
+    except SecretConfigError as exc:
+        raise HTTPException(400, str(exc)) from None
+
     # Validate new printer_id if being changed
     # Validate new printer_id if being changed
-    if "printer_id" in update_data and update_data["printer_id"]:
+    if "printer_id" in update_data and update_data["printer_id"] is not None:
         new_printer_id = update_data["printer_id"]
         new_printer_id = update_data["printer_id"]
+        printer_scope.ensure(new_printer_id)
 
 
         # Check printer exists
         # Check printer exists
         result = await db.execute(select(Printer).where(Printer.id == new_printer_id))
         result = await db.execute(select(Printer).where(Printer.id == new_printer_id))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
 
 
-        # Check if that printer already has a different Tasmota plug assigned
-        # Tasmota plugs: only one per printer (physical power device)
-        # HA entities: allow multiple per printer (for different automations)
-        new_plug_type = update_data.get("plug_type", plug.plug_type)
-        if new_plug_type == "tasmota":
-            result = await db.execute(
-                select(SmartPlug).where(
-                    SmartPlug.printer_id == new_printer_id,
-                    SmartPlug.id != plug_id,
-                    SmartPlug.plug_type == "tasmota",
-                )
-            )
-            if result.scalar_one_or_none():
-                raise HTTPException(400, "This printer already has a Tasmota plug assigned")
+    # One Tasmota plug per printer (it is the printer's power device), checked
+    # when this edit makes the plug a Tasmota plug on a printer, by its type or
+    # its printer: a type change alone cannot add a second, and an edit that
+    # changes neither is not refused for plugs stored earlier.
+    merged_type = update_data.get("plug_type", plug.plug_type)
+    merged_printer_id = update_data.get("printer_id", plug.printer_id)
+    becomes_tasmota_here = merged_type != plug.plug_type or merged_printer_id != plug.printer_id
+    if merged_type == "tasmota" and merged_printer_id and becomes_tasmota_here:
+        if await _printer_has_other_tasmota_plug(db, merged_printer_id, exclude_plug_id=plug_id):
+            raise HTTPException(400, "This printer already has a Tasmota plug assigned")
+
+    # The stored plug with the change applied must pass what create checks:
+    # a plug that fails it can no longer be read back (list, detail and this
+    # response all fail). The error names the field to fix, sent or stored,
+    # and carries no values: the stored plug holds write-only credentials.
+    merged = {field: update_data.get(field, getattr(plug, field, None)) for field in SmartPlugBase.model_fields}
+    try:
+        SmartPlugBase.model_validate(merged)
+    except ValidationError as exc:
+        raise RequestValidationError(
+            [
+                {"type": error["type"], "loc": ("body", *error["loc"]), "msg": error["msg"]}
+                for error in exc.errors(include_url=False, include_input=False, include_context=False)
+            ]
+        ) from None
 
 
     # Track old MQTT settings for comparison
     # Track old MQTT settings for comparison
     old_plug_type = plug.plug_type
     old_plug_type = plug.plug_type
@@ -582,26 +719,29 @@ async def update_smart_plug(
     return plug
     return plug
 
 
 
 
+async def delete_plug(db: AsyncSession, plug: SmartPlug) -> None:
+    """Delete a smart plug (the caller commits). An MQTT plug stops listening first."""
+    smart_plug_manager.forget_plug(plug.id)
+    if plug.plug_type == "mqtt":
+        mqtt_relay.smart_plug_service.unsubscribe(plug.id)
+    await db.delete(plug)
+
+
 @router.delete("/{plug_id}")
 @router.delete("/{plug_id}")
 async def delete_smart_plug(
 async def delete_smart_plug(
     plug_id: int,
     plug_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Delete a smart plug."""
     """Delete a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
     plug_name = plug.name
     plug_name = plug.name
-    plug_type = plug.plug_type
-
-    # Unsubscribe MQTT plug before deletion
-    if plug_type == "mqtt":
-        mqtt_relay.smart_plug_service.unsubscribe(plug_id)
-
-    await db.delete(plug)
+    await delete_plug(db, plug)
     await db.commit()
     await db.commit()
 
 
     logger.info("Deleted smart plug '%s'", plug_name)
     logger.info("Deleted smart plug '%s'", plug_name)
@@ -629,13 +769,14 @@ async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
 async def control_smart_plug(
 async def control_smart_plug(
     plug_id: int,
     plug_id: int,
     control: SmartPlugControl,
     control: SmartPlugControl,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Manual control: on/off/toggle."""
     """Manual control: on/off/toggle."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
     # MQTT plugs are monitor-only - cannot control them
     # MQTT plugs are monitor-only - cannot control them
@@ -679,74 +820,35 @@ async def control_smart_plug(
         plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
         plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
     )
     )
     if is_main_plug and plug.printer_id and expected_state:
     if is_main_plug and plug.printer_id and expected_state:
-        await trigger_associated_scripts(plug.printer_id, expected_state, db)
+        await run_linked_scripts(plug.printer_id, expected_state, db)
 
 
-    # MQTT relay - publish smart plug state change
-    if expected_state:
+    # MQTT relay - publish smart plug state change. A toggle doesn't say which
+    # way it went, so read the state it ended in.
+    published_state = expected_state
+    if published_state is None:
         try:
         try:
-            from backend.app.services.mqtt_relay import mqtt_relay
-
-            # Get printer name if linked
-            printer_name = None
-            if plug.printer_id:
-                result = await db.execute(select(Printer).where(Printer.id == plug.printer_id))
-                printer = result.scalar_one_or_none()
-                printer_name = printer.name if printer else None
-
-            await mqtt_relay.on_smart_plug_state(
-                plug_id=plug.id,
-                plug_name=plug.name,
-                state="on" if expected_state == "ON" else "off",
-                printer_id=plug.printer_id,
-                printer_name=printer_name,
-            )
+            published_state = (await service.get_status(plug)).get("state")
         except Exception:
         except Exception:
-            pass  # Don't fail if MQTT fails
+            published_state = None
+    if published_state in ("ON", "OFF"):
+        await publish_plug_state(
+            plug.id, plug.name, plug.printer_id, on=published_state == "ON", ha_entity_id=plug.ha_entity_id
+        )
 
 
     return {"success": True, "action": control.action}
     return {"success": True, "action": control.action}
 
 
 
 
-async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession):
-    """Trigger scripts linked to a printer based on main plug state change.
-
-    When the main plug turns ON, triggers scripts with auto_on=True.
-    When the main plug turns OFF, triggers scripts with auto_off=True.
-    """
-    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-    plugs = result.scalars().all()
-
-    # Find scripts that should be triggered
-    for plug in plugs:
-        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
-        if not is_script:
-            continue
-
-        should_trigger = False
-        if plug_state == "ON" and plug.auto_on:
-            should_trigger = True
-            logger.info("Auto-triggering script '%s' on printer power-on", plug.name)
-        elif plug_state == "OFF" and plug.auto_off:
-            should_trigger = True
-            logger.info("Auto-triggering script '%s' on printer power-off", plug.name)
-
-        if should_trigger:
-            try:
-                service = await _get_service_for_plug(plug, db)
-                await service.turn_on(plug)  # Scripts are triggered by calling turn_on
-            except Exception as e:
-                logger.error("Failed to trigger script '%s': %s", plug.name, e)
-
-
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 async def get_plug_status(
 async def get_plug_status(
     plug_id: int,
     plug_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get current plug status from device including energy data."""
     """Get current plug status from device including energy data."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
     # Handle MQTT plugs - get data from subscription service
     # Handle MQTT plugs - get data from subscription service
@@ -767,9 +869,6 @@ async def get_plug_status(
                     power=data.power,
                     power=data.power,
                     today=data.energy,
                     today=data.energy,
                 )
                 )
-                # Check power alerts
-                if data.power is not None:
-                    await check_power_alerts(plug, data.power, db)
 
 
             return SmartPlugStatus(
             return SmartPlugStatus(
                 state=data.state,
                 state=data.state,
@@ -807,9 +906,8 @@ async def get_plug_status(
             # knows its own daily figures, is left alone.
             # knows its own daily figures, is left alone.
             energy = await fill_derived_energy(db, plug.id, energy)
             energy = await fill_derived_energy(db, plug.id, energy)
             energy_data = SmartPlugEnergy(**energy)
             energy_data = SmartPlugEnergy(**energy)
-
-            # Check power alerts
-            await check_power_alerts(plug, energy.get("power"), db)
+            # Power alerts are checked in the background (services/power_alerts.py),
+            # not here: this is only read while someone has a printer page open.
 
 
     return SmartPlugStatus(
     return SmartPlugStatus(
         state=status["state"],
         state=status["state"],
@@ -819,74 +917,31 @@ async def get_plug_status(
     )
     )
 
 
 
 
-async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: AsyncSession):
-    """Check if power crosses alert thresholds and send notifications."""
-    if not plug.power_alert_enabled or current_power is None:
-        return
-
-    # Cooldown: don't alert more than once per 5 minutes
-    cooldown_minutes = 5
-    if plug.power_alert_last_triggered:
-        # Naive UTC on both sides: the column is naive, so a row loaded fresh from
-        # the DB comes back without an offset and subtracting an aware now() would
-        # raise TypeError.
-        time_since_last = utcnow_naive() - to_naive_utc(plug.power_alert_last_triggered)
-        if time_since_last < timedelta(minutes=cooldown_minutes):
-            return
-
-    alert_triggered = False
-    alert_type = None
-    threshold = None
-
-    # Check high threshold
-    if plug.power_alert_high is not None and current_power > plug.power_alert_high:
-        alert_triggered = True
-        alert_type = "high"
-        threshold = plug.power_alert_high
-
-    # Check low threshold
-    if plug.power_alert_low is not None and current_power < plug.power_alert_low:
-        alert_triggered = True
-        alert_type = "low"
-        threshold = plug.power_alert_low
-
-    if alert_triggered:
-        plug.power_alert_last_triggered = utcnow_naive()
-        await db.commit()
-
-        # Send notification
-        title = f"Power Alert: {plug.name}"
-        if alert_type == "high":
-            message = f"Power consumption is {current_power:.1f}W, above threshold of {threshold:.1f}W"
-        else:
-            message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
-
-        logger.info("Power alert triggered for %s: %s", plug.name, message)
-
-        # Use printer_error event type for power alerts (closest match)
-        await notification_service.send_notification(
-            event_type="printer_error",
-            title=title,
-            message=message,
-            printer_id=plug.printer_id,
-            printer_name=plug.name,
-            context={
-                "error_type": f"Power {alert_type.title()}",
-                "error_detail": message,
-            },
-        )
-
-
 @router.post("/test-connection")
 @router.post("/test-connection")
 async def test_connection(
 async def test_connection(
     data: SmartPlugTestConnection,
     data: SmartPlugTestConnection,
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.SMART_PLUGS_UPDATE)),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
-    """Test connection to a Tasmota device."""
+    """Test connection to a Tasmota device.
+
+    With plug_id, the placeholder password is the plug's stored password, for
+    the plug's own address only.
+    """
+    password = data.password
+    if password == SECRET_PLACEHOLDER:
+        plug = await _plug_for_secret_test(db, data.plug_id, can_update, printer_scope)
+        if not plug.password:
+            raise HTTPException(400, "Enter the password; none is saved")
+        if not same_value(data.ip_address, plug.ip_address):
+            raise HTTPException(400, "Enter the password again: the address changed")
+        password = plug.password
     result = await tasmota_service.test_connection(
     result = await tasmota_service.test_connection(
         data.ip_address,
         data.ip_address,
         data.username,
         data.username,
-        data.password,
+        password,
     )
     )
 
 
     if not result["success"]:
     if not result["success"]:

+ 2 - 2
backend/app/api/routes/sponsor_prompt.py

@@ -27,7 +27,7 @@ def _user_id(current_user: User | None) -> int | None:
 @router.get("/check", response_model=SponsorPromptCheckResponse)
 @router.get("/check", response_model=SponsorPromptCheckResponse)
 async def check_sponsor_prompt(
 async def check_sponsor_prompt(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Return the next eligible sponsor-toast trigger, or `{show: false}`."""
     """Return the next eligible sponsor-toast trigger, or `{show: false}`."""
     trigger = await service.evaluate(db, _user_id(current_user))
     trigger = await service.evaluate(db, _user_id(current_user))
@@ -47,7 +47,7 @@ async def check_sponsor_prompt(
 async def dismiss_sponsor_prompt(
 async def dismiss_sponsor_prompt(
     data: SponsorPromptDismissRequest,
     data: SponsorPromptDismissRequest,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Anchor the 14-day cooldown and record the milestone as shown."""
     """Anchor the 14-day cooldown and record the milestone as shown."""
     await service.dismiss(db, _user_id(current_user), data.milestone)
     await service.dismiss(db, _user_id(current_user), data.milestone)

+ 408 - 117
backend/app/api/routes/spoolbuddy.py

@@ -4,19 +4,22 @@ import asyncio
 import contextlib
 import contextlib
 import json
 import json
 import logging
 import logging
+import re
 import time
 import time
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
 import httpx
 import httpx
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.api.routes.spoolman_inventory import _clear_stale_tag_links
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
+from backend.app.models.spool import Spool
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.spoolbuddy import (
 from backend.app.schemas.spoolbuddy import (
@@ -36,12 +39,24 @@ from backend.app.schemas.spoolbuddy import (
     TagRemovedRequest,
     TagRemovedRequest,
     TagScannedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
     UpdateSpoolWeightRequest,
-    UpdateStatusRequest,
     WriteTagRequest,
     WriteTagRequest,
     WriteTagResultRequest,
     WriteTagResultRequest,
 )
 )
-from backend.app.services.spool_tag_matcher import get_spool_by_tag
+from backend.app.services.bambu_tag_blocks import spool_prefill_from_tag
+from backend.app.services.spool_tag_matcher import (
+    begins_with_cascade_level_1,
+    cascade_level_1_uid,
+    cascade_level_1_uid_start,
+    get_spool_by_tag,
+    warn_cascade_level_1_ambiguous,
+)
 from backend.app.services.spoolman import SpoolmanClientError, SpoolmanNotFoundError, SpoolmanUnavailableError
 from backend.app.services.spoolman import SpoolmanClientError, SpoolmanNotFoundError, SpoolmanUnavailableError
+from backend.app.utils.tag_normalization import (
+    is_bambu_tray_uuid,
+    normalize_hex,
+    normalize_tag_uid,
+    normalize_tray_uuid,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -68,6 +83,19 @@ async def _translate_spoolbuddy_errors():
         raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
         raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
 
 
 
 
+async def _spoolman_mode(db: AsyncSession) -> bool:
+    """Spoolman mode as every SpoolBuddy screen reads it: switched on, with a URL.
+
+    Distinct from having a usable client: a URL the SSRF guard refuses leaves the
+    mode on but no client, and the spools are still Spoolman's.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    enabled = await get_setting(db, "spoolman_enabled")
+    url = await get_setting(db, "spoolman_url")
+    return bool(enabled) and enabled.lower() == "true" and bool((url or "").strip())
+
+
 async def _get_spoolman_client_or_none(db: AsyncSession):
 async def _get_spoolman_client_or_none(db: AsyncSession):
     """Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None."""
     """Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None."""
     from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
     from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
@@ -177,7 +205,7 @@ def _should_broadcast_online(device_id: str, force: bool = False) -> bool:
 @router.post("/devices/register", response_model=DeviceResponse)
 @router.post("/devices/register", response_model=DeviceResponse)
 async def register_device(
 async def register_device(
     req: DeviceRegisterRequest,
     req: DeviceRegisterRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Register or re-register a SpoolBuddy device."""
     """Register or re-register a SpoolBuddy device."""
@@ -248,7 +276,7 @@ async def register_device(
 
 
 @router.get("/devices", response_model=list[DeviceResponse])
 @router.get("/devices", response_model=list[DeviceResponse])
 async def list_devices(
 async def list_devices(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
 ):
     """List all registered SpoolBuddy devices."""
     """List all registered SpoolBuddy devices."""
@@ -260,7 +288,7 @@ async def list_devices(
 @router.delete("/devices/{device_id}")
 @router.delete("/devices/{device_id}")
 async def unregister_device(
 async def unregister_device(
     device_id: str,
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_DELETE),
 ):
 ):
     """Unregister a SpoolBuddy device. The daemon can re-register via heartbeat later."""
     """Unregister a SpoolBuddy device. The daemon can re-register via heartbeat later."""
@@ -281,7 +309,7 @@ async def unregister_device(
 async def device_heartbeat(
 async def device_heartbeat(
     device_id: str,
     device_id: str,
     req: HeartbeatRequest,
     req: HeartbeatRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Daemon heartbeat — updates status and returns pending commands."""
     """Daemon heartbeat — updates status and returns pending commands."""
@@ -374,10 +402,183 @@ async def device_heartbeat(
 # --- NFC endpoints ---
 # --- NFC endpoints ---
 
 
 
 
+async def _backfill_local_tray_uuid(db: AsyncSession, spool: Spool, tag_uid: str, tray_uuid: str) -> None:
+    """Give a spool matched by its exact tag UID the tray UUID read from that tag.
+
+    Spools added on the kiosk before #984 carry only the UID of the tag that was
+    scanned then, so the spool's other tag and the AMS never found them. The scan
+    read block 9 of this very tag, so the UUID belongs to this spool. Only an
+    exact UID match counts: a fuzzy (suffix or first-byte) match may be another
+    spool's tag and must never write anything. A UUID any other spool carries,
+    archived ones included, is left alone.
+    """
+    if spool.tray_uuid or normalize_tag_uid(spool.tag_uid) != normalize_tag_uid(tag_uid):
+        return
+    try:
+        holder = await db.execute(
+            select(Spool.id).where(func.upper(Spool.tray_uuid) == tray_uuid, Spool.id != spool.id).limit(1)
+        )
+        if holder.scalar_one_or_none() is not None:
+            return
+        spool.tray_uuid = tray_uuid
+        await db.commit()
+        logger.info("SpoolBuddy: saved tray_uuid %s on spool %d, matched by tag %s", tray_uuid, spool.id, tag_uid)
+    except Exception:
+        await db.rollback()
+        logger.exception("SpoolBuddy: could not save tray_uuid %s on spool %d", tray_uuid, spool.id)
+
+
+async def _backfill_spoolman_tray_uuid(client, sm_spool: dict, tag_uid: str, tray_uuid: str) -> None:
+    """Store the tray UUID as the tag of a Spoolman spool matched by its exact tag UID.
+
+    Spoolman has one extra.tag per spool, and the AMS sync keys Bambu spools by
+    the tray UUID there, so the UUID replaces the tag UID. The caller already
+    looked the UUID up among the active spools and found none, the same check
+    the link route makes. See _backfill_local_tray_uuid for why only an exact
+    UID match counts.
+    """
+    extra = sm_spool.get("extra")
+    raw_tag = extra.get("tag") if isinstance(extra, dict) else None
+    stored = raw_tag.strip('"').upper() if isinstance(raw_tag, str) else ""
+    spool_id = sm_spool.get("id")
+    if not isinstance(spool_id, int) or not stored or stored != tag_uid.strip('"').upper():
+        return
+    try:
+        await client.merge_spool_extra(spool_id, {"tag": json.dumps(tray_uuid)})
+        logger.info("SpoolBuddy: stored tray_uuid %s as the tag of Spoolman spool %d", tray_uuid, spool_id)
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    except Exception:
+        logger.exception("SpoolBuddy: could not store tray_uuid %s on Spoolman spool %d", tray_uuid, spool_id)
+
+
+def _spoolman_native_tag_uids(sm_spool: dict) -> set[str]:
+    """The UIDs a Spoolman spool holds as native tags (Spoolman 0.27+)."""
+    uids = {normalize_hex(t.get("uid")) for t in sm_spool.get("tags") or [] if isinstance(t, dict)}
+    uids.discard("")
+    return uids
+
+
+def _spoolman_extra_tag_uid(sm_spool: dict) -> str:
+    """The tag UID in a Spoolman spool's extra.tag, which holds it JSON-encoded; empty when none."""
+    extra = sm_spool.get("extra")
+    raw_tag = extra.get("tag") if isinstance(extra, dict) else None
+    return normalize_hex(raw_tag) if isinstance(raw_tag, str) else ""
+
+
+def _spoolman_tag_links(sm_spool: dict) -> set[str]:
+    """Every UID a Spoolman spool is linked by: its native tags and extra.tag."""
+    return _spoolman_native_tag_uids(sm_spool) | ({_spoolman_extra_tag_uid(sm_spool)} - {""})
+
+
+def _spoolman_spool_by_cascade_level_1(spools: list[dict], tag_uid: str, sak: int | None = None) -> dict | None:
+    """Match a tag UID and a Spoolman link stored from the tag's cascade level 1, either way round.
+
+    The Spoolman counterpart of the built-in inventory's fallback in
+    get_spool_by_tag (#3258), with the same rules. A full 7- or 10-byte read
+    finds the active spool linked as 88 + the UID's first three bytes, as a
+    native tag or in extra.tag. A level-1 read from a daemon not updated yet
+    finds the active spool linked by the 7-byte UID it begins. Three bytes can
+    begin more than one tag, so only a single fitting spool is matched.
+    """
+    level1_uid = cascade_level_1_uid(tag_uid)
+    uid_start = cascade_level_1_uid_start(tag_uid, sak)
+    if not level1_uid and not uid_start:
+        return None
+
+    matches: list[dict] = []
+    for sm_spool in spools:
+        if not isinstance(sm_spool, dict) or sm_spool.get("archived"):
+            continue
+        links = _spoolman_tag_links(sm_spool)
+        if level1_uid in links or (
+            uid_start is not None and any(begins_with_cascade_level_1(uid, uid_start) for uid in links)
+        ):
+            matches.append(sm_spool)
+
+    if len(matches) == 1:
+        logger.info(
+            "Matched Spoolman spool %s by cascade level 1 of tag %s: links %s",
+            matches[0].get("id"),
+            normalize_hex(tag_uid),
+            sorted(_spoolman_tag_links(matches[0])),
+        )
+        return matches[0]
+    if len(matches) > 1:
+        warn_cascade_level_1_ambiguous(normalize_hex(tag_uid), [sm_spool.get("id") for sm_spool in matches])
+    return None
+
+
+async def _upgrade_spoolman_cascade_level_1_link(client, sm_spool: dict, full_uid: str, level1_uid: str) -> None:
+    """Move a Spoolman spool's tag link stored from cascade level 1 over to the full UID (#3258).
+
+    The scan has linked the full UID as a native tag already (add_native_tags).
+    An extra.tag holding the 88 form is rewritten to the full UID, and the 88
+    native tag is unlinked once the spool is linked by the full UID, natively
+    or in extra.tag, so the spool is never left without a link.
+
+    Best-effort: a write Spoolman refuses or fails is logged and the scan keeps
+    its match. Whatever is left of the 88 form stays on the spool, and the next
+    full scan of the tag comes here again and finishes the move.
+    """
+    spool_id = sm_spool.get("id")
+    if not isinstance(spool_id, int):
+        return
+    changed = False
+
+    if _spoolman_extra_tag_uid(sm_spool) == level1_uid:
+        try:
+            await client.merge_spool_extra(spool_id, {"tag": json.dumps(full_uid)})
+            changed = True
+            logger.info(
+                "SpoolBuddy: extra.tag %s of Spoolman spool %d rewritten to the full UID %s",
+                level1_uid,
+                spool_id,
+                full_uid,
+            )
+        except Exception as exc:
+            logger.warning(
+                "SpoolBuddy: could not rewrite extra.tag %s of Spoolman spool %d to %s, trying again on the next scan: %s",
+                level1_uid,
+                spool_id,
+                full_uid,
+                exc,
+            )
+
+    if level1_uid in _spoolman_native_tag_uids(sm_spool):
+        try:
+            current = await client.get_spool(spool_id)
+            if full_uid in _spoolman_tag_links(current):
+                await client.unlink_native_tag(spool_id, level1_uid)
+                changed = True
+                logger.info(
+                    "SpoolBuddy: native tag %s unlinked from Spoolman spool %d, now linked by the full UID %s",
+                    level1_uid,
+                    spool_id,
+                    full_uid,
+                )
+            else:
+                logger.info(
+                    "SpoolBuddy: Spoolman spool %d keeps native tag %s until the full UID %s is linked",
+                    spool_id,
+                    level1_uid,
+                    full_uid,
+                )
+        except Exception as exc:
+            logger.warning(
+                "SpoolBuddy: could not unlink native tag %s from Spoolman spool %d, trying again on the next scan: %s",
+                level1_uid,
+                spool_id,
+                exc,
+            )
+
+    if changed:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+
+
 @router.post("/nfc/tag-scanned")
 @router.post("/nfc/tag-scanned")
 async def nfc_tag_scanned(
 async def nfc_tag_scanned(
     req: TagScannedRequest,
     req: TagScannedRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """RPi reports NFC tag detected — lookup spool and broadcast.
     """RPi reports NFC tag detected — lookup spool and broadcast.
@@ -393,29 +594,82 @@ async def nfc_tag_scanned(
     """
     """
     from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
     from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 
 
-    # _get_spoolman_client_or_none returns a usable client when spoolman_enabled
-    # is true (and the URL passes the SSRF guard), None otherwise — so its
-    # return value doubles as the mode discriminator.
+    # Daemons before #984 read the filament type from blocks 4-5 and sent it as
+    # tray_uuid. Drop anything that is not a real tray UUID, so an old daemon
+    # falls back to tag_uid matching and its value is never offered for saving.
+    tray_uuid: str | None = normalize_tray_uuid(req.tray_uuid) or None
+    if tray_uuid and not is_bambu_tray_uuid(tray_uuid):
+        logger.info(
+            "SpoolBuddy %s sent tray_uuid %s for tag %s, which is not a Bambu tray UUID; ignoring it. "
+            "Update the SpoolBuddy daemon.",
+            req.device_id,
+            tray_uuid,
+            req.tag_uid,
+        )
+        tray_uuid = None
+
+    # A usable client when Spoolman is on and its URL passes the SSRF guard.
+    # With the mode on but no usable client the spools are still Spoolman's,
+    # and the built-in table must not answer.
     client = await _get_spoolman_client_or_none(db)
     client = await _get_spoolman_client_or_none(db)
+    if client is None and await _spoolman_mode(db):
+        await ws_manager.broadcast(
+            {
+                "type": "spoolman_unavailable",
+                "device_id": req.device_id,
+                "context": "nfc_tag_scanned",
+            }
+        )
+        return {"status": "ok", "matched": False, "spool_id": None}
 
 
     if client is not None:
     if client is not None:
         # Spoolman mode — exclusive lookup, no local-DB fallback.
         # Spoolman mode — exclusive lookup, no local-DB fallback.
         try:
         try:
-            cached_spools = await client.get_spools()
             sm_spool: dict | None = None
             sm_spool: dict | None = None
-            if req.tray_uuid:
-                sm_spool = await client.find_spool_by_tag(req.tray_uuid, cached_spools=cached_spools)
-            if sm_spool is None and req.tag_uid:
-                sm_spool = await client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools)
+            native = await client.has_tag_api()
+            cached_spools: list[dict] | None = None
+            # The tray UUID is settled before the tag UID, as it always was: a spool
+            # that carries the UUID only in extra.tag still wins over one holding the
+            # chip UID natively. Spoolman 0.27+ answers each identifier with one exact
+            # query; the whole inventory is loaded only when that misses.
+            for uid in (tray_uuid, req.tag_uid):
+                if not uid or sm_spool is not None:
+                    continue
+                if native:
+                    try:
+                        sm_spool = await client.find_spool_by_native_tag(uid.upper())
+                    except SpoolmanClientError as exc:
+                        logger.warning("Native tag lookup refused for %s, using extra.tag: %s", uid, exc)
+                if sm_spool is None:
+                    if cached_spools is None:
+                        cached_spools = await client.get_spools()
+                    sm_spool = await client.find_spool_by_tag(uid, cached_spools=cached_spools)
+
+            # Last, a link stored from the tag's cascade level 1 (#3258), as
+            # get_spool_by_tag falls back to in the built-in inventory.
+            level1_read = cascade_level_1_uid_start(req.tag_uid, req.sak) is not None
+            level1_read_match = False
+            if sm_spool is None and (level1_read or cascade_level_1_uid(req.tag_uid)):
+                if cached_spools is None:
+                    cached_spools = await client.get_spools()
+                sm_spool = _spoolman_spool_by_cascade_level_1(cached_spools, req.tag_uid, req.sak)
+                level1_read_match = level1_read and sm_spool is not None
 
 
             if sm_spool is not None:
             if sm_spool is not None:
+                # What this scan read becomes a native tag of the spool: a spool found
+                # through extra.tag moves over on its first scan, and a Bambu spool
+                # collects the chip UID of each side as it is read. A level-1 read
+                # that matched through the fallback is not one: 88 + three bytes can
+                # begin more than one tag, and the spool is linked by the full UID.
+                scanned_uids = [tray_uuid] if level1_read_match else [tray_uuid, req.tag_uid]
+                await client.add_native_tags(sm_spool, scanned_uids, "bambu" if tray_uuid else None, retry_refused=True)
                 mapped = _map_spoolman_spool(sm_spool)
                 mapped = _map_spoolman_spool(sm_spool)
                 await ws_manager.broadcast(
                 await ws_manager.broadcast(
                     {
                     {
                         "type": "spoolbuddy_tag_matched",
                         "type": "spoolbuddy_tag_matched",
                         "device_id": req.device_id,
                         "device_id": req.device_id,
                         "tag_uid": req.tag_uid,
                         "tag_uid": req.tag_uid,
-                        "tray_uuid": req.tray_uuid,
+                        "tray_uuid": tray_uuid,
                         "spool": {
                         "spool": {
                             "id": mapped["id"],
                             "id": mapped["id"],
                             "material": mapped["material"],
                             "material": mapped["material"],
@@ -439,6 +693,15 @@ async def nfc_tag_scanned(
                     }
                     }
                 )
                 )
                 logger.info("SpoolBuddy tag matched (Spoolman): %s -> spool %d", req.tag_uid, mapped["id"])
                 logger.info("SpoolBuddy tag matched (Spoolman): %s -> spool %d", req.tag_uid, mapped["id"])
+                # A full read of a spool still linked by the 88 form, matched by the
+                # fallback or exactly after an earlier move stopped half-way.
+                level1_uid = cascade_level_1_uid(req.tag_uid)
+                if level1_uid and level1_uid in _spoolman_tag_links(sm_spool):
+                    await _upgrade_spoolman_cascade_level_1_link(
+                        client, sm_spool, normalize_hex(req.tag_uid), level1_uid
+                    )
+                if tray_uuid:
+                    await _backfill_spoolman_tray_uuid(client, sm_spool, req.tag_uid, tray_uuid)
                 return {"status": "ok", "matched": True, "spool_id": mapped["id"]}
                 return {"status": "ok", "matched": True, "spool_id": mapped["id"]}
         except ValueError as exc:
         except ValueError as exc:
             logger.error(
             logger.error(
@@ -481,14 +744,14 @@ async def nfc_tag_scanned(
             return {"status": "ok", "matched": False, "spool_id": None}
             return {"status": "ok", "matched": False, "spool_id": None}
     else:
     else:
         # Local mode — exclusive lookup, no Spoolman fallback.
         # Local mode — exclusive lookup, no Spoolman fallback.
-        spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or "")
+        spool = await get_spool_by_tag(db, req.tag_uid, tray_uuid or "", req.sak)
         if spool:
         if spool:
             await ws_manager.broadcast(
             await ws_manager.broadcast(
                 {
                 {
                     "type": "spoolbuddy_tag_matched",
                     "type": "spoolbuddy_tag_matched",
                     "device_id": req.device_id,
                     "device_id": req.device_id,
                     "tag_uid": req.tag_uid,
                     "tag_uid": req.tag_uid,
-                    "tray_uuid": req.tray_uuid,
+                    "tray_uuid": tray_uuid,
                     "spool": {
                     "spool": {
                         "id": spool.id,
                         "id": spool.id,
                         "material": spool.material,
                         "material": spool.material,
@@ -508,24 +771,36 @@ async def nfc_tag_scanned(
                 }
                 }
             )
             )
             logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id)
             logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id)
+            if tray_uuid:
+                await _backfill_local_tray_uuid(db, spool, req.tag_uid, tray_uuid)
             return {"status": "ok", "matched": True, "spool_id": spool.id}
             return {"status": "ok", "matched": True, "spool_id": spool.id}
 
 
+    # What the tag itself says about the roll, so the kiosk's Quick Add can
+    # create the spool AMS sync would have created, not a blank PLA (#2685)
+    tag_data = None
+    if req.raw_blocks:
+        try:
+            tag_data = await spool_prefill_from_tag(db, req.raw_blocks)
+        except Exception:
+            logger.warning("Could not decode Bambu tag blocks for %s", req.tag_uid, exc_info=True)
+
     await ws_manager.broadcast(
     await ws_manager.broadcast(
         {
         {
             "type": "spoolbuddy_unknown_tag",
             "type": "spoolbuddy_unknown_tag",
             "device_id": req.device_id,
             "device_id": req.device_id,
             "tag_uid": req.tag_uid,
             "tag_uid": req.tag_uid,
-            "tray_uuid": req.tray_uuid,
+            "tray_uuid": tray_uuid,
             "sak": req.sak,
             "sak": req.sak,
             "tag_type": req.tag_type,
             "tag_type": req.tag_type,
+            "tag_data": tag_data,
         }
         }
     )
     )
     logger.info(
     logger.info(
         "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
         "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
         req.tag_uid,
         req.tag_uid,
         len(req.tag_uid or ""),
         len(req.tag_uid or ""),
-        req.tray_uuid,
-        len(req.tray_uuid or ""),
+        tray_uuid,
+        len(tray_uuid or ""),
         req.tag_type,
         req.tag_type,
         req.sak,
         req.sak,
     )
     )
@@ -551,10 +826,15 @@ async def nfc_tag_removed(
 @router.post("/nfc/write-tag")
 @router.post("/nfc/write-tag")
 async def nfc_write_tag(
 async def nfc_write_tag(
     req: WriteTagRequest,
     req: WriteTagRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
-    """Queue an NFC tag write command for a SpoolBuddy device."""
+    """Queue an NFC tag write command for a SpoolBuddy device.
+
+    The spool comes from the inventory in use: Spoolman when it is enabled,
+    otherwise Bambuddy's own. Built-in spools are kept after switching to
+    Spoolman, so one with the same id must not stand in for the Spoolman spool.
+    """
     from backend.app.models.spool import Spool
     from backend.app.models.spool import Spool
     from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
     from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
 
 
@@ -564,22 +844,20 @@ async def nfc_write_tag(
     if not device:
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
         raise HTTPException(status_code=404, detail="Device not registered")
 
 
-    # Try local DB first
-    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
-    spool = result.scalar_one_or_none()
+    sm_client = await _get_spoolman_client_or_none(db)
+    if sm_client is None and await _spoolman_mode(db):
+        raise HTTPException(status_code=503, detail="Spoolman is unavailable")
 
 
     nfc_warnings: list[str] = []
     nfc_warnings: list[str] = []
-    if spool:
+    if sm_client is None:
+        spool = (await db.execute(select(Spool).where(Spool.id == req.spool_id))).scalar_one_or_none()
+        if spool is None:
+            raise HTTPException(status_code=404, detail="Spool not found")
         ndef_data = encode_opentag3d(spool)
         ndef_data = encode_opentag3d(spool)
         data_origin = "local"
         data_origin = "local"
     else:
     else:
-        # Local DB miss — fall back to Spoolman when enabled
         from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
         from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 
 
-        sm_client = await _get_spoolman_client_or_none(db)
-        if sm_client is None:
-            raise HTTPException(status_code=404, detail="Spool not found")
-
         async with _translate_spoolbuddy_errors():
         async with _translate_spoolbuddy_errors():
             sm_spool = await sm_client.get_spool(req.spool_id)
             sm_spool = await sm_client.get_spool(req.spool_id)
 
 
@@ -595,19 +873,36 @@ async def nfc_write_tag(
                 detail="Spoolman spool has no material set — cannot encode NFC tag",
                 detail="Spoolman spool has no material set — cannot encode NFC tag",
             )
             )
 
 
+        # A Bambu spool created from the AMS is linked by its tray UUID in
+        # extra.tag; a sticker would replace it there, and the next AMS sync,
+        # no longer finding the spool, would create a duplicate (SM-C5). Not
+        # when Spoolman also keeps that UUID as a native tag (0.27+): the sync
+        # finds the spool by it first, and the sticker leaves it alone.
+        from backend.app.api.routes._spoolman_helpers import _extract_extra_str
+
+        extra_tag = _extract_extra_str(sm_spool.get("extra") or {}, "tag").strip().upper()
+        native_uids = {str(t.get("uid", "")).upper() for t in sm_spool.get("tags") or [] if isinstance(t, dict)}
+        if re.fullmatch(r"[0-9A-F]{32}", extra_tag) and extra_tag not in native_uids:
+            raise HTTPException(
+                status_code=409,
+                detail="This spool is linked to its Bambu RFID tag through the AMS; "
+                "a sticker would replace that link in Spoolman",
+            )
+
         ndef_data = encode_opentag3d_from_mapped(mapped)
         ndef_data = encode_opentag3d_from_mapped(mapped)
         data_origin = "spoolman"
         data_origin = "spoolman"
 
 
         # Warn when fields that drive NFC content are absent in Spoolman.
         # Warn when fields that drive NFC content are absent in Spoolman.
-        # color_name specifically must check the raw filament field, not the
-        # mapped value — _map_spoolman_spool falls back to the filament's
-        # subtype when color_name is unset (so LinkSpoolModal stops showing
-        # "Unknown color"), but the NFC tag should still warn when Spoolman
-        # has no genuine color_name on file. Without this, the fallback
-        # silently masks a real missing-data condition.
-        raw_filament: dict = sm_spool.get("filament") or {}
-        if not raw_filament.get("color_name"):
-            nfc_warnings.append("color_name not set in Spoolman — tag encodes empty color name")
+        # The colour name is read the way the tag gets it: from the spool's
+        # extra (where Bambuddy keeps it -- Spoolman's filament has no such
+        # field, so checking that warned on every spool, SM-C6), else the
+        # subtype stands in for it.
+        if mapped.get("color_name") is None:
+            nfc_warnings.append("color_name not set in Spoolman — tag encodes an empty color name")
+        elif mapped.get("color_name_is_synthesized"):
+            nfc_warnings.append(
+                f"color_name not set in Spoolman — tag encodes the subtype ({mapped['color_name']}) as its color name"
+            )
         if not mapped.get("nozzle_temp_min"):
         if not mapped.get("nozzle_temp_min"):
             nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C")
             nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C")
         if not mapped.get("subtype"):
         if not mapped.get("subtype"):
@@ -652,7 +947,7 @@ async def nfc_write_tag(
 @router.post("/nfc/write-result")
 @router.post("/nfc/write-result")
 async def nfc_write_result(
 async def nfc_write_result(
     req: WriteTagResultRequest,
     req: WriteTagResultRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Handle NFC tag write result from SpoolBuddy daemon."""
     """Handle NFC tag write result from SpoolBuddy daemon."""
@@ -688,6 +983,7 @@ async def nfc_write_result(
                         "spool_id": req.spool_id,
                         "spool_id": req.spool_id,
                         "tag_uid": req.tag_uid,
                         "tag_uid": req.tag_uid,
                         "message": "Spoolman not configured",
                         "message": "Spoolman not configured",
+                        "reason": "spoolman_not_configured",
                     }
                     }
                 )
                 )
                 raise HTTPException(
                 raise HTTPException(
@@ -702,28 +998,13 @@ async def nfc_write_result(
                 # one Spoolman spool, otherwise find_spool_by_tag returns
                 # one Spoolman spool, otherwise find_spool_by_tag returns
                 # whichever spool comes first in the cached list (usually the
                 # whichever spool comes first in the cached list (usually the
                 # older one) and the dashboard shows the wrong spool when the
                 # older one) and the dashboard shows the wrong spool when the
-                # tag is scanned. Before binding the new owner, clear the tag
-                # from any other spool that currently has it. Best-effort:
-                # cleanup failure does not block the write itself, but the
-                # warning surfaces in logs so a stale duplicate can be tracked
-                # down manually.
+                # tag is scanned. Before binding the new owner, clear extra.tag
+                # on every other spool that holds it -- JSON-encoded, as Spoolman
+                # refuses a bare "" (#3312). Best-effort: cleanup failure does
+                # not block the write itself; each failed spool is logged.
                 try:
                 try:
-                    cached_spools = await sm_client.get_spools()
-                    duplicate = await sm_client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools)
-                    if duplicate is not None and duplicate.get("id") != req.spool_id:
-                        await sm_client.merge_spool_extra(int(duplicate["id"]), {"tag": ""})
-                        logger.info(
-                            "Spoolman: cleared tag %s from previous holder spool %d before binding to spool %d",
-                            req.tag_uid,
-                            duplicate["id"],
-                            req.spool_id,
-                        )
-                except (SpoolmanNotFoundError, SpoolmanUnavailableError, SpoolmanClientError) as cleanup_exc:
-                    logger.warning(
-                        "Spoolman: failed to clear duplicate tag %s before binding to spool %d (proceeding anyway): %s",
-                        req.tag_uid,
-                        req.spool_id,
-                        cleanup_exc,
+                    await _clear_stale_tag_links(
+                        sm_client, tag=req.tag_uid, keep_spool_id=req.spool_id, log_context="SpoolBuddy tag write"
                     )
                     )
                 except Exception:
                 except Exception:
                     logger.exception(
                     logger.exception(
@@ -732,7 +1013,39 @@ async def nfc_write_result(
                         req.spool_id,
                         req.spool_id,
                     )
                     )
 
 
-                await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value})
+                # Spoolman 0.27+: the same tag as a native one. It was just written onto
+                # this spool, so a previous holder gives it up here too. Linked before
+                # extra.tag, as the step Spoolman can refuse; a failed extra.tag write
+                # takes back what was added.
+                uid = req.tag_uid.upper()
+                added_native = False
+                if await sm_client.has_tag_api():
+                    before = await sm_client.get_spool(req.spool_id)
+                    had = any(t.get("uid") == uid for t in before.get("tags") or [])
+                    # A spool that already holds the tag has nothing to link or move.
+                    if not had:
+                        holder = await sm_client.link_native_tag(req.spool_id, uid)
+                        if holder is not None and holder > 0:
+                            previous = holder
+                            await sm_client.unlink_native_tag(previous, uid)
+                            holder = await sm_client.link_native_tag(req.spool_id, uid)
+                            logger.info(
+                                "Spoolman: native tag %s moved from spool %d to %d", uid, previous, req.spool_id
+                            )
+                        if holder is not None:
+                            logger.warning("Native tag %s belongs to a filament or location, not linked", uid)
+                        added_native = holder is None
+                try:
+                    await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value})
+                except Exception:
+                    if added_native:
+                        try:
+                            await sm_client.unlink_native_tag(req.spool_id, uid)
+                        except (SpoolmanClientError, SpoolmanUnavailableError) as undo_exc:
+                            logger.warning(
+                                "Could not take back native tag %s from spool %d: %s", uid, req.spool_id, undo_exc
+                            )
+                    raise
                 logger.info(
                 logger.info(
                     "Spoolman tag written and linked: spool %d -> tag %s",
                     "Spoolman tag written and linked: spool %d -> tag %s",
                     req.spool_id,
                     req.spool_id,
@@ -756,6 +1069,10 @@ async def nfc_write_result(
                 # fall through to broadcast + raise 502 below
                 # fall through to broadcast + raise 502 below
 
 
             await db.commit()
             await db.commit()
+            # The tag moved between Spoolman spools, or the stale holders were
+            # cleared before a link that then failed: either way pages open
+            # elsewhere hold a tag -> spool map that changed (#3402).
+            await ws_manager.broadcast({"type": "inventory_changed"})
             if _tag_link_ok:
             if _tag_link_ok:
                 await ws_manager.broadcast(
                 await ws_manager.broadcast(
                     {
                     {
@@ -775,6 +1092,7 @@ async def nfc_write_result(
                         # Generic message — full exception (may contain internal URLs/hostnames)
                         # Generic message — full exception (may contain internal URLs/hostnames)
                         # is logged server-side only to prevent information leakage via WebSocket.
                         # is logged server-side only to prevent information leakage via WebSocket.
                         "message": "Spoolman link failed",
                         "message": "Spoolman link failed",
+                        "reason": "spoolman_link_failed",
                     }
                     }
                 )
                 )
                 raise HTTPException(
                 raise HTTPException(
@@ -799,6 +1117,7 @@ async def nfc_write_result(
                         "device_id": req.device_id,
                         "device_id": req.device_id,
                         "spool_id": req.spool_id,
                         "spool_id": req.spool_id,
                         "message": "Spool not found",
                         "message": "Spool not found",
+                        "reason": "spool_not_found",
                     }
                     }
                 )
                 )
                 return {"status": "ok", "linked": False, "message": "Spool not found"}
                 return {"status": "ok", "linked": False, "message": "Spool not found"}
@@ -836,7 +1155,7 @@ async def nfc_write_result(
 @router.post("/devices/{device_id}/cancel-write")
 @router.post("/devices/{device_id}/cancel-write")
 async def cancel_write(
 async def cancel_write(
     device_id: str,
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Cancel a pending write-tag command."""
     """Cancel a pending write-tag command."""
@@ -878,7 +1197,7 @@ async def scale_reading(
 @router.post("/scale/update-spool-weight")
 @router.post("/scale/update-spool-weight")
 async def update_spool_weight(
 async def update_spool_weight(
     req: UpdateSpoolWeightRequest,
     req: UpdateSpoolWeightRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Update spool's used weight from scale reading.
     """Update spool's used weight from scale reading.
@@ -895,6 +1214,8 @@ async def update_spool_weight(
     from backend.app.models.spool import Spool
     from backend.app.models.spool import Spool
 
 
     sm_client = await _get_spoolman_client_or_none(db)
     sm_client = await _get_spoolman_client_or_none(db)
+    if sm_client is None and await _spoolman_mode(db):
+        raise HTTPException(status_code=503, detail="Spoolman is unavailable")
 
 
     if sm_client is None:
     if sm_client is None:
         # Local mode — exclusive update, no Spoolman fallback.
         # Local mode — exclusive update, no Spoolman fallback.
@@ -907,7 +1228,11 @@ async def update_spool_weight(
         spool.weight_used = max(0, spool.label_weight - net_filament)
         spool.weight_used = max(0, spool.label_weight - net_filament)
         spool.last_scale_weight = req.weight_grams
         spool.last_scale_weight = req.weight_grams
         spool.last_weighed_at = datetime.now(timezone.utc)
         spool.last_weighed_at = datetime.now(timezone.utc)
+        # A measured figure, which the AMS remain% sync must not overwrite
+        # (as a hand-set one, see update_spool).
+        spool.weight_locked = True
         await db.commit()
         await db.commit()
+        await ws_manager.broadcast({"type": "inventory_changed"})
         logger.info(
         logger.info(
             "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
             "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
             spool.id,
             spool.id,
@@ -936,6 +1261,7 @@ async def update_spool_weight(
 
 
     async with _translate_spoolbuddy_errors():
     async with _translate_spoolbuddy_errors():
         await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
         await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
+    await ws_manager.broadcast({"type": "inventory_changed"})
 
 
     weight_used = max(0.0, label_weight - remaining_weight)
     weight_used = max(0.0, label_weight - remaining_weight)
     logger.info(
     logger.info(
@@ -957,7 +1283,7 @@ async def update_spool_weight(
 @router.post("/devices/{device_id}/calibration/tare")
 @router.post("/devices/{device_id}/calibration/tare")
 async def tare_scale(
 async def tare_scale(
     device_id: str,
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Set pending tare command for the device to pick up."""
     """Set pending tare command for the device to pick up."""
@@ -975,7 +1301,7 @@ async def tare_scale(
 async def set_tare_offset(
 async def set_tare_offset(
     device_id: str,
     device_id: str,
     req: SetTareRequest,
     req: SetTareRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Store tare offset reported by the daemon after executing a tare."""
     """Store tare offset reported by the daemon after executing a tare."""
@@ -999,7 +1325,7 @@ async def set_tare_offset(
 async def set_calibration_factor(
 async def set_calibration_factor(
     device_id: str,
     device_id: str,
     req: SetCalibrationFactorRequest,
     req: SetCalibrationFactorRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Calculate and store calibration factor from a known weight."""
     """Calculate and store calibration factor from a known weight."""
@@ -1036,7 +1362,7 @@ async def set_calibration_factor(
 @router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
 @router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
 async def get_calibration(
 async def get_calibration(
     device_id: str,
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
 ):
     """Get current calibration values for a device."""
     """Get current calibration values for a device."""
@@ -1057,7 +1383,7 @@ async def get_calibration(
 @router.get("/devices/{device_id}/display")
 @router.get("/devices/{device_id}/display")
 async def get_display_settings(
 async def get_display_settings(
     device_id: str,
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Read current display brightness and screen blank timeout for a device.
     """Read current display brightness and screen blank timeout for a device.
@@ -1080,7 +1406,7 @@ async def get_display_settings(
 async def update_display_settings(
 async def update_display_settings(
     device_id: str,
     device_id: str,
     req: DisplaySettingsRequest,
     req: DisplaySettingsRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Update display brightness and screen blank timeout for a device."""
     """Update display brightness and screen blank timeout for a device."""
@@ -1106,7 +1432,7 @@ async def update_display_settings(
 async def queue_system_config_update(
 async def queue_system_config_update(
     device_id: str,
     device_id: str,
     req: SystemConfigRequest,
     req: SystemConfigRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Queue update of SpoolBuddy .env config on the device."""
     """Queue update of SpoolBuddy .env config on the device."""
@@ -1143,7 +1469,7 @@ VALID_SYSTEM_COMMANDS = {"reboot", "shutdown", "restart_daemon", "restart_browse
 async def queue_system_command(
 async def queue_system_command(
     device_id: str,
     device_id: str,
     req: SystemCommandRequest,
     req: SystemCommandRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     # Aligns with the rest of the kiosk-scoped device routes (calibration,
     # Aligns with the rest of the kiosk-scoped device routes (calibration,
     # display, cancel-write, command-result — all INVENTORY_UPDATE). The
     # display, cancel-write, command-result — all INVENTORY_UPDATE). The
     # previous SETTINGS_UPDATE gate locked operators out of the QuickMenu's
     # previous SETTINGS_UPDATE gate locked operators out of the QuickMenu's
@@ -1179,7 +1505,7 @@ async def queue_system_command(
 async def system_command_result(
 async def system_command_result(
     device_id: str,
     device_id: str,
     req: SystemCommandResultRequest,
     req: SystemCommandResultRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Receive completion status for queued system command from daemon."""
     """Receive completion status for queued system command from daemon."""
@@ -1220,8 +1546,8 @@ async def system_command_result(
 async def queue_diagnostic(
 async def queue_diagnostic(
     device_id: str,
     device_id: str,
     diagnostic: str,
     diagnostic: str,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Queue a hardware diagnostic to run on the SpoolBuddy device.
     """Queue a hardware diagnostic to run on the SpoolBuddy device.
 
 
@@ -1252,7 +1578,7 @@ async def queue_diagnostic(
 async def get_diagnostic_result(
 async def get_diagnostic_result(
     device_id: str,
     device_id: str,
     diagnostic: str,
     diagnostic: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
 ):
     """Get the latest diagnostic result for a device.
     """Get the latest diagnostic result for a device.
@@ -1282,7 +1608,7 @@ async def get_diagnostic_result(
 async def report_diagnostic_result(
 async def report_diagnostic_result(
     device_id: str,
     device_id: str,
     req: DiagnosticResultRequest,
     req: DiagnosticResultRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Report diagnostic result from SpoolBuddy device."""
     """Report diagnostic result from SpoolBuddy device."""
@@ -1314,7 +1640,7 @@ async def report_diagnostic_result(
 @router.get("/devices/{device_id}/update-check")
 @router.get("/devices/{device_id}/update-check")
 async def check_daemon_update(
 async def check_daemon_update(
     device_id: str,
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
 ):
     """Check if the SpoolBuddy daemon needs updating to match the Bambuddy backend version."""
     """Check if the SpoolBuddy daemon needs updating to match the Bambuddy backend version."""
@@ -1339,7 +1665,7 @@ async def check_daemon_update(
 async def trigger_daemon_update(
 async def trigger_daemon_update(
     device_id: str,
     device_id: str,
     req: dict | None = None,
     req: dict | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     # Aligns with the rest of the kiosk-scoped device routes (calibration,
     # Aligns with the rest of the kiosk-scoped device routes (calibration,
     # display, cancel-write, system/command — all INVENTORY_UPDATE).
     # display, cancel-write, system/command — all INVENTORY_UPDATE).
     # SETTINGS_UPDATE is on the API-key deny-list, which blocks the Update
     # SETTINGS_UPDATE is on the API-key deny-list, which blocks the Update
@@ -1412,41 +1738,6 @@ async def get_ssh_public_key(
         raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e
         raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e
 
 
 
 
-@router.post("/devices/{device_id}/update-status")
-async def report_update_status(
-    device_id: str,
-    req: UpdateStatusRequest,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-):
-    """Daemon reports update progress back to the backend."""
-    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
-    device = result.scalar_one_or_none()
-    if not device:
-        raise HTTPException(status_code=404, detail="Device not registered")
-
-    device.update_status = req.status
-    device.update_message = req.message
-    # Only "complete" clears pending_command here. "error" leaves it set so the user can retry
-    # via the UI. The SSH service's own _update_progress clears on both "complete" and "error"
-    # because it owns the full update lifecycle end-to-end.
-    if req.status == "complete":
-        device.pending_command = None
-    await db.commit()
-
-    logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, req.status, req.message)
-    await ws_manager.broadcast(
-        {
-            "type": "spoolbuddy_update",
-            "device_id": device_id,
-            "update_status": req.status,
-            "update_message": req.message,
-        }
-    )
-
-    return {"status": "ok"}
-
-
 # --- Background watchdog ---
 # --- Background watchdog ---
 
 
 
 

+ 194 - 88
backend/app/api/routes/spoolman.py

@@ -11,18 +11,27 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool, spoolman_net_weight
 from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool, spoolman_net_weight
-from backend.app.api.routes.spoolman_inventory import _clear_stale_tag_links
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.api.routes.spoolman_inventory import _announce_freed_slots, _clear_stale_tag_links
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
+from backend.app.core.websocket import ws_manager
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.services import slot_unlink_grace
-from backend.app.services.printer_manager import printer_manager
+from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId
+from backend.app.services import slot_unlink_grace, spool_swaps
+from backend.app.services.ams_slot_presence import spool_present
+from backend.app.services.inventory_mode import spoolman_owns_assignments
+from backend.app.services.printer_manager import PrinterManager, printer_manager
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.spool_filament_preset import resolve_spoolman_preset
 from backend.app.services.spool_filament_preset import resolve_spoolman_preset
@@ -34,6 +43,7 @@ from backend.app.services.spoolman import (
     get_spoolman_client,
     get_spoolman_client,
     init_spoolman_client,
     init_spoolman_client,
 )
 )
+from backend.app.services.spoolman_slot_sync import write_slot_results
 from backend.app.utils.filament_ids import (
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_FILAMENT_IDS,
     filament_id_to_setting_id,
     filament_id_to_setting_id,
@@ -52,6 +62,8 @@ class SpoolmanStatus(BaseModel):
     enabled: bool
     enabled: bool
     connected: bool
     connected: bool
     url: str | None
     url: str | None
+    # Spoolman 0.27+ links tags natively; the settings offer the migration then.
+    native_tags: bool = False
 
 
 
 
 class SkippedSpool(BaseModel):
 class SkippedSpool(BaseModel):
@@ -102,7 +114,7 @@ async def get_spoolman_settings(db: AsyncSession) -> dict:
 
 
 @router.get("/status", response_model=SpoolmanStatus)
 @router.get("/status", response_model=SpoolmanStatus)
 async def get_spoolman_status(
 async def get_spoolman_status(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
 ):
     """Get Spoolman integration status.
     """Get Spoolman integration status.
@@ -128,6 +140,7 @@ async def get_spoolman_status(
     enabled, url = sm["enabled"], sm["url"]
     enabled, url = sm["enabled"], sm["url"]
 
 
     connected = False
     connected = False
+    native_tags = False
     if enabled and url:
     if enabled and url:
         client = await get_spoolman_client()
         client = await get_spoolman_client()
         if not client or client.base_url != url.rstrip("/"):
         if not client or client.base_url != url.rstrip("/"):
@@ -146,17 +159,20 @@ async def get_spoolman_status(
                 client = None
                 client = None
         if client:
         if client:
             connected = await client.health_check()
             connected = await client.health_check()
+            # Asked once per client and cached, so the 30-second poll costs nothing more.
+            native_tags = connected and await client.has_tag_api()
 
 
     return SpoolmanStatus(
     return SpoolmanStatus(
         enabled=enabled,
         enabled=enabled,
         connected=connected,
         connected=connected,
         url=url if url else None,
         url=url if url else None,
+        native_tags=native_tags,
     )
     )
 
 
 
 
 @router.post("/connect")
 @router.post("/connect")
 async def connect_spoolman(
 async def connect_spoolman(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Connect to Spoolman server using configured URL."""
     """Connect to Spoolman server using configured URL."""
@@ -209,11 +225,40 @@ async def disconnect_spoolman(
     return {"success": True, "message": "Disconnected from Spoolman"}
     return {"success": True, "message": "Disconnected from Spoolman"}
 
 
 
 
+def _empty_slot_may_be_unlinked(state, tray_data: dict) -> bool:
+    """The automatic pass's guards for a tray that reads empty (#3186): not while
+    the printer prints, where a runout reads empty, and not in a slot whose
+    presence bit says a spool is there. Its grace period is left out: the sync
+    was asked for.
+    """
+    if str(getattr(state, "state", "") or "").upper() in PrinterManager.ACTIVE_PRINT_STATES:
+        return False
+    return spool_present(tray_data) is not True
+
+
+async def _refresh_slot_preset(db: AsyncSession, spool: dict, tray, printer_id: int, ams_id: int) -> None:
+    """The slot's preset row follows the spool synced into it, as in the
+    automatic pass: the slot card reads that row ahead of the live tray, so it
+    kept the previous spool's preset name (SSA-2)."""
+    from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spoolman_spool
+
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db,
+        spoolman_spool=spool,
+        tray_info_idx=tray.tray_info_idx or "",
+        tray_sub_brands=tray.tray_sub_brands or "",
+        tray_type=tray.tray_type or "",
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray.tray_id,
+    )
+
+
 @router.post("/sync/{printer_id}", response_model=SyncResult)
 @router.post("/sync/{printer_id}", response_model=SyncResult)
 async def sync_printer_ams(
 async def sync_printer_ams(
     printer_id: int,
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
 ):
     """Sync AMS data from a specific printer to Spoolman."""
     """Sync AMS data from a specific printer to Spoolman."""
     # Check if Spoolman is enabled and connected
     # Check if Spoolman is enabled and connected
@@ -303,30 +348,36 @@ async def sync_printer_ams(
             detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
             detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
         )
         )
 
 
-    # Load inventory weights as fallback (when AMS MQTT data lacks remain values)
+    # Load inventory weights as fallback (when AMS MQTT data lacks remain values).
+    # Inert in Spoolman mode, as in the automatic pass (main.on_ams_change): the
+    # built-in slot rows are left over from before the switch, and a new Spoolman
+    # spool would be seeded with an unrelated weight (SM-C3).
     inv_weights: dict[tuple[int, int], float] = {}
     inv_weights: dict[tuple[int, int], float] = {}
-    try:
-        assign_result = await db.execute(
-            select(SpoolAssignment)
-            .options(selectinload(SpoolAssignment.spool))
-            .where(SpoolAssignment.printer_id == printer_id)
-        )
-        for assignment in assign_result.scalars().all():
-            spool = assignment.spool
-            if spool and spool.label_weight > 0:
-                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
-                inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
-    except Exception as e:
-        logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
+    if not await spoolman_owns_assignments(db):
+        try:
+            assign_result = await db.execute(
+                select(SpoolAssignment)
+                .options(selectinload(SpoolAssignment.spool))
+                .where(SpoolAssignment.printer_id == printer_id)
+            )
+            for assignment in assign_result.scalars().all():
+                spool = assignment.spool
+                if spool and spool.label_weight > 0:
+                    remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
+                    inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
+        except Exception as e:
+            logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
 
 
     # Load existing Spoolman slot assignments for the no-RFID fallback path
     # Load existing Spoolman slot assignments for the no-RFID fallback path
     spoolman_slot_map: dict[tuple[int, int], int] = {}
     spoolman_slot_map: dict[tuple[int, int], int] = {}
+    slot_map_read = False
     try:
     try:
         slot_result = await db.execute(
         slot_result = await db.execute(
             select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
             select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
         )
         )
         for slot in slot_result.scalars().all():
         for slot in slot_result.scalars().all():
             spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
             spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+        slot_map_read = True
     except Exception as e:
     except Exception as e:
         logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
         logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
 
 
@@ -347,7 +398,8 @@ async def sync_printer_ams(
             tray_id_raw = int(tray_data.get("id", 0))
             tray_id_raw = int(tray_data.get("id", 0))
             tray = client.parse_ams_tray(ams_id, tray_data)
             tray = client.parse_ams_tray(ams_id, tray_data)
             if not tray:
             if not tray:
-                empty_slots.append((ams_id, tray_id_raw))
+                if _empty_slot_may_be_unlinked(state, tray_data):
+                    empty_slots.append((ams_id, tray_id_raw))
                 continue
                 continue
 
 
             spool_tag = (
             spool_tag = (
@@ -380,6 +432,7 @@ async def sync_printer_ams(
                         if not spool_exists:
                         if not spool_exists:
                             cached_spools.append(sync_result)
                             cached_spools.append(sync_result)
                             logger.debug("Added newly created spool %s to cache", sync_result["id"])
                             logger.debug("Added newly created spool %s to cache", sync_result["id"])
+                        await _refresh_slot_preset(db, sync_result, tray, printer_id, ams_id)
                     logger.info(
                     logger.info(
                         "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
                         "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
                     )
                     )
@@ -408,29 +461,18 @@ async def sync_printer_ams(
                 logger.error(error_msg)
                 logger.error(error_msg)
                 errors.append(error_msg)
                 errors.append(error_msg)
 
 
-    # Persist slot assignment changes to the local table
+    # Persist slot assignment changes to the local table: only what changed,
+    # and only where the slot still holds what was read (SSA-3).
     if slot_changes or empty_slots:
     if slot_changes or empty_slots:
         try:
         try:
-            for ams_id, tray_id, spool_id in slot_changes:
-                await db.execute(
-                    text(
-                        "INSERT INTO spoolman_slot_assignments"
-                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                        " ON CONFLICT(printer_id, ams_id, tray_id)"
-                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                    ),
-                    {"printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
-                )
-            for ams_id, tray_id in empty_slots:
-                await db.execute(
-                    delete(SpoolmanSlotAssignment).where(
-                        SpoolmanSlotAssignment.printer_id == printer_id,
-                        SpoolmanSlotAssignment.ams_id == ams_id,
-                        SpoolmanSlotAssignment.tray_id == tray_id,
-                    )
-                )
-            await db.commit()
+            await write_slot_results(
+                db,
+                {(printer_id, a_id, t_id): spool_id for (a_id, t_id), spool_id in spoolman_slot_map.items()}
+                if slot_map_read
+                else None,
+                [(printer_id, a_id, t_id, spool_id) for a_id, t_id, spool_id in slot_changes],
+                [(printer_id, a_id, t_id) for a_id, t_id in empty_slots],
+            )
         except Exception as e:
         except Exception as e:
             await db.rollback()
             await db.rollback()
             logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
             logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
@@ -447,8 +489,9 @@ async def sync_printer_ams(
 
 
 @router.post("/sync-all", response_model=SyncResult)
 @router.post("/sync-all", response_model=SyncResult)
 async def sync_all_printers(
 async def sync_all_printers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Sync AMS data from all connected printers to Spoolman."""
     """Sync AMS data from all connected printers to Spoolman."""
     # Check if Spoolman is enabled
     # Check if Spoolman is enabled
@@ -470,7 +513,7 @@ async def sync_all_printers(
 
 
     # Get all active printers
     # Get all active printers
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
 
     total_synced = 0
     total_synced = 0
     all_skipped: list[SkippedSpool] = []
     all_skipped: list[SkippedSpool] = []
@@ -496,24 +539,28 @@ async def sync_all_printers(
 
 
     # Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values)
     # Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values)
     # Key: (printer_id, ams_id, tray_id) → remaining_weight in grams
     # Key: (printer_id, ams_id, tray_id) → remaining_weight in grams
+    # Inert in Spoolman mode, as in sync_printer_ams above (SM-C3).
     inventory_weights: dict[tuple[int, int, int], float] = {}
     inventory_weights: dict[tuple[int, int, int], float] = {}
-    try:
-        assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
-        for assignment in assign_result.scalars().all():
-            spool = assignment.spool
-            if spool and spool.label_weight > 0:
-                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
-                inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
-    except Exception as e:
-        logger.debug("Could not load inventory assignments for weight fallback: %s", e)
+    if not await spoolman_owns_assignments(db):
+        try:
+            assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
+            for assignment in assign_result.scalars().all():
+                spool = assignment.spool
+                if spool and spool.label_weight > 0:
+                    remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
+                    inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
+        except Exception as e:
+            logger.debug("Could not load inventory assignments for weight fallback: %s", e)
 
 
     # Load all Spoolman slot assignments for the no-RFID fallback
     # Load all Spoolman slot assignments for the no-RFID fallback
     # Key: (printer_id, ams_id, tray_id) → spoolman_spool_id
     # Key: (printer_id, ams_id, tray_id) → spoolman_spool_id
     all_slot_map: dict[tuple[int, int, int], int] = {}
     all_slot_map: dict[tuple[int, int, int], int] = {}
+    all_slot_map_read = False
     try:
     try:
         slot_result = await db.execute(select(SpoolmanSlotAssignment))
         slot_result = await db.execute(select(SpoolmanSlotAssignment))
         for slot in slot_result.scalars().all():
         for slot in slot_result.scalars().all():
             all_slot_map[(slot.printer_id, slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
             all_slot_map[(slot.printer_id, slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+        all_slot_map_read = True
     except Exception as e:
     except Exception as e:
         logger.warning("Could not load Spoolman slot assignments: %s", e)
         logger.warning("Could not load Spoolman slot assignments: %s", e)
 
 
@@ -565,7 +612,8 @@ async def sync_all_printers(
                 tray_id_raw = int(tray_data.get("id", 0))
                 tray_id_raw = int(tray_data.get("id", 0))
                 tray = client.parse_ams_tray(ams_id, tray_data)
                 tray = client.parse_ams_tray(ams_id, tray_data)
                 if not tray:
                 if not tray:
-                    all_empty_slots.append((printer.id, ams_id, tray_id_raw))
+                    if _empty_slot_may_be_unlinked(state, tray_data):
+                        all_empty_slots.append((printer.id, ams_id, tray_id_raw))
                     continue
                     continue
 
 
                 spool_tag = (
                 spool_tag = (
@@ -598,6 +646,7 @@ async def sync_all_printers(
                             if not spool_exists:
                             if not spool_exists:
                                 cached_spools.append(sync_result)
                                 cached_spools.append(sync_result)
                                 logger.debug("Added newly created spool %s to cache", sync_result["id"])
                                 logger.debug("Added newly created spool %s to cache", sync_result["id"])
+                            await _refresh_slot_preset(db, sync_result, tray, printer.id, ams_id)
                     elif spool_tag and not auto_add_unknown_rfid:
                     elif spool_tag and not auto_add_unknown_rfid:
                         all_skipped.append(
                         all_skipped.append(
                             SkippedSpool(
                             SkippedSpool(
@@ -621,29 +670,11 @@ async def sync_all_printers(
                 except Exception as e:
                 except Exception as e:
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
 
 
-    # Persist slot assignment changes across all printers
+    # Persist slot assignment changes across all printers: only what changed,
+    # and only where the slot still holds what was read (SSA-3).
     if all_slot_changes or all_empty_slots:
     if all_slot_changes or all_empty_slots:
         try:
         try:
-            for p_id, ams_id, tray_id, spool_id in all_slot_changes:
-                await db.execute(
-                    text(
-                        "INSERT INTO spoolman_slot_assignments"
-                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                        " ON CONFLICT(printer_id, ams_id, tray_id)"
-                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                    ),
-                    {"printer_id": p_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
-                )
-            for p_id, ams_id, tray_id in all_empty_slots:
-                await db.execute(
-                    delete(SpoolmanSlotAssignment).where(
-                        SpoolmanSlotAssignment.printer_id == p_id,
-                        SpoolmanSlotAssignment.ams_id == ams_id,
-                        SpoolmanSlotAssignment.tray_id == tray_id,
-                    )
-                )
-            await db.commit()
+            await write_slot_results(db, all_slot_map if all_slot_map_read else None, all_slot_changes, all_empty_slots)
         except Exception as e:
         except Exception as e:
             await db.rollback()
             await db.rollback()
             logger.error("Error persisting Spoolman slot assignments: %s", e)
             logger.error("Error persisting Spoolman slot assignments: %s", e)
@@ -660,7 +691,7 @@ async def sync_all_printers(
 
 
 @router.get("/spools")
 @router.get("/spools")
 async def get_spools(
 async def get_spools(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
 ):
     """Get all spools from Spoolman."""
     """Get all spools from Spoolman."""
@@ -685,7 +716,7 @@ async def get_spools(
 
 
 @router.get("/filaments")
 @router.get("/filaments")
 async def get_filaments(
 async def get_filaments(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
 ):
     """Get all filaments from Spoolman."""
     """Get all filaments from Spoolman."""
@@ -722,7 +753,7 @@ class UnlinkedSpool(BaseModel):
 
 
 @router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
 @router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
 async def get_unlinked_spools(
 async def get_unlinked_spools(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
 ):
     """Get all Spoolman spools not currently assigned to an AMS slot."""
     """Get all Spoolman spools not currently assigned to an AMS slot."""
@@ -776,7 +807,7 @@ async def get_unlinked_spools(
 
 
 @router.get("/spools/linked")
 @router.get("/spools/linked")
 async def get_linked_spools(
 async def get_linked_spools(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
 ):
     """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
     """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
@@ -824,16 +855,17 @@ class LinkSpoolRequest(BaseModel):
     tray_uuid: str | None = None
     tray_uuid: str | None = None
     tag_uid: str | None = None
     tag_uid: str | None = None
     printer_id: int | None = None
     printer_id: int | None = None
-    ams_id: int | None = None
-    tray_id: int | None = None
+    ams_id: SlotAmsId | None = None
+    tray_id: SlotTrayId | None = None
 
 
 
 
 @router.post("/spools/{spool_id}/link")
 @router.post("/spools/{spool_id}/link")
 async def link_spool(
 async def link_spool(
     spool_id: int,
     spool_id: int,
     request: LinkSpoolRequest,
     request: LinkSpoolRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Link a Spoolman spool to an AMS tag by setting Spoolman extra.tag."""
     """Link a Spoolman spool to an AMS tag by setting Spoolman extra.tag."""
     sm = await get_spoolman_settings(db)
     sm = await get_spoolman_settings(db)
@@ -871,13 +903,48 @@ async def link_spool(
     # that field is user-managed in Spoolman. Slot assignment is stored locally.
     # that field is user-managed in Spoolman. Slot assignment is stored locally.
     printer_context: tuple[int, int, int] | None = None
     printer_context: tuple[int, int, int] | None = None
     if request.printer_id is not None and request.ams_id is not None and request.tray_id is not None:
     if request.printer_id is not None and request.ams_id is not None and request.tray_id is not None:
+        printer_scope.ensure(request.printer_id)
         printer_result = await db.execute(select(Printer).where(Printer.id == request.printer_id))
         printer_result = await db.execute(select(Printer).where(Printer.id == request.printer_id))
         if not printer_result.scalar_one_or_none():
         if not printer_result.scalar_one_or_none():
             raise HTTPException(status_code=404, detail="Printer not found")
             raise HTTPException(status_code=404, detail="Printer not found")
         printer_context = (request.printer_id, request.ams_id, request.tray_id)
         printer_context = (request.printer_id, request.ams_id, request.tray_id)
+        # An archived spool on a slot would be charged the next print's usage;
+        # refused before anything is written, as the slot assignment does (SSA-8).
+        try:
+            linked = await client.get_spool(spool_id)
+        except SpoolmanNotFoundError:
+            raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
+        except SpoolmanClientError:
+            raise HTTPException(status_code=502, detail="Spoolman rejected the request")
+        except SpoolmanUnavailableError:
+            raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+        if linked.get("archived"):
+            raise HTTPException(status_code=400, detail="Cannot assign an archived spool")
 
 
     try:
     try:
-        await client.merge_spool_extra(spool_id, {"tag": json.dumps(spool_tag)})
+        # Spoolman 0.27+: a tray UUID also becomes a native tag of the spool. The
+        # 16-character values this route takes are either a slot's fallback ID or
+        # the AMS's padded chip UID - neither is what a reader sees, so they stay in
+        # extra.tag only; the AMS sync adds the real chip UID when it reads the tag.
+        # The native link goes first, as the step Spoolman can refuse, so a refusal
+        # leaves extra.tag untouched; a failed extra.tag write takes back what was added.
+        added_native = False
+        if len(spool_tag) == 32 and await client.has_tag_api():
+            before = await client.get_spool(spool_id)
+            had = any(t.get("uid") == spool_tag for t in before.get("tags") or [])
+            holder = await client.claim_native_tag(spool_id, spool_tag, "bambu")
+            if holder is not None:
+                logger.warning("Native tag %s belongs to spool %s, left there", spool_tag, holder)
+            added_native = holder is None and not had
+        try:
+            await client.merge_spool_extra(spool_id, {"tag": json.dumps(spool_tag)})
+        except Exception:
+            if added_native:
+                try:
+                    await client.unlink_native_tag(spool_id, spool_tag)
+                except (SpoolmanClientError, SpoolmanUnavailableError) as exc:
+                    logger.warning("Could not take back native tag %s from spool %s: %s", spool_tag, spool_id, exc)
+            raise
     except SpoolmanNotFoundError:
     except SpoolmanNotFoundError:
         raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
         raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
     except SpoolmanClientError:
     except SpoolmanClientError:
@@ -899,6 +966,7 @@ async def link_spool(
                 ),
                 ),
                 {"printer_id": p_id, "ams_id": a_id, "tray_id": t_id, "spool_id": spool_id},
                 {"printer_id": p_id, "ams_id": a_id, "tray_id": t_id, "spool_id": spool_id},
             )
             )
+            await spool_swaps.record_assignment(db, p_id, a_id, t_id, spool_id, spool_swaps.SPOOLMAN)
             await db.commit()
             await db.commit()
             slot_unlink_grace.forget_slot(p_id, a_id, t_id)
             slot_unlink_grace.forget_slot(p_id, a_id, t_id)
         except Exception as e:
         except Exception as e:
@@ -936,6 +1004,11 @@ async def link_spool(
             else "via /spools/{id}/link"
             else "via /spools/{id}/link"
         ),
         ),
     )
     )
+    # Pages open elsewhere find a slot's spool by its tag, and by the slot row
+    # when one was written: both changed (#3402).
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    if printer_context:
+        await _announce_freed_slots([printer_context])
 
 
     # Auto-configure AMS slot via MQTT (best-effort; tag link and slot assignment already persisted)
     # Auto-configure AMS slot via MQTT (best-effort; tag link and slot assignment already persisted)
     if printer_context:
     if printer_context:
@@ -1098,7 +1171,9 @@ async def link_spool(
                     setting_id=effective_setting_id,
                     setting_id=effective_setting_id,
                 )
                 )
 
 
+                sent_cali_idx: int | None = None
                 if matching_kp and matching_kp.cali_idx is not None:
                 if matching_kp and matching_kp.cali_idx is not None:
+                    sent_cali_idx = matching_kp.cali_idx
                     cali_filament_id = (
                     cali_filament_id = (
                         printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
                         printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
                     ) or effective_tray_info_idx
                     ) or effective_tray_info_idx
@@ -1131,6 +1206,7 @@ async def link_spool(
                         live_tray = _find_tray_in_ams_data(ams_raw, a_id, t_id)
                         live_tray = _find_tray_in_ams_data(ams_raw, a_id, t_id)
                     live_cali_idx = (live_tray or {}).get("cali_idx")
                     live_cali_idx = (live_tray or {}).get("cali_idx")
                     if live_cali_idx is not None and live_cali_idx >= 0:
                     if live_cali_idx is not None and live_cali_idx >= 0:
+                        sent_cali_idx = live_cali_idx
                         mqtt_client.extrusion_cali_sel(
                         mqtt_client.extrusion_cali_sel(
                             ams_id=a_id,
                             ams_id=a_id,
                             tray_id=t_id,
                             tray_id=t_id,
@@ -1139,6 +1215,18 @@ async def link_spool(
                             nozzle_diameter=nozzle_diameter,
                             nozzle_diameter=nozzle_diameter,
                         )
                         )
 
 
+                # Read-back verification, as the assign paths register it
+                # (#2582); the K-profile only when one was sent.
+                mqtt_client.register_assignment_verification(
+                    ams_id=a_id,
+                    tray_id=t_id,
+                    tray_info_idx=effective_tray_info_idx,
+                    tray_color=tray_color,
+                    cali_idx=sent_cali_idx,
+                )
+                # Fresh telemetry for the check to compare against.
+                mqtt_client.request_status_update()
+
                 logger.info(
                 logger.info(
                     "Auto-configured AMS slot ams=%d tray=%d after linking Spoolman spool %d on printer %d",
                     "Auto-configured AMS slot ams=%d tray=%d after linking Spoolman spool %d on printer %d",
                     a_id,
                     a_id,
@@ -1167,7 +1255,7 @@ async def link_spool(
 @router.post("/spools/{spool_id}/unlink")
 @router.post("/spools/{spool_id}/unlink")
 async def unlink_spool(
 async def unlink_spool(
     spool_id: int,
     spool_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
 ):
     """Unlink a Spoolman spool from AMS by clearing Spoolman extra.tag."""
     """Unlink a Spoolman spool from AMS by clearing Spoolman extra.tag."""
@@ -1198,6 +1286,9 @@ async def unlink_spool(
     # deadlock (asyncio.Lock is not reentrant).
     # deadlock (asyncio.Lock is not reentrant).
     try:
     try:
         await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
         await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
+        # And the native tags, so an unlinked spool is found by none of them.
+        if await client.has_tag_api():
+            await client.unlink_all_native_tags(await client.get_spool(spool_id))
     except SpoolmanNotFoundError:
     except SpoolmanNotFoundError:
         raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
         raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
     except SpoolmanClientError:
     except SpoolmanClientError:
@@ -1207,7 +1298,12 @@ async def unlink_spool(
 
 
     # Remove local slot assignment for this spool (all slots — a spool can only be in one at a time)
     # Remove local slot assignment for this spool (all slots — a spool can only be in one at a time)
     try:
     try:
-        await db.execute(delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spool_id))
+        result = await db.execute(
+            delete(SpoolmanSlotAssignment)
+            .where(SpoolmanSlotAssignment.spoolman_spool_id == spool_id)
+            .returning(SpoolmanSlotAssignment.printer_id, SpoolmanSlotAssignment.ams_id, SpoolmanSlotAssignment.tray_id)
+        )
+        freed = list(result.tuples())
         await db.commit()
         await db.commit()
     except Exception:
     except Exception:
         await db.rollback()
         await db.rollback()
@@ -1215,6 +1311,8 @@ async def unlink_spool(
         raise HTTPException(status_code=500, detail="Failed to remove local slot assignment")
         raise HTTPException(status_code=500, detail="Failed to remove local slot assignment")
 
 
     logger.info("Unlinked Spoolman spool %s", spool_id)
     logger.info("Unlinked Spoolman spool %s", spool_id)
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
     return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
 
 
 
 
@@ -1227,8 +1325,9 @@ class CreateSpoolFromSlotRequest(BaseModel):
 @router.post("/spools/from-slot")
 @router.post("/spools/from-slot")
 async def create_spool_from_slot(
 async def create_spool_from_slot(
     req: CreateSpoolFromSlotRequest,
     req: CreateSpoolFromSlotRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Explicit user action: create a Spoolman spool from an AMS slot's current tray data.
     """Explicit user action: create a Spoolman spool from an AMS slot's current tray data.
 
 
@@ -1250,6 +1349,7 @@ async def create_spool_from_slot(
     if not await client.health_check():
     if not await client.health_check():
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
 
+    printer_scope.ensure(req.printer_id)
     result = await db.execute(select(Printer).where(Printer.id == req.printer_id))
     result = await db.execute(select(Printer).where(Printer.id == req.printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
     if not printer:
     if not printer:
@@ -1323,6 +1423,9 @@ async def create_spool_from_slot(
                     "spool_id": sync_result["id"],
                     "spool_id": sync_result["id"],
                 },
                 },
             )
             )
+            await spool_swaps.record_assignment(
+                db, req.printer_id, req.ams_id, req.tray_id, sync_result["id"], spool_swaps.SPOOLMAN
+            )
             await db.commit()
             await db.commit()
             slot_unlink_grace.forget_slot(req.printer_id, req.ams_id, req.tray_id)
             slot_unlink_grace.forget_slot(req.printer_id, req.ams_id, req.tray_id)
         except Exception as exc:
         except Exception as exc:
@@ -1333,4 +1436,7 @@ async def create_spool_from_slot(
                 detail=f"Spool created in Spoolman but slot assignment failed: {exc}",
                 detail=f"Spool created in Spoolman but slot assignment failed: {exc}",
             ) from exc
             ) from exc
 
 
+    if sync_result.get("id"):
+        await ws_manager.broadcast({"type": "inventory_changed"})
+        await _announce_freed_slots([(req.printer_id, req.ams_id, req.tray_id)])
     return {"success": True, "spool_id": sync_result.get("id")}
     return {"success": True, "spool_id": sync_result.get("id")}

File diff suppressed because it is too large
+ 509 - 102
backend/app/api/routes/spoolman_inventory.py


+ 82 - 31
backend/app/api/routes/support.py

@@ -177,7 +177,7 @@ async def get_logs(
     limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
     limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
     level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
     level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
     search: str | None = Query(None, description="Search in message or logger name"),
     search: str | None = Query(None, description="Search in message or logger name"),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Get recent application log entries with optional filtering."""
     """Get recent application log entries with optional filtering."""
     entries, total_lines = read_log_entries(limit=limit, level_filter=level, search=search)
     entries, total_lines = read_log_entries(limit=limit, level_filter=level, search=search)
@@ -222,21 +222,20 @@ def _sanitize_path(path: str) -> str:
 
 
 
 
 def _detect_docker_network_mode() -> str:
 def _detect_docker_network_mode() -> str:
-    """Detect Docker network mode by checking for host-level interfaces.
+    """The container's network mode as the connection diagnostic reads it:
+    "host", "bridge", "macvlan", "ipvlan", "ipvlan_l3", or "unknown".
 
 
-    In host mode the container shares the host network namespace, so Docker
-    infrastructure interfaces (docker0, br-*, veth*) are visible.  In bridge
-    mode the container is isolated and only sees its own veth (named eth0).
+    This used to be its own copy of the diagnostic's first rule only, so it
+    called a macvlan container bridge (#3417). Shells out to ``ip``: call it
+    off the event loop.
     """
     """
-    try:
-        import socket
+    from backend.app.services.discovery import detect_container_runtime
+    from backend.app.services.printer_diagnostic import _detect_container_network_mode
 
 
-        for _idx, name in socket.if_nameindex():
-            if name.startswith(("docker", "br-", "veth", "virbr")):
-                return "host"
+    try:
+        return _detect_container_network_mode(detect_container_runtime()) or "unknown"
     except Exception:
     except Exception:
-        pass
-    return "bridge"
+        return "unknown"
 
 
 
 
 def _mask_subnet(subnet: str) -> str:
 def _mask_subnet(subnet: str) -> str:
@@ -365,6 +364,7 @@ def _collect_process_info() -> dict:
         out["connections"] = len(proc.net_connections(kind="inet"))
         out["connections"] = len(proc.net_connections(kind="inet"))
     except Exception:
     except Exception:
         pass
         pass
+    out.update(_collect_fd_info(proc))
 
 
     # Children by executable name only. The count per name is what identifies a
     # Children by executable name only. The count per name is what identifies a
     # leak; the arguments would leak credentials.
     # leak; the arguments would leak credentials.
@@ -411,6 +411,72 @@ def _collect_process_info() -> dict:
     return out
     return out
 
 
 
 
+def _fd_kind(target: str) -> str:
+    """What an open descriptor points at, without saying where (#2883)."""
+    for prefix, kind in (("socket:", "socket"), ("pipe:", "pipe"), ("anon_inode:", "anon_inode")):
+        if target.startswith(prefix):
+            return kind
+    if target.endswith(".db"):
+        return "database"
+    if target.endswith(".db-wal"):
+        return "database_wal"
+    if target.endswith(".db-shm"):
+        return "database_shm"
+    if target.startswith("/dev/"):
+        return "device"
+    return "file"
+
+
+def _collect_fd_info(proc) -> dict:
+    """Descriptors in use, by kind, against the limit (#2883).
+
+    #2883 ran out of descriptors at a 1024 limit, and the SQLite pool could
+    account for only part of that. Nobody could say what held the rest, because
+    a bundle carried no count. ``fds_by_type`` answers it from the next report:
+    database, -wal and -shm separately (WAL keeps a closed connection's db fd
+    open), sockets, pipes and everything else. Kinds only, never paths. Linux
+    only for the breakdown; best-effort throughout.
+    """
+    import os
+
+    out: dict = {}
+    try:
+        out["num_fds"] = proc.num_fds()
+    except Exception:
+        pass
+    try:
+        import resource
+
+        soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
+        infinity = resource.RLIM_INFINITY
+        out["fd_limit"] = {
+            "soft": "unlimited" if soft == infinity else soft,
+            "hard": "unlimited" if hard == infinity else hard,
+        }
+    except Exception:
+        pass
+    try:
+        from backend.app.core import fd_limit
+
+        if fd_limit.startup_status is not None:
+            out["fd_limit_at_startup"] = dict(fd_limit.startup_status)
+    except Exception:
+        pass
+    try:
+        kinds: dict[str, int] = {}
+        for name in os.listdir("/proc/self/fd"):
+            try:
+                kind = _fd_kind(os.readlink(f"/proc/self/fd/{name}"))
+            except OSError:
+                # Closed between listing and reading, which is normal.
+                continue
+            kinds[kind] = kinds.get(kind, 0) + 1
+        out["fds_by_type"] = dict(sorted(kinds.items(), key=lambda kv: -kv[1]))
+    except Exception:
+        pass
+    return out
+
+
 def _format_bytes(size_bytes: int) -> str:
 def _format_bytes(size_bytes: int) -> str:
     """Format bytes into human-readable string."""
     """Format bytes into human-readable string."""
     if size_bytes < 1024:
     if size_bytes < 1024:
@@ -643,23 +709,6 @@ async def _collect_github_backup_info(db: AsyncSession) -> dict:
     }
     }
 
 
 
 
-async def _check_url_reachable(url: str, timeout: float = 2.0) -> bool | None:
-    """Single HEAD/GET ping with a short timeout. Returns None if URL is empty."""
-    if not url or not url.strip():
-        return None
-    try:
-        import httpx
-
-        async with httpx.AsyncClient(timeout=timeout, verify=False) as client:  # nosec B501 — local sidecars often use self-signed; this is a reachability/health probe only, no secrets are sent
-            r = await client.get(url, follow_redirects=False)
-            # Anything that returned a status code counts as reachable, even 404
-            # (the API server is up, just the path was wrong) — separates network
-            # failure from configuration mistakes for the user.
-            return r.status_code is not None
-    except Exception:
-        return False
-
-
 async def _fetch_slicer_health(url: str, timeout: float = 2.0) -> dict | None:
 async def _fetch_slicer_health(url: str, timeout: float = 2.0) -> dict | None:
     """Fetch ``/health`` from a slicer sidecar and extract the CLI version.
     """Fetch ``/health`` from a slicer sidecar and extract the CLI version.
 
 
@@ -833,7 +882,7 @@ async def _collect_support_info() -> dict:
             info["docker"] = {
             info["docker"] = {
                 "container_memory_limit_bytes": mem_limit,
                 "container_memory_limit_bytes": mem_limit,
                 "container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None,
                 "container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None,
-                "network_mode_hint": _detect_docker_network_mode(),
+                "network_mode_hint": await asyncio.to_thread(_detect_docker_network_mode),
             }
             }
         except Exception:
         except Exception:
             logger.debug("Failed to collect Docker info", exc_info=True)
             logger.debug("Failed to collect Docker info", exc_info=True)
@@ -926,6 +975,7 @@ async def _collect_support_info() -> dict:
                     "ams_tray_count": ams_tray_count,
                     "ams_tray_count": ams_tray_count,
                     "has_vt_tray": has_vt_tray,
                     "has_vt_tray": has_vt_tray,
                     "external_camera_configured": bool(printer.external_camera_url),
                     "external_camera_configured": bool(printer.external_camera_url),
+                    "camera_light_auto": bool(printer.camera_light_auto),
                     "plate_detection_enabled": printer.plate_detection_enabled,
                     "plate_detection_enabled": printer.plate_detection_enabled,
                     "obico_enabled": obico_globally_enabled
                     "obico_enabled": obico_globally_enabled
                     and (obico_enabled_set is None or printer.id in obico_enabled_set),
                     and (obico_enabled_set is None or printer.id in obico_enabled_set),
@@ -992,6 +1042,7 @@ async def _collect_support_info() -> dict:
             "host",
             "host",
             "broker",  # MQTT broker hostname / IP — network exposure
             "broker",  # MQTT broker hostname / IP — network exposure
             "credential",
             "credential",
+            "client_id",  # e.g. manyfold_client_id (#1471): half of an OAuth login
         }
         }
         # Value-based safety net: redact anything whose value carries an
         # Value-based safety net: redact anything whose value carries an
         # unambiguous secret prefix, even if the key name didn't match.
         # unambiguous secret prefix, even if the key name didn't match.
@@ -1423,7 +1474,7 @@ async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
 
 
 @router.get("/bundle")
 @router.get("/bundle")
 async def generate_support_bundle(
 async def generate_support_bundle(
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Generate a support bundle ZIP file for issue reporting."""
     """Generate a support bundle ZIP file for issue reporting."""
     # Check if debug logging is enabled and collect sensitive values for redaction
     # Check if debug logging is enabled and collect sensitive values for redaction

+ 20 - 5
backend/app/api/routes/system.py

@@ -13,11 +13,12 @@ from fastapi import APIRouter, Depends
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.local_config import read_local_toml, read_ntp_gate
 from backend.app.core.local_config import read_local_toml, read_ntp_gate
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_log import PrintLogEntry
@@ -401,14 +402,19 @@ async def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict
 
 
 @router.get("/info")
 @router.get("/info")
 async def get_system_info(
 async def get_system_info(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get comprehensive system information."""
     """Get comprehensive system information."""
 
 
     # Database stats
     # Database stats
     archive_count = await db.scalar(select(func.count(PrintArchive.id)))
     archive_count = await db.scalar(select(func.count(PrintArchive.id)))
-    printer_count = await db.scalar(select(func.count(Printer.id)))
+    # Printers are counted and listed as the caller's printer list shows them.
+    printer_count_query = select(func.count(Printer.id))
+    if (printer_clause := printer_scope.where_strict(Printer.id)) is not None:
+        printer_count_query = printer_count_query.where(printer_clause)
+    printer_count = await db.scalar(printer_count_query)
     filament_count = await db.scalar(select(func.count(Filament.id)))
     filament_count = await db.scalar(select(func.count(Filament.id)))
     project_count = await db.scalar(select(func.count(Project.id)))
     project_count = await db.scalar(select(func.count(Project.id)))
     smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))
     smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))
@@ -441,6 +447,8 @@ async def get_system_info(
     # Connected printers
     # Connected printers
     connected_printers = []
     connected_printers = []
     for printer_id, client in printer_manager._clients.items():
     for printer_id, client in printer_manager._clients.items():
+        if not printer_scope.allows(printer_id):
+            continue
         state = client.state
         state = client.state
         if state and state.connected:
         if state and state.connected:
             # Get printer name and model from database
             # Get printer name and model from database
@@ -594,8 +602,9 @@ async def get_storage_usage(
 
 
 @router.get("/health", response_model=ScanResult)
 @router.get("/health", response_model=ScanResult)
 async def get_system_health(
 async def get_system_health(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Scan the recent application log against the known-issue catalog.
     """Scan the recent application log against the known-issue catalog.
 
 
@@ -603,7 +612,13 @@ async def get_system_health(
     Sample lines are sanitized before they leave the process.
     Sample lines are sanitized before they leave the process.
     """
     """
     sensitive_strings = await collect_sensitive_strings(db)
     sensitive_strings = await collect_sensitive_strings(db)
-    return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
+    result = await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
+    # A log line can be about any printer; a caller limited to some gets the
+    # findings without them.
+    if not printer_scope.is_unrestricted:
+        for finding in result.findings:
+            finding.sample = ""
+    return result
 
 
 
 
 @router.get("/db-pool")
 @router.get("/db-pool")

+ 110 - 31
backend/app/api/routes/updates.py

@@ -5,6 +5,7 @@ import logging
 import os
 import os
 import re
 import re
 import shutil
 import shutil
+import signal
 import sys
 import sys
 import time
 import time
 
 
@@ -25,6 +26,53 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/updates", tags=["updates"])
 router = APIRouter(prefix="/updates", tags=["updates"])
 
 
 # Global state for update progress
 # Global state for update progress
+# How long one update step may run. Generous, for slow links and a Raspberry
+# Pi, but finite: a step that hung (a stalled git fetch or npm install) left the
+# status at "downloading" or "installing" for good, and every retry answered
+# "Update already in progress" until Bambuddy was restarted.
+GIT_STEP_TIMEOUT_SECONDS = 300
+INSTALL_STEP_TIMEOUT_SECONDS = 900
+TERMINATE_GRACE_SECONDS = 5
+
+
+class _StepTimedOut(Exception):
+    def __init__(self, step: str, seconds: float) -> None:
+        super().__init__(f"{step} timed out after {seconds:.0f} s")
+        self.step = step
+
+
+async def _communicate(process, timeout: float, step: str) -> tuple[bytes, bytes]:
+    """``process.communicate()`` with a deadline; at it the step is stopped."""
+    try:
+        return await asyncio.wait_for(process.communicate(), timeout)
+    except TimeoutError:
+        # Asked first: git removes its lock files on SIGTERM, and a lock left
+        # by SIGKILL would make every retry fail. Killed if it does not go.
+        for sig, grace in ((signal.SIGTERM, TERMINATE_GRACE_SECONDS), (signal.SIGKILL, 10)):
+            _signal_step(process, sig)
+            try:
+                await asyncio.wait_for(process.wait(), grace)
+                break
+            except (TimeoutError, ProcessLookupError):
+                if sig == signal.SIGKILL:
+                    logger.warning("%s did not exit after being killed", step)
+        raise _StepTimedOut(step, timeout) from None
+
+
+def _signal_step(process, sig) -> None:
+    """The whole process group where the step was started in its own session
+    (npm and pip start children of their own); otherwise the process itself."""
+    try:
+        if hasattr(os, "killpg") and isinstance(process.pid, int) and os.getpgid(process.pid) == process.pid:
+            os.killpg(process.pid, sig)
+        elif sig == signal.SIGTERM:
+            process.terminate()
+        else:
+            process.kill()
+    except (ProcessLookupError, PermissionError, OSError):
+        pass
+
+
 _update_status = {
 _update_status = {
     "status": "idle",  # idle, checking, downloading, installing, complete, error
     "status": "idle",  # idle, checking, downloading, installing, complete, error
     "progress": 0,
     "progress": 0,
@@ -324,8 +372,8 @@ async def _origin_points_at_repo(git_path: str, git_config: list[str], app_dir,
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
         )
-        stdout, _ = await process.communicate()
-    except (OSError, asyncio.CancelledError):
+        stdout, _ = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git remote get-url")
+    except (OSError, asyncio.CancelledError, _StepTimedOut):
         # Fail closed: let the caller go through the rewrite branch if we
         # Fail closed: let the caller go through the rewrite branch if we
         # can't even invoke git. The unconditional set-url is the safer
         # can't even invoke git. The unconditional set-url is the safer
         # fallback, only mildly destructive.
         # fallback, only mildly destructive.
@@ -447,7 +495,7 @@ async def get_version():
 
 
 @router.get("/check")
 @router.get("/check")
 async def check_for_updates(
 async def check_for_updates(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
 ):
 ):
     """Check GitHub for available updates."""
     """Check GitHub for available updates."""
@@ -753,7 +801,7 @@ async def _perform_update(target_ref: str):
                 stdout=asyncio.subprocess.PIPE,
                 stdout=asyncio.subprocess.PIPE,
                 stderr=asyncio.subprocess.PIPE,
                 stderr=asyncio.subprocess.PIPE,
             )
             )
-            await process.communicate()
+            await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git remote set-url")
 
 
         _update_status = {
         _update_status = {
             "status": "downloading",
             "status": "downloading",
@@ -787,7 +835,7 @@ async def _perform_update(target_ref: str):
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
         )
-        stdout, stderr = await process.communicate()
+        stdout, stderr = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git fetch")
 
 
         if process.returncode != 0:
         if process.returncode != 0:
             error_msg = stderr.decode() if stderr else "Git fetch failed"
             error_msg = stderr.decode() if stderr else "Git fetch failed"
@@ -823,7 +871,7 @@ async def _perform_update(target_ref: str):
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
         )
-        stdout, stderr = await process.communicate()
+        stdout, stderr = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git reset")
 
 
         if process.returncode != 0:
         if process.returncode != 0:
             error_msg = stderr.decode() if stderr else "Git reset failed"
             error_msg = stderr.decode() if stderr else "Git reset failed"
@@ -858,8 +906,9 @@ async def _perform_update(target_ref: str):
             cwd=str(app_dir),
             cwd=str(app_dir),
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
+            start_new_session=True,
         )
         )
-        stdout, stderr = await process.communicate()
+        stdout, stderr = await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "pip install")
 
 
         if process.returncode != 0:
         if process.returncode != 0:
             logger.warning("pip install warning: %s", stderr.decode() if stderr else "unknown")
             logger.warning("pip install warning: %s", stderr.decode() if stderr else "unknown")
@@ -876,29 +925,36 @@ async def _perform_update(target_ref: str):
                 "error": None,
                 "error": None,
             }
             }
 
 
-            # npm install
-            process = await asyncio.create_subprocess_exec(
-                npm_path,
-                "install",
-                cwd=str(frontend_dir),
-                stdout=asyncio.subprocess.PIPE,
-                stderr=asyncio.subprocess.PIPE,
-            )
-            await process.communicate()
-
-            # npm run build
-            process = await asyncio.create_subprocess_exec(
-                npm_path,
-                "run",
-                "build",
-                cwd=str(frontend_dir),
-                stdout=asyncio.subprocess.PIPE,
-                stderr=asyncio.subprocess.PIPE,
-            )
-            stdout, stderr = await process.communicate()
-
-            if process.returncode != 0:
-                logger.warning("Frontend build warning: %s", stderr.decode() if stderr else "unknown")
+            # Optional: the release ships pre-built static files, so a build
+            # that fails or runs out of time leaves those in place.
+            try:
+                # npm install
+                process = await asyncio.create_subprocess_exec(
+                    npm_path,
+                    "install",
+                    cwd=str(frontend_dir),
+                    stdout=asyncio.subprocess.PIPE,
+                    stderr=asyncio.subprocess.PIPE,
+                    start_new_session=True,
+                )
+                await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "npm install")
+
+                # npm run build
+                process = await asyncio.create_subprocess_exec(
+                    npm_path,
+                    "run",
+                    "build",
+                    cwd=str(frontend_dir),
+                    stdout=asyncio.subprocess.PIPE,
+                    stderr=asyncio.subprocess.PIPE,
+                    start_new_session=True,
+                )
+                stdout, stderr = await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "npm run build")
+
+                if process.returncode != 0:
+                    logger.warning("Frontend build warning: %s", stderr.decode() if stderr else "unknown")
+            except _StepTimedOut as exc:
+                logger.warning("%s; keeping the pre-built frontend", exc)
         else:
         else:
             logger.info("npm not found or frontend dir missing - using pre-built static files")
             logger.info("npm not found or frontend dir missing - using pre-built static files")
 
 
@@ -911,6 +967,29 @@ async def _perform_update(target_ref: str):
 
 
         logger.info("Update completed successfully")
         logger.info("Update completed successfully")
 
 
+    except _StepTimedOut as exc:
+        logger.error("Update failed: %s", exc)
+        if exc.step == "pip install":
+            # git reset has already moved the code: it must not be started on
+            # dependencies that were never installed.
+            advice = (
+                "The new code is in place but its dependencies were not installed. "
+                "Check for updates and run the update again before restarting."
+            )
+        elif exc.step == "git reset":
+            # Stopped while the files were being replaced: some may be new.
+            advice = (
+                "The new code may be only partly in place. "
+                "Check for updates and run the update again before restarting."
+            )
+        else:
+            advice = "Nothing was installed. Check for updates and run the update again."
+        _update_status = {
+            "status": "error",
+            "progress": 0,
+            "message": f"Update failed: {exc.step} took too long",
+            "error": f"{exc}. {advice}",
+        }
     except Exception as e:
     except Exception as e:
         logger.error("Update failed: %s", e)
         logger.error("Update failed: %s", e)
         _update_status = {
         _update_status = {
@@ -924,7 +1003,7 @@ async def _perform_update(target_ref: str):
 @router.post("/apply")
 @router.post("/apply")
 async def apply_update(
 async def apply_update(
     background_tasks: BackgroundTasks,
     background_tasks: BackgroundTasks,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Apply available update (git pull + rebuild)."""
     """Apply available update (git pull + rebuild)."""

+ 2 - 2
backend/app/api/routes/user_notifications.py

@@ -21,7 +21,7 @@ router = APIRouter(prefix="/user-notifications", tags=["user-notifications"])
 @router.get("/preferences", response_model=UserEmailPreferenceResponse)
 @router.get("/preferences", response_model=UserEmailPreferenceResponse)
 async def get_user_email_preferences(
 async def get_user_email_preferences(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Get the current user's email notification preferences.
     """Get the current user's email notification preferences.
 
 
@@ -55,7 +55,7 @@ async def get_user_email_preferences(
 async def update_user_email_preferences(
 async def update_user_email_preferences(
     data: UserEmailPreferenceUpdate,
     data: UserEmailPreferenceUpdate,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Update the current user's email notification preferences."""
     """Update the current user's email notification preferences."""
     if current_user is None:
     if current_user is None:

+ 239 - 20
backend/app/api/routes/users.py

@@ -4,7 +4,7 @@ from typing import Annotated
 import jwt as _jwt
 import jwt as _jwt
 from fastapi import APIRouter, Depends, HTTPException, Query, status
 from fastapi import APIRouter, Depends, HTTPException, Query, status
 from fastapi.security import HTTPAuthorizationCredentials
 from fastapi.security import HTTPAuthorizationCredentials
-from sqlalchemy import delete, func, select
+from sqlalchemy import delete, func, select, update as sql_update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
@@ -23,15 +23,17 @@ from backend.app.core.auth import (
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
-from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
+from backend.app.models.auth_ephemeral import AuthEphemeralToken
 from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.group import Group
 from backend.app.models.group import Group
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.long_lived_token import LongLivedToken
 from backend.app.models.long_lived_token import LongLivedToken
 from backend.app.models.oidc_provider import UserOIDCLink
 from backend.app.models.oidc_provider import UserOIDCLink
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.print_batch import PrintBatch
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -52,7 +54,10 @@ from backend.app.services.email_service import (
     send_email,
     send_email,
 )
 )
 from backend.app.services.finance_defaults import ensure_user_finance_defaults
 from backend.app.services.finance_defaults import ensure_user_finance_defaults
+from backend.app.services.printer_manager import printer_manager
+from backend.app.services.slice_dispatch import slice_dispatch
 from backend.app.utils.library_paths import remove_library_photos_dir
 from backend.app.utils.library_paths import remove_library_photos_dir
+from backend.app.utils.local_time import utcnow_naive
 
 
 router = APIRouter(prefix="/users", tags=["users"])
 router = APIRouter(prefix="/users", tags=["users"])
 
 
@@ -77,7 +82,7 @@ def _user_to_response(user: User) -> UserResponse:
 @router.get("/", response_model=list[UserResponse])
 @router.get("/", response_model=list[UserResponse])
 async def list_users(
 async def list_users(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """List all users.
     """List all users.
 
 
@@ -91,13 +96,31 @@ async def list_users(
     return [_user_to_response(user) for user in users]
     return [_user_to_response(user) for user in users]
 
 
 
 
+async def drop_pending_tokens(db: AsyncSession, username: str) -> None:
+    """Delete the one-time tokens pending under a username that is going away.
+
+    Reset links, 2FA and OIDC steps, connect codes, camera, WebSocket and slicer
+    tokens are looked up by username, so they would work for whoever is given
+    the name next. The logout list (revoked_jti) stays: dropping an entry would
+    make the logged-out token valid again.
+    """
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            # Both sides in SQL: SQLite's lower() changes only ASCII letters, so
+            # it never equals Python's lower() of a name with an umlaut.
+            func.lower(AuthEphemeralToken.username) == func.lower(username),
+            AuthEphemeralToken.token_type != "revoked_jti",
+        )
+    )
+
+
 @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 async def create_user(
 async def create_user(
     user_data: UserCreate,
     user_data: UserCreate,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Create a new user.
     """Create a new user.
 
 
@@ -204,7 +227,7 @@ async def create_user(
 @router.get("/slim", response_model=list[UserSlim])
 @router.get("/slim", response_model=list[UserSlim])
 async def list_users_slim(
 async def list_users_slim(
     _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.USERS_READ_SLIM, Permission.USERS_READ),
     _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.USERS_READ_SLIM, Permission.USERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """List users as ``{id, username}`` only (#1894).
     """List users as ``{id, username}`` only (#1894).
 
 
@@ -230,7 +253,7 @@ async def list_users_slim(
 async def get_user(
 async def get_user(
     user_id: int,
     user_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Get a user by ID. Read-only — gated on ``USERS_READ`` only."""
     """Get a user by ID. Read-only — gated on ``USERS_READ`` only."""
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
@@ -250,9 +273,11 @@ async def update_user(
     user_data: UserUpdate,
     user_data: UserUpdate,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Update a user."""
     """Update a user."""
+    close_sockets = False
+    deactivated_keys: list[int] = []
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     user = result.scalar_one_or_none()
     if not user:
     if not user:
@@ -302,9 +327,40 @@ async def update_user(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Username already exists",
                 detail="Username already exists",
             )
             )
+        if user_data.username != user.username:
+            # The name passes on: a deleted user's still-valid JWTs under the new
+            # name must not sign this user in, and what is pending under the old
+            # one must not reach whoever is given it next. This user's own
+            # sessions carry the old name and end anyway. A change of case counts
+            # too: SQLite compares only ASCII letters without case, so 'Ärger'
+            # and 'ärger' are two names there.
+            await drop_pending_tokens(db, user.username)
+            user.password_changed_at = utcnow_naive()
+            close_sockets = True
         user.username = user_data.username
         user.username = user_data.username
 
 
-    if user_data.email is not None:
+    if user_data.email is not None and not user_data.email.strip():
+        # Empty removes the email; null leaves it as it is.
+        if user.email:
+            rows = await db.execute(
+                select(Settings.key, Settings.value).where(
+                    Settings.key.in_(["advanced_auth_enabled", f"user_{user.id}_email_2fa_enabled"])
+                )
+            )
+            flags = {key: (value or "").lower() == "true" for key, value in rows.all()}
+            if flags.get("advanced_auth_enabled"):
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail="Email is required when advanced authentication is enabled",
+                )
+            # Their sign-in codes go to this address.
+            if flags.get(f"user_{user.id}_email_2fa_enabled"):
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail="Email is required while the user has email two-factor authentication on",
+                )
+            user.email = None
+    elif user_data.email is not None:
         # Check if new email already exists (case-insensitive)
         # Check if new email already exists (case-insensitive)
         existing_email = await db.execute(
         existing_email = await db.execute(
             select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)
             select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)
@@ -323,6 +379,11 @@ async def update_user(
                 detail="Cannot set password for LDAP users",
                 detail="Cannot set password for LDAP users",
             )
             )
         user.password_hash = get_password_hash(user_data.password)
         user.password_hash = get_password_hash(user_data.password)
+        # As a reset: sessions and pending tokens of the old password end (an
+        # admin setting a password is often taking an account back).
+        await drop_pending_tokens(db, user.username)
+        user.password_changed_at = utcnow_naive()
+        close_sockets = True
 
 
     if user_data.role is not None:
     if user_data.role is not None:
         if user_data.role not in ["admin", "user"]:
         if user_data.role not in ["admin", "user"]:
@@ -333,6 +394,20 @@ async def update_user(
         user.role = user_data.role
         user.role = user_data.role
 
 
     if user_data.is_active is not None:
     if user_data.is_active is not None:
+        if user.is_active and not user_data.is_active:
+            # Deactivated: their printers go at the scope refresh below, but
+            # events of no printer (inventory, SpoolBuddy tags, announcements)
+            # would still reach their open dashboards, and their tokens would
+            # open new ones. The same for their API keys' (a kiosk), which
+            # REST already refuses for a deactivated owner.
+            from backend.app.api.routes.api_keys import drop_api_key_tokens
+
+            await drop_pending_tokens(db, user.username)
+            deactivated_keys = list(
+                (await db.execute(select(APIKey.id).where(APIKey.user_id == user_id))).scalars().all()
+            )
+            await drop_api_key_tokens(db, deactivated_keys)
+            close_sockets = True
         user.is_active = user_data.is_active
         user.is_active = user_data.is_active
 
 
     # Handle group assignments
     # Handle group assignments
@@ -349,6 +424,18 @@ async def update_user(
     await ensure_user_finance_defaults(db, user)
     await ensure_user_finance_defaults(db, user)
 
 
     await db.commit()
     await db.commit()
+    if deactivated_keys:
+        await ws_manager.close_api_keys(deactivated_keys)
+    if close_sockets:
+        # Open dashboards are not signed in again. After a rename they keep the
+        # name they connected with and look their printers up by it on every
+        # scope refresh (the next user given it); after a password set they are
+        # whoever held the account before.
+        await ws_manager.close_user(user_id)
+    if user_data.group_ids is not None or user_data.role is not None or user_data.is_active is not None:
+        # Groups, admin role and deactivation all change which printers the
+        # user's open dashboards may hear about (#1727)
+        await ws_manager.refresh_printer_scopes()
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one()
     user = result.scalar_one()
 
 
@@ -359,7 +446,7 @@ async def update_user(
 async def get_user_items_count(
 async def get_user_items_count(
     user_id: int,
     user_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Get count of items created by this user. Read-only — gated on
     """Get count of items created by this user. Read-only — gated on
     ``USERS_READ`` only."""
     ``USERS_READ`` only."""
@@ -403,7 +490,7 @@ async def delete_user(
     delete_items: bool = Query(False, description="Delete all items created by this user"),
     delete_items: bool = Query(False, description="Delete all items created by this user"),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Delete a user.
     """Delete a user.
 
 
@@ -455,7 +542,92 @@ async def delete_user(
         doomed_library_file_ids = list(
         doomed_library_file_ids = list(
             (await db.execute(select(LibraryFile.id).where(LibraryFile.created_by_id == user_id))).scalars().all()
             (await db.execute(select(LibraryFile.id).where(LibraryFile.created_by_id == user_id))).scalars().all()
         )
         )
-        # Delete all items created by this user
+        # Delete all items created by this user. The print log stays, without
+        # the archives it pointed at; archives of other users printed from
+        # these library files lose that link (both SET NULL on PostgreSQL).
+        from backend.app.services.library_trash import delete_dependent_variants, release_queue_references
+
+        await delete_dependent_variants(db, doomed_library_file_ids)
+        await release_queue_references(db, doomed_library_file_ids)
+        # A print still running keeps what it needs to be billed when it
+        # ends: its queue row and the user's archive it prints are detached
+        # (no user), as without delete_items, rather than deleted. Deleted, the
+        # completion found no queue row and charged the archive's creator
+        # instead (for a reprint someone else, who never printed it), or found
+        # no archive and billed nothing, its reservation held for good.
+        from sqlalchemy import and_, or_
+
+        from backend.app.models.finance import BudgetReservation
+
+        running_rows = (
+            await db.execute(
+                select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
+                    PrintQueueItem.created_by_id == user_id, PrintQueueItem.status == "printing"
+                )
+            )
+        ).all()
+        running_items = [row.id for row in running_rows]
+        running_archives = [row.archive_id for row in running_rows if row.archive_id is not None]
+        running_archives += (
+            (
+                await db.execute(
+                    select(PrintArchive.id).where(
+                        PrintArchive.created_by_id == user_id, PrintArchive.status == "printing"
+                    )
+                )
+            )
+            .scalars()
+            .all()
+        )
+        # Also one another user is printing (a reprint of this user's archive,
+        # dispatched before the printer's start marks the archive printing):
+        # deleted, it took their queue row with it and their print went unbilled.
+        own_archives = select(PrintArchive.id).where(PrintArchive.created_by_id == user_id)
+        running_archives += (
+            (
+                await db.execute(
+                    select(PrintQueueItem.archive_id).where(
+                        PrintQueueItem.status == "printing", PrintQueueItem.archive_id.in_(own_archives)
+                    )
+                )
+            )
+            .scalars()
+            .all()
+        )
+        if running_items:
+            await db.execute(
+                sql_update(PrintQueueItem).where(PrintQueueItem.id.in_(running_items)).values(created_by_id=None)
+            )
+        if running_archives:
+            await db.execute(
+                sql_update(PrintArchive)
+                .where(PrintArchive.id.in_(running_archives), PrintArchive.created_by_id == user_id)
+                .values(created_by_id=None)
+            )
+
+        # Budget held for the queue items and archives deleted here: no print
+        # completion can settle it any more (it finds no archive), and an
+        # active reservation held the cost centre's budget, and kept the cost
+        # centre from being deleted, for good. Nothing is charged for those:
+        # they go, as the admin chose. (A running print's was detached above.)
+        await db.execute(
+            sql_update(BudgetReservation)
+            .where(
+                BudgetReservation.status == "active",
+                or_(
+                    and_(
+                        BudgetReservation.source_type == "print_queue",
+                        BudgetReservation.source_id.in_(
+                            select(PrintQueueItem.id).where(PrintQueueItem.created_by_id == user_id)
+                        ),
+                    ),
+                    BudgetReservation.print_archive_id.in_(
+                        select(PrintArchive.id).where(PrintArchive.created_by_id == user_id)
+                    ),
+                ),
+            )
+            .values(status="released", released_at=datetime.now(timezone.utc))
+        )
         await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
         await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
         await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
         await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
         await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))
         await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))
@@ -467,6 +639,22 @@ async def delete_user(
         # users would otherwise leave dangling created_by_id on SQLite (#1295 review nit).
         # users would otherwise leave dangling created_by_id on SQLite (#1295 review nit).
         from sqlalchemy import update
         from sqlalchemy import update
 
 
+        from backend.app.core.printer_scope import resolve_user_printer_scope
+
+        # An ownerless "any <model>" job may run on any printer of that model;
+        # the scheduler held it to this user's printers only while it was
+        # theirs (#1727). Stage those jobs instead, so an admin decides where
+        # they run rather than the job quietly spreading across the fleet.
+        if not (await resolve_user_printer_scope(db, user)).is_unrestricted:
+            await db.execute(
+                update(PrintQueueItem)
+                .where(
+                    PrintQueueItem.created_by_id == user_id,
+                    PrintQueueItem.printer_id.is_(None),
+                    PrintQueueItem.status == "pending",
+                )
+                .values(manual_start=True)
+            )
         await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
         await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
         await db.execute(
         await db.execute(
             update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
             update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
@@ -474,6 +662,16 @@ async def delete_user(
         await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
         await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
         await db.execute(update(PrintBatch).where(PrintBatch.created_by_id == user_id).values(created_by_id=None))
         await db.execute(update(PrintBatch).where(PrintBatch.created_by_id == user_id).values(created_by_id=None))
 
 
+    # The print log keeps who started each run as long as the user exists, and
+    # outlives them either way (ON DELETE SET NULL, which SQLite doesn't apply).
+    # Every run of theirs, the one printing now too.
+    await db.execute(
+        sql_update(PrintLogEntry)
+        .where(PrintLogEntry.created_by_id == user_id)
+        .values(created_by_id=None)
+        .execution_options(include_open_runs=True)
+    )
+
     # Drop API keys owned by this user. The model declares ON DELETE CASCADE
     # Drop API keys owned by this user. The model declares ON DELETE CASCADE
     # so Postgres handles this automatically, but SQLite ships with FK
     # so Postgres handles this automatically, but SQLite ships with FK
     # enforcement off (the project's existing pattern — same reason the
     # enforcement off (the project's existing pattern — same reason the
@@ -483,6 +681,10 @@ async def delete_user(
     # silently degrading the keys to anonymous (and locking them out of
     # silently degrading the keys to anonymous (and locking them out of
     # /cloud/* — but the rest of the API would still accept them, which is
     # /cloud/* — but the rest of the API would still accept them, which is
     # exactly the orphan-key state the CASCADE was meant to prevent).
     # exactly the orphan-key state the CASCADE was meant to prevent).
+    from backend.app.api.routes.api_keys import drop_api_key_tokens
+
+    key_ids = (await db.execute(select(APIKey.id).where(APIKey.user_id == user_id))).scalars().all()
+    await drop_api_key_tokens(db, key_ids)
     await db.execute(delete(APIKey).where(APIKey.user_id == user_id))
     await db.execute(delete(APIKey).where(APIKey.user_id == user_id))
 
 
     # Drop OIDC links, MFA state, and long-lived camera-stream tokens
     # Drop OIDC links, MFA state, and long-lived camera-stream tokens
@@ -502,21 +704,31 @@ async def delete_user(
     await db.execute(delete(LongLivedToken).where(LongLivedToken.user_id == user_id))
     await db.execute(delete(LongLivedToken).where(LongLivedToken.user_id == user_id))
 
 
     # Connected apps, same SQLite/FK pattern. A leftover consent row would
     # Connected apps, same SQLite/FK pattern. A leftover consent row would
-    # skip the consent screen for whoever is next given this user id, and a
-    # code issued in the last minute is keyed by username, not id.
+    # skip the consent screen for whoever is next given this user id.
     from sqlalchemy import update as _update
     from sqlalchemy import update as _update
 
 
     await db.execute(delete(ConnectedAppGrant).where(ConnectedAppGrant.user_id == user_id))
     await db.execute(delete(ConnectedAppGrant).where(ConnectedAppGrant.user_id == user_id))
-    await db.execute(
-        delete(AuthEphemeralToken).where(
-            AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
-            func.lower(AuthEphemeralToken.username) == user.username.lower(),
-        )
-    )
+    await drop_pending_tokens(db, user.username)
     await db.execute(_update(ConnectedApp).where(ConnectedApp.created_by_id == user_id).values(created_by_id=None))
     await db.execute(_update(ConnectedApp).where(ConnectedApp.created_by_id == user_id).values(created_by_id=None))
 
 
+    # Email 2FA is a settings key named after the id, which no foreign key covers.
+    await db.execute(delete(Settings).where(Settings.key == f"user_{user_id}_email_2fa_enabled"))
+
     await db.delete(user)
     await db.delete(user)
     await db.commit()
     await db.commit()
+    # What still runs for them must not be credited to whoever gets the id next
+    # (SQLite reuses it): a print, a slice, an open dashboard.
+    printer_manager.forget_user(user_id)
+    from backend.app.main import forget_print_creator
+
+    forget_print_creator(user_id)
+    slice_dispatch.forget_owner(user_id)
+    await ws_manager.close_user(user_id)
+    await ws_manager.close_api_keys(key_ids)
+    from backend.app.api.routes.notifications import forget_app_senders
+
+    forget_app_senders(key_ids, [user_id])
+    await ws_manager.refresh_printer_scopes()
 
 
     for file_id in doomed_library_file_ids:
     for file_id in doomed_library_file_ids:
         remove_library_photos_dir(file_id)
         remove_library_photos_dir(file_id)
@@ -527,7 +739,7 @@ async def change_own_password(
     password_data: ChangePasswordRequest,
     password_data: ChangePasswordRequest,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     current_user: User | None = Depends(get_current_user_optional),
     current_user: User | None = Depends(get_current_user_optional),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Change the current user's password. Requires current password verification."""
     """Change the current user's password. Requires current password verification."""
     if not current_user:
     if not current_user:
@@ -574,7 +786,14 @@ async def change_own_password(
     # Update password
     # Update password
     user.password_hash = get_password_hash(password_data.new_password)
     user.password_hash = get_password_hash(password_data.new_password)
     user.password_changed_at = datetime.now(timezone.utc)  # M-R7-B: invalidate all prior JWTs
     user.password_changed_at = datetime.now(timezone.utc)  # M-R7-B: invalidate all prior JWTs
+    # The WebSocket and camera tokens too: reusable for an hour, they would
+    # reconnect a closed dashboard straight away.
+    await drop_pending_tokens(db, user.username)
     await db.commit()
     await db.commit()
+    # Open dashboards are never signed in again: one someone else had open on
+    # the account would keep receiving its printers. This tab signs in again
+    # (the route revokes its JWT anyway).
+    await ws_manager.close_user(user.id)
 
 
     # L-R6-A: Password verified successfully — reset the failure counter
     # L-R6-A: Password verified successfully — reset the failure counter
     from backend.app.api.routes.mfa import clear_failed_attempts
     from backend.app.api.routes.mfa import clear_failed_attempts

+ 95 - 55
backend/app/api/routes/virtual_printers.py

@@ -1,15 +1,19 @@
 import logging
 import logging
+import os
+from pathlib import Path
 
 
 from fastapi import APIRouter, Depends
 from fastapi import APIRouter, Depends
 from fastapi.responses import JSONResponse
 from fastapi.responses import JSONResponse
 from pydantic import BaseModel
 from pydantic import BaseModel
-from sqlalchemy import select
+from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.user import User
 from backend.app.models.user import User
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.virtual_printer import VPDiagnosticResult
 from backend.app.schemas.virtual_printer import VPDiagnosticResult
 
 
 # Imported at module scope so tests can patch
 # Imported at module scope so tests can patch
@@ -45,7 +49,8 @@ class VirtualPrinterCreate(BaseModel):
     remote_interface_ip: str | None = None
     remote_interface_ip: str | None = None
 
 
 
 
-class VirtualPrinterUpdate(BaseModel):
+class VirtualPrinterUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "virtual_printers"
     name: str | None = None
     name: str | None = None
     enabled: bool | None = None
     enabled: bool | None = None
     mode: str | None = None
     mode: str | None = None
@@ -78,6 +83,14 @@ def _resolve_printer_model(printer_model: str | None) -> str | None:
     return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
     return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
 
 
 
 
+def _target_missing(target_printer_id: int) -> JSONResponse:
+    return JSONResponse(status_code=400, content={"detail": f"Printer with ID {target_printer_id} not found"})
+
+
+def _vp_missing() -> JSONResponse:
+    return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+
+
 async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
 async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
     """Convert VirtualPrinter model to response dict.
     """Convert VirtualPrinter model to response dict.
 
 
@@ -123,8 +136,9 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
 
 
 @router.get("")
 @router.get("")
 async def list_virtual_printers(
 async def list_virtual_printers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """List all virtual printers with status."""
     """List all virtual printers with status."""
     from backend.app.models.virtual_printer import VirtualPrinter
     from backend.app.models.virtual_printer import VirtualPrinter
@@ -135,6 +149,10 @@ async def list_virtual_printers(
 
 
     printers = []
     printers = []
     for vp in vps:
     for vp in vps:
+        # One bridging to a printer the caller can't use is left out: its
+        # status names that printer.
+        if not printer_scope.allows(vp.target_printer_id):
+            continue
         instance = virtual_printer_manager.get_instance(vp.id)
         instance = virtual_printer_manager.get_instance(vp.id)
         status = instance.get_status() if instance else {"running": False, "pending_files": 0}
         status = instance.get_status() if instance else {"running": False, "pending_files": 0}
         printers.append(await _vp_to_dict(vp, db, status))
         printers.append(await _vp_to_dict(vp, db, status))
@@ -148,8 +166,9 @@ async def list_virtual_printers(
 @router.post("")
 @router.post("")
 async def create_virtual_printer(
 async def create_virtual_printer(
     body: VirtualPrinterCreate,
     body: VirtualPrinterCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Create a new virtual printer."""
     """Create a new virtual printer."""
     from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
     from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
@@ -189,15 +208,13 @@ async def create_virtual_printer(
 
 
     # Validate proxy target printer exists
     # Validate proxy target printer exists
     target_printer = None
     target_printer = None
-    if body.target_printer_id:
+    if body.target_printer_id is not None:
         from backend.app.models.printer import Printer
         from backend.app.models.printer import Printer
 
 
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
         target_printer = result.scalar_one_or_none()
         target_printer = result.scalar_one_or_none()
-        if not target_printer:
-            return JSONResponse(
-                status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
-            )
+        if not target_printer or not printer_scope.allows(target_printer.id):
+            return _target_missing(body.target_printer_id)
 
 
     # Validate bind_ip uniqueness (against all enabled VPs)
     # Validate bind_ip uniqueness (against all enabled VPs)
     if body.bind_ip:
     if body.bind_ip:
@@ -313,8 +330,9 @@ async def get_ca_certificate(
 @router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
 @router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
 async def diagnose_virtual_printer(
 async def diagnose_virtual_printer(
     vp_id: int,
     vp_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Run setup diagnostics for a virtual printer.
     """Run setup diagnostics for a virtual printer.
 
 
@@ -327,8 +345,8 @@ async def diagnose_virtual_printer(
 
 
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     vp = result.scalar_one_or_none()
     vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
 
 
     instance = virtual_printer_manager.get_instance(vp.id)
     instance = virtual_printer_manager.get_instance(vp.id)
     return await run_vp_diagnostic(vp, instance)
     return await run_vp_diagnostic(vp, instance)
@@ -337,8 +355,9 @@ async def diagnose_virtual_printer(
 @router.get("/{vp_id}")
 @router.get("/{vp_id}")
 async def get_virtual_printer(
 async def get_virtual_printer(
     vp_id: int,
     vp_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get a single virtual printer with status."""
     """Get a single virtual printer with status."""
     from backend.app.models.virtual_printer import VirtualPrinter
     from backend.app.models.virtual_printer import VirtualPrinter
@@ -346,8 +365,8 @@ async def get_virtual_printer(
 
 
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     vp = result.scalar_one_or_none()
     vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
 
 
     instance = virtual_printer_manager.get_instance(vp.id)
     instance = virtual_printer_manager.get_instance(vp.id)
     status = instance.get_status() if instance else {"running": False, "pending_files": 0}
     status = instance.get_status() if instance else {"running": False, "pending_files": 0}
@@ -359,8 +378,9 @@ async def get_virtual_printer(
 async def update_virtual_printer(
 async def update_virtual_printer(
     vp_id: int,
     vp_id: int,
     body: VirtualPrinterUpdate,
     body: VirtualPrinterUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Update a virtual printer."""
     """Update a virtual printer."""
     from backend.app.models.virtual_printer import VirtualPrinter
     from backend.app.models.virtual_printer import VirtualPrinter
@@ -368,8 +388,9 @@ async def update_virtual_printer(
 
 
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     vp = result.scalar_one_or_none()
     vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    # One bridging to a printer the caller can't use is not theirs to change.
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
 
 
     # Redact the access code before logging — model_dump otherwise includes
     # Redact the access code before logging — model_dump otherwise includes
     # the plaintext value at DEBUG, violating the project no-secrets-in-logs
     # the plaintext value at DEBUG, violating the project no-secrets-in-logs
@@ -415,10 +436,8 @@ async def update_virtual_printer(
 
 
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
         target_printer = result.scalar_one_or_none()
         target_printer = result.scalar_one_or_none()
-        if not target_printer:
-            return JSONResponse(
-                status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
-            )
+        if not target_printer or not printer_scope.allows(target_printer.id):
+            return _target_missing(body.target_printer_id)
         vp.target_printer_id = body.target_printer_id
         vp.target_printer_id = body.target_printer_id
         # Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
         # Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
         if body.model is None and vp.mode == "proxy" and target_printer.model:
         if body.model is None and vp.mode == "proxy" and target_printer.model:
@@ -491,7 +510,11 @@ async def update_virtual_printer(
             )
             )
             return JSONResponse(
             return JSONResponse(
                 status_code=400,
                 status_code=400,
-                content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
+                content={
+                    "detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"
+                    if printer_scope.allows(conflict.target_printer_id)
+                    else f"Bind IP {vp.bind_ip} is already in use"
+                },
             )
             )
         if effective_mode == "proxy":
         if effective_mode == "proxy":
             if not vp.target_printer_id:
             if not vp.target_printer_id:
@@ -534,57 +557,74 @@ async def update_virtual_printer(
     return await _vp_to_dict(vp, db, status)
     return await _vp_to_dict(vp, db, status)
 
 
 
 
-@router.delete("/{vp_id}")
-async def delete_virtual_printer(
-    vp_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-):
-    """Delete a virtual printer."""
+async def drop_virtual_printer(db: AsyncSession, vp_id: int) -> Path:
+    """Delete a virtual printer's row and discard its pending uploads.
+
+    Its instance must be stopped first, if one runs. The caller commits, then
+    calls remove_upload_dir with what this returns. *db* is a session or a
+    connection.
+    """
     from sqlalchemy import delete as sql_delete
     from sqlalchemy import delete as sql_delete
 
 
+    from backend.app.models.pending_upload import PendingUpload
     from backend.app.models.virtual_printer import VirtualPrinter
     from backend.app.models.virtual_printer import VirtualPrinter
     from backend.app.services.virtual_printer import virtual_printer_manager
     from backend.app.services.virtual_printer import virtual_printer_manager
 
 
-    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
-    vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
-
-    vp_name = vp.name
-
-    # Stop instance if running
-    await virtual_printer_manager.remove_instance(vp_id)
-
+    upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id)
     # Mark any PendingUpload rows that referenced this VP's upload_dir as
     # Mark any PendingUpload rows that referenced this VP's upload_dir as
     # discarded — without this the rows live on as phantom entries in
     # discarded — without this the rows live on as phantom entries in
     # /pending-uploads/ pointing at file paths that no longer exist, and
     # /pending-uploads/ pointing at file paths that no longer exist, and
     # the user only learns they're orphaned by trying to archive one and
     # the user only learns they're orphaned by trying to archive one and
     # getting a flip-to-discarded on file-missing.
     # getting a flip-to-discarded on file-missing.
-    upload_prefix = str(virtual_printer_manager._base_dir / "uploads" / str(vp_id))
     try:
     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()
+        await db.execute(
+            update(PendingUpload)
+            # The folder, not every path that starts with its name (uploads/12 for 1).
+            .where(PendingUpload.file_path.startswith(f"{upload_dir}{os.sep}", autoescape=True))
+            .values(status="discarded")
+        )
     except Exception as e:
     except Exception as e:
         logger.error("Failed to discard orphan PendingUpload rows for VP %d: %s", vp_id, 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.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
-    await db.commit()
+    return upload_dir
 
 
-    # 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)
+
+def remove_upload_dir(upload_dir: Path) -> None:
+    """Remove a deleted virtual printer's 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).
+    """
     if upload_dir.exists():
     if upload_dir.exists():
         import shutil
         import shutil
 
 
         shutil.rmtree(upload_dir, ignore_errors=True)
         shutil.rmtree(upload_dir, ignore_errors=True)
 
 
+
+@router.delete("/{vp_id}")
+async def delete_virtual_printer(
+    vp_id: int,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Delete a virtual printer."""
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    vp = result.scalar_one_or_none()
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
+
+    vp_name = vp.name
+
+    # Stop instance if running
+    await virtual_printer_manager.remove_instance(vp_id)
+    upload_dir = await drop_virtual_printer(db, vp_id)
+    await db.commit()
+    remove_upload_dir(upload_dir)
+
     logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
     logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
 
 
     # Resync remaining services
     # Resync remaining services

+ 116 - 44
backend/app/api/routes/webhook.py

@@ -2,19 +2,35 @@ import logging
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from pydantic import BaseModel
 from pydantic import BaseModel
-from sqlalchemy import select
+from sqlalchemy import or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import check_printer_access, check_webhook_permission, get_api_key
+from backend.app.api.routes.print_queue import _assert_can_queue_archive, saved_slicer_ams_mapping
+from backend.app.core.auth import (
+    ApiKeyActor,
+    api_key_printer_scope,
+    check_webhook_permission,
+    ensure_api_key_printer_access,
+    get_api_key,
+    is_auth_enabled,
+    queue_review_required_for,
+    resolve_apikey_owner,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.row_visibility import may_reach_row
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.schemas.printer import HMSErrorResponse, hms_error_responses
 from backend.app.schemas.printer import HMSErrorResponse, hms_error_responses
 from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
 from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
+from backend.app.services.print_control import stop_print_by_user
+from backend.app.services.print_defaults import default_print_options
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.queue_announcements import announce_queue_jobs_added
 from backend.app.services.queue_position import next_queue_position
 from backend.app.services.queue_position import next_queue_position
+from backend.app.utils.local_time import to_naive_utc
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -90,24 +106,33 @@ def _job_id(subtask_id) -> str | None:
 # Webhook endpoints
 # Webhook endpoints
 
 
 
 
+async def _key_actor(db: AsyncSession, api_key: APIKey) -> ApiKeyActor | None:
+    """The key acting for its owner; None with auth off, where there is no owner
+    to act for (as on POST /queue/) and the key's printers still apply."""
+    return ApiKeyActor(api_key, await resolve_apikey_owner(db, api_key)) if await is_auth_enabled(db) else None
+
+
 @router.post("/queue/add", response_model=QueueAddResponse)
 @router.post("/queue/add", response_model=QueueAddResponse)
 async def webhook_add_to_queue(
 async def webhook_add_to_queue(
     data: QueueAddRequest,
     data: QueueAddRequest,
     api_key: APIKey = Depends(get_api_key),
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Add a print to the queue via webhook.
     """Add a print to the queue via webhook.
 
 
     Requires 'can_queue' permission.
     Requires 'can_queue' permission.
     """
     """
     await check_webhook_permission(db, api_key, "queue")
     await check_webhook_permission(db, api_key, "queue")
-    check_printer_access(api_key, data.printer_id)
+    await ensure_api_key_printer_access(db, api_key, data.printer_id)
 
 
-    # Verify archive exists
+    # Verify archive exists and passes the same source gate as POST /queue/,
+    # with the key acting for its owner within its printers.
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
     if not archive:
     if not archive:
         raise HTTPException(status_code=404, detail="Archive not found")
         raise HTTPException(status_code=404, detail="Archive not found")
+    actor = await _key_actor(db, api_key)
+    await _assert_can_queue_archive(archive, actor, await api_key_printer_scope(db, api_key))
 
 
     # Verify printer exists
     # Verify printer exists
     result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
     result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
@@ -125,8 +150,9 @@ async def webhook_add_to_queue(
         from datetime import datetime
         from datetime import datetime
 
 
         try:
         try:
-            scheduled_time = datetime.fromisoformat(data.scheduled_time.replace("Z", "+00:00"))
-        except ValueError:
+            # Stored as naive UTC: an offset is converted, not dropped.
+            scheduled_time = to_naive_utc(datetime.fromisoformat(data.scheduled_time.replace("Z", "+00:00")))
+        except (ValueError, OverflowError):
             raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
             raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
 
 
     # Create queue item
     # Create queue item
@@ -145,10 +171,17 @@ async def webhook_add_to_queue(
         # for the person whose key it is. Legacy keys predating per-user ownership
         # for the person whose key it is. Legacy keys predating per-user ownership
         # have no `user_id`, and those rows stay ownerless.
         # have no `user_id`, and those rows stay ownerless.
         created_by_id=api_key.user_id,
         created_by_id=api_key.user_id,
+        # Waits for someone to start it unless the owner may print without review (#1620)
+        manual_start=await is_auth_enabled(db) and queue_review_required_for(await resolve_apikey_owner(db, api_key)),
+        # No print options in the request: the install's defaults.
+        **await default_print_options(db),
+        # The slicer's slots, as a reprint from the UI reuses them
+        ams_mapping=saved_slicer_ams_mapping(archive, data.printer_id),
     )
     )
     db.add(queue_item)
     db.add(queue_item)
-    await db.flush()
+    await db.commit()
     await db.refresh(queue_item)
     await db.refresh(queue_item)
+    await announce_queue_jobs_added(db, [queue_item.id])
 
 
     return QueueAddResponse(
     return QueueAddResponse(
         id=queue_item.id,
         id=queue_item.id,
@@ -164,7 +197,7 @@ async def webhook_add_to_queue(
 async def webhook_start_print(
 async def webhook_start_print(
     printer_id: int,
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Trigger the next manual-start queue item on a printer.
     """Trigger the next manual-start queue item on a printer.
 
 
@@ -180,7 +213,7 @@ async def webhook_start_print(
     Requires 'can_control_printer' permission.
     Requires 'can_control_printer' permission.
     """
     """
     await check_webhook_permission(db, api_key, "control_printer")
     await check_webhook_permission(db, api_key, "control_printer")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
 
     # Get printer
     # Get printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -188,24 +221,52 @@ async def webhook_start_print(
     if not printer:
     if not printer:
         raise HTTPException(status_code=404, detail="Printer not found")
         raise HTTPException(status_code=404, detail="Printer not found")
 
 
-    # Get next pending queue item
-    result = await db.execute(
-        select(PrintQueueItem)
-        .where(
-            PrintQueueItem.printer_id == printer_id,
-            PrintQueueItem.status == "pending",
+    # The next pending queue item the key's owner could start from the queue:
+    # theirs, or one nobody owns yet (a virtual-printer upload, claimed by
+    # starting it, #1670), unless they may start everyone's.
+    actor = await _key_actor(db, api_key)
+    owner = actor.owner if actor is not None else None
+    startable: list | None = []
+    if owner is not None and not owner.has_permission(Permission.QUEUE_UPDATE_ALL.value):
+        startable = (
+            [or_(PrintQueueItem.created_by_id == owner.id, PrintQueueItem.created_by_id.is_(None))]
+            if owner.has_permission(Permission.QUEUE_UPDATE_OWN.value)
+            else None
         )
         )
-        .order_by(PrintQueueItem.position)
-        .limit(1)
-    )
-    queue_item = result.scalar_one_or_none()
+    queue_item = None
+    if startable is not None:
+        result = await db.execute(
+            select(PrintQueueItem)
+            .where(
+                PrintQueueItem.printer_id == printer_id,
+                PrintQueueItem.status == "pending",
+                *startable,
+            )
+            .order_by(PrintQueueItem.position)
+            .limit(1)
+        )
+        queue_item = result.scalar_one_or_none()
     if not queue_item:
     if not queue_item:
         raise HTTPException(status_code=404, detail="No pending prints in queue")
         raise HTTPException(status_code=404, detail="No pending prints in queue")
 
 
+    # Starting a waiting job is a review decision (#1620): a key whose owner
+    # needs review for their own jobs can't make one for anybody's
+    if (
+        queue_item.manual_start
+        and await is_auth_enabled(db)
+        and queue_review_required_for(await resolve_apikey_owner(db, api_key))
+    ):
+        raise HTTPException(
+            status_code=403,
+            detail="The next job waits for review: someone who can manage all queue jobs has to start it",
+        )
+
     # Clear manual_start so the scheduler will dispatch. If the item was
     # Clear manual_start so the scheduler will dispatch. If the item was
     # already auto-dispatchable this is a no-op; the scheduler will still
     # already auto-dispatchable this is a no-op; the scheduler will still
     # pick it up on its next tick.
     # pick it up on its next tick.
     queue_item.manual_start = False
     queue_item.manual_start = False
+    if owner is not None and queue_item.created_by_id is None:
+        queue_item.created_by_id = owner.id
     await db.commit()
     await db.commit()
     await db.refresh(queue_item)
     await db.refresh(queue_item)
 
 
@@ -213,18 +274,34 @@ async def webhook_start_print(
     return {"message": "Print started", "queue_item_id": queue_item.id}
     return {"message": "Print started", "queue_item_id": queue_item.id}
 
 
 
 
+# States in which a print can be stopped, as with the Stop button on the
+# printer card: printing or paused.
+_STOPPABLE_STATES = ("RUNNING", "PAUSE")
+
+
+def _stop_current_print(printer_id: int) -> None:
+    """Stop the print on the user's behalf (cancel and stop are the same command)."""
+    try:
+        sent = stop_print_by_user(printer_id)
+    except Exception as e:
+        logger.error("Failed to stop print on printer %s: %s", printer_id, e)
+        raise HTTPException(status_code=500, detail=str(e))
+    if not sent:
+        raise HTTPException(status_code=503, detail="Printer not connected")
+
+
 @router.post("/printer/{printer_id}/stop")
 @router.post("/printer/{printer_id}/stop")
 async def webhook_stop_print(
 async def webhook_stop_print(
     printer_id: int,
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Stop the current print on a printer.
     """Stop the current print on a printer.
 
 
     Requires 'can_control_printer' permission.
     Requires 'can_control_printer' permission.
     """
     """
     await check_webhook_permission(db, api_key, "control_printer")
     await check_webhook_permission(db, api_key, "control_printer")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
 
     status = printer_manager.get_status(printer_id)
     status = printer_manager.get_status(printer_id)
     # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
     # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
@@ -233,14 +310,10 @@ async def webhook_stop_print(
     if not status or not status.connected:
     if not status or not status.connected:
         raise HTTPException(status_code=503, detail="Printer not connected")
         raise HTTPException(status_code=503, detail="Printer not connected")
 
 
-    if status.state != "RUNNING":
+    if status.state not in _STOPPABLE_STATES:
         raise HTTPException(status_code=409, detail="No print in progress")
         raise HTTPException(status_code=409, detail="No print in progress")
 
 
-    try:
-        await printer_manager.stop_print(printer_id)
-    except Exception as e:
-        logger.error("Failed to stop print: %s", e)
-        raise HTTPException(status_code=500, detail=str(e))
+    _stop_current_print(printer_id)
 
 
     return {"message": "Print stopped"}
     return {"message": "Print stopped"}
 
 
@@ -249,28 +322,24 @@ async def webhook_stop_print(
 async def webhook_cancel_print(
 async def webhook_cancel_print(
     printer_id: int,
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Cancel the current print on a printer.
     """Cancel the current print on a printer.
 
 
     Requires 'can_control_printer' permission.
     Requires 'can_control_printer' permission.
     """
     """
     await check_webhook_permission(db, api_key, "control_printer")
     await check_webhook_permission(db, api_key, "control_printer")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
 
     status = printer_manager.get_status(printer_id)
     status = printer_manager.get_status(printer_id)
     # Same dataclass-not-dict shape as stop_print above (#1584).
     # Same dataclass-not-dict shape as stop_print above (#1584).
     if not status or not status.connected:
     if not status or not status.connected:
         raise HTTPException(status_code=503, detail="Printer not connected")
         raise HTTPException(status_code=503, detail="Printer not connected")
 
 
-    if status.state not in ["RUNNING", "PAUSE"]:
+    if status.state not in _STOPPABLE_STATES:
         raise HTTPException(status_code=409, detail="No print to cancel")
         raise HTTPException(status_code=409, detail="No print to cancel")
 
 
-    try:
-        await printer_manager.cancel_print(printer_id)
-    except Exception as e:
-        logger.error("Failed to cancel print: %s", e)
-        raise HTTPException(status_code=500, detail=str(e))
+    _stop_current_print(printer_id)
 
 
     return {"message": "Print cancelled"}
     return {"message": "Print cancelled"}
 
 
@@ -279,14 +348,14 @@ async def webhook_cancel_print(
 async def webhook_get_printer_status(
 async def webhook_get_printer_status(
     printer_id: int,
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Get status of a printer.
     """Get status of a printer.
 
 
     Requires 'can_read_status' permission.
     Requires 'can_read_status' permission.
     """
     """
     await check_webhook_permission(db, api_key, "read_status")
     await check_webhook_permission(db, api_key, "read_status")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
 
     # Get printer
     # Get printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -332,25 +401,27 @@ async def webhook_get_printer_status(
 async def webhook_get_queue_status(
 async def webhook_get_queue_status(
     printer_id: int | None = None,
     printer_id: int | None = None,
     api_key: APIKey = Depends(get_api_key),
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
 ):
     """Get queue status for all printers or a specific printer.
     """Get queue status for all printers or a specific printer.
 
 
-    Requires 'can_read_status' permission.
+    Requires 'can_read_status' permission. The counts cover every job; the
+    items listed are those the key's owner sees in the queue.
     """
     """
     await check_webhook_permission(db, api_key, "read_status")
     await check_webhook_permission(db, api_key, "read_status")
+    actor = await _key_actor(db, api_key)
 
 
     # Get printers
     # Get printers
     if printer_id:
     if printer_id:
-        check_printer_access(api_key, printer_id)
+        await ensure_api_key_printer_access(db, api_key, printer_id)
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         printers = result.scalars().all()
         printers = result.scalars().all()
     else:
     else:
         result = await db.execute(select(Printer))
         result = await db.execute(select(Printer))
         printers = result.scalars().all()
         printers = result.scalars().all()
-        # Filter by allowed printers if limited
-        if api_key.printer_ids is not None:
-            printers = [p for p in printers if p.id in api_key.printer_ids]
+        # Only the printers the key (and its owner) may reach
+        scope = await api_key_printer_scope(db, api_key)
+        printers = [p for p in printers if scope.allows(p.id)]
 
 
     response = []
     response = []
     for printer in printers:
     for printer in printers:
@@ -382,6 +453,7 @@ async def webhook_get_queue_status(
                         "status": item.status,
                         "status": item.status,
                     }
                     }
                     for item in items
                     for item in items
+                    if may_reach_row(item.created_by_id, actor, Permission.QUEUE_READ_ALL, Permission.QUEUE_READ_OWN)
                 ],
                 ],
             )
             )
         )
         )

+ 142 - 19
backend/app/api/routes/websocket.py

@@ -21,8 +21,15 @@ import logging
 from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
 from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
 from sqlalchemy import select
 from sqlalchemy import select
 
 
-from backend.app.core.auth import is_auth_enabled, verify_websocket_token
+from backend.app.core.auth import (
+    is_auth_enabled,
+    principal_may_connect,
+    principal_printer_scope,
+    usable_api_key_prefix,
+    verify_websocket_token_principal,
+)
 from backend.app.core.database import async_session
 from backend.app.core.database import async_session
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
@@ -37,6 +44,60 @@ router = APIRouter()
 _WS_CLOSE_UNAUTHORIZED = 4401
 _WS_CLOSE_UNAUTHORIZED = 4401
 
 
 
 
+async def _user_id_of(username: str) -> int | None:
+    """The id of the user with this name, or None (also when it can't be read)."""
+    try:
+        async with async_session() as db:
+            row = await db.execute(select(User.id).where(User.username == username))
+            return row.scalar_one_or_none()
+    except Exception:  # SEC-AUTH-EXC: a failed lookup refuses the socket (fail-closed)
+        logger.warning("WebSocket principal resolve failed for %s", username, exc_info=True)
+        return None
+
+
+async def _api_key_prefix(api_key_id: int) -> str | None:
+    """The key's prefix, which tells it from a later key given its id; None when
+    the key is gone, disabled or expired (also when it can't be read)."""
+    try:
+        async with async_session() as db:
+            return await usable_api_key_prefix(db, api_key_id)
+    except Exception:  # SEC-AUTH-EXC: a failed lookup refuses the socket (fail-closed)
+        logger.warning("WebSocket API key lookup failed for %s", api_key_id, exc_info=True)
+        return None
+
+
+async def _still_may_connect(principal: str | None, api_key_id: int | None) -> bool:
+    """websocket:connect still held (False also when it can't be read): a group
+    or key change that took it away while the socket joined closed nothing."""
+    try:
+        async with async_session() as db:
+            return await principal_may_connect(db, principal, api_key_id)
+    except Exception:  # SEC-AUTH-EXC: a failed read refuses the socket (fail-closed)
+        logger.warning("WebSocket permission re-check failed", exc_info=True)
+        return False
+
+
+async def _auth_turned_on() -> bool:
+    """Whether authentication is on now (True also when it can't be read)."""
+    try:
+        async with async_session() as db:
+            return await is_auth_enabled(db)
+    except Exception:  # SEC-AUTH-EXC: a failed read refuses the socket (fail-closed)
+        logger.warning("WebSocket auth re-check failed", exc_info=True)
+        return True
+
+
+async def _token_principal(token: str | None):
+    """The token's principal again, or None (also when it can't be read)."""
+    if not token:
+        return None
+    try:
+        return await verify_websocket_token_principal(token)
+    except Exception:  # SEC-AUTH-EXC: a failed lookup refuses the socket (fail-closed)
+        logger.warning("WebSocket token re-check failed", exc_info=True)
+        return None
+
+
 @router.websocket("/ws")
 @router.websocket("/ws")
 async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(default=None)) -> None:
 async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(default=None)) -> None:
     """WebSocket endpoint for real-time updates.
     """WebSocket endpoint for real-time updates.
@@ -67,46 +128,108 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
         return
         return
 
 
     principal: str | None = None
     principal: str | None = None
+    api_key_id: int | None = None
+    printer_scope: PrinterScope = ALL_PRINTERS
     if auth_required:
     if auth_required:
         if not token:
         if not token:
             logger.info("WebSocket connect refused: no token (auth enabled)")
             logger.info("WebSocket connect refused: no token (auth enabled)")
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             return
             return
-        principal = await verify_websocket_token(token)
-        if principal is None:
+        token_principal = await verify_websocket_token_principal(token)
+        if token_principal is None:
             logger.info("WebSocket connect refused: invalid or expired token")
             logger.info("WebSocket connect refused: invalid or expired token")
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             return
             return
+        principal, api_key_id = token_principal
+        # Which printers this socket may hear about (#1727). Fail-closed: if
+        # it can't be worked out the socket is refused rather than admitted
+        # with every printer.
+        try:
+            async with async_session() as db:
+                may_connect = await principal_may_connect(db, principal, api_key_id)
+                printer_scope = await principal_printer_scope(db, principal, api_key_id)
+        except Exception:  # SEC-AUTH-EXC: scope lookup failure → refuse connect (fail-closed)
+            logger.error("WebSocket printer scope lookup failed; refusing connection", exc_info=True)
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
+        if not may_connect:
+            # The token was minted while its principal held websocket:connect;
+            # a group or key change has taken it away since.
+            logger.info("WebSocket connect refused: websocket:connect no longer held")
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
 
 
     # Token verified (or auth disabled); now safe to admit the connection.
     # Token verified (or auth disabled); now safe to admit the connection.
     logger.info("WebSocket client connecting (principal=%s)", principal if principal else "<anonymous>")
     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
     # Resolve principal username → User.id once at connect so
     # Resolve principal username → User.id once at connect so
     # ``ws_manager.broadcast_to_user()`` can filter without re-querying
     # ``ws_manager.broadcast_to_user()`` can filter without re-querying
     # per message. Auth-disabled path keeps None (broadcast_to_user fans
     # per message. Auth-disabled path keeps None (broadcast_to_user fans
     # out to all when target is None — matches the legacy single-user
     # out to all when target is None — matches the legacy single-user
     # toast behaviour). API-keyed principal is empty string → None.
     # toast behaviour). API-keyed principal is empty string → None.
+    #
+    # A user renamed or deleted since the token was checked is refused: the
+    # socket would keep the old name, and every scope refresh would give it the
+    # printers of the next user given that name. Done before connect(), so the
+    # socket carries its id from the moment it is listed, and checked again
+    # after: a rename or delete commits before it closes the user's sockets by
+    # id, so one landing in between is seen by the second check, and one after
+    # it finds this socket listed with its id.
+    #
+    # Same for an API key deleted since: its scope is looked up by key id on
+    # every refresh, and SQLite gives the id to the next key created.
     principal_user_id: int | None = None
     principal_user_id: int | None = None
     if principal:
     if principal:
-        try:
-            async with async_session() as db:
-                row = await db.execute(select(User.id).where(User.username == principal))
-                principal_user_id = row.scalar_one_or_none()
-        except Exception:  # SEC-AUTH-EXC: resolution failure is non-fatal — degrades to no per-user routing
-            logger.warning("WebSocket principal resolve failed for %s", principal, exc_info=True)
+        principal_user_id = await _user_id_of(principal)
+        if principal_user_id is None:
+            logger.info("WebSocket connect refused: user %s is gone", principal)
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
+    api_key_prefix = await _api_key_prefix(api_key_id) if api_key_id is not None else None
+    if api_key_id is not None and api_key_prefix is None:
+        logger.info("WebSocket connect refused: API key %s is gone, disabled or expired", api_key_id)
+        await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+        return
+    # Stamped before connect() puts the socket in the broadcast list, so no
+    # broadcast can reach it unfiltered (ws_manager refuses printer-bound
+    # messages to a socket without a scope anyway).
+    websocket.state.bambuddy_printer_scope = printer_scope
+    websocket.state.bambuddy_scope_principal = (principal, api_key_id)
     websocket.state.bambuddy_principal_user_id = principal_user_id
     websocket.state.bambuddy_principal_user_id = principal_user_id
+    # A scope refresh closes the socket if a different key has the id by then.
+    websocket.state.bambuddy_api_key_prefix = api_key_prefix
+    # 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
+    await ws_manager.connect(websocket)
+    # And the token still there: whatever ends a session (password change or
+    # reset, 2FA removal, deactivation) drops it before closing the user's
+    # sockets, so one landing in between is seen here. Admitted with auth off,
+    # auth must still be off: turning it on closes the anonymous sockets, which
+    # this one may have joined just after.
+    if (
+        (principal and await _user_id_of(principal) != principal_user_id)
+        or (api_key_id is not None and await _api_key_prefix(api_key_id) != api_key_prefix)
+        or (auth_required and await _token_principal(token) != (principal, api_key_id))
+        or (auth_required and not await _still_may_connect(principal, api_key_id))
+        or (not auth_required and await _auth_turned_on())
+    ):
+        logger.info("WebSocket connect refused: its session ended while it connected")
+        await ws_manager.disconnect(websocket)
+        await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+        return
     logger.info("WebSocket client connected")
     logger.info("WebSocket client connected")
 
 
     try:
     try:
         # Send initial status of all printers.
         # Send initial status of all printers.
-        statuses = printer_manager.get_all_statuses()
+        statuses = {
+            pid: state
+            for pid, state in printer_manager.get_all_statuses().items()
+            if websocket.state.bambuddy_printer_scope.allows(pid)
+        }
         for printer_id, state in statuses.items():
         for printer_id, state in statuses.items():
             await websocket.send_json(
             await websocket.send_json(
                 {
                 {
@@ -134,7 +257,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
             # Handle status request
             # Handle status request
             elif data.get("type") == "get_status":
             elif data.get("type") == "get_status":
                 printer_id = data.get("printer_id")
                 printer_id = data.get("printer_id")
-                if printer_id:
+                if printer_id and websocket.state.bambuddy_printer_scope.allows(printer_id):
                     state = printer_manager.get_status(printer_id)
                     state = printer_manager.get_status(printer_id)
                     if state:
                     if state:
                         await websocket.send_json(
                         await websocket.send_json(

+ 13 - 4
backend/app/cli.py

@@ -53,10 +53,6 @@ async def kiosk_bootstrap(
                 f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate."
                 f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate."
             )
             )
 
 
-        if existing:
-            await db.delete(existing)
-            await db.flush()
-
         full_key, key_hash, key_prefix = generate_api_key()
         full_key, key_hash, key_prefix = generate_api_key()
         row = APIKey(
         row = APIKey(
             name=name,
             name=name,
@@ -83,6 +79,19 @@ async def kiosk_bootstrap(
             expires_at=None,
             expires_at=None,
         )
         )
         db.add(row)
         db.add(row)
+        if existing:
+            from backend.app.api.routes.api_keys import drop_api_key_tokens
+
+            # The new key is added before the old one goes, so it gets a new id:
+            # SQLite would give it the old one's, and the old (perhaps leaked)
+            # key's links and open dashboards would take its printers, all of
+            # them even if an admin had limited or disabled the old key. This
+            # runs outside the server, so those dashboards stay open with the
+            # scope they had until the server next refreshes scopes, which
+            # closes sockets whose key is gone.
+            await db.flush()
+            await drop_api_key_tokens(db, [existing.id])
+            await db.delete(existing)
 
 
         # Mark first-run setup as completed so the kiosk URL loads directly
         # Mark first-run setup as completed so the kiosk URL loads directly
         # instead of being force-redirected to /setup by AuthContext. Without
         # instead of being force-redirected to /setup by AuthContext. Without

File diff suppressed because it is too large
+ 716 - 217
backend/app/core/auth.py


+ 16 - 14
backend/app/core/catalog_defaults.py

@@ -154,20 +154,22 @@ DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [
     ("Bambu Lab", "Ash Gray", "#9B9EA0", "PLA Matte"),
     ("Bambu Lab", "Ash Gray", "#9B9EA0", "PLA Matte"),
     ("Bambu Lab", "Nardo Gray", "#757575", "PLA Matte"),
     ("Bambu Lab", "Nardo Gray", "#757575", "PLA Matte"),
     ("Bambu Lab", "Charcoal", "#000000", "PLA Matte"),
     ("Bambu Lab", "Charcoal", "#000000", "PLA Matte"),
-    # Bambu Lab PLA Silk+ (from store page)
-    ("Bambu Lab", "Gold", "#F4A925", "PLA Silk"),
-    ("Bambu Lab", "Silver", "#C8C8C8", "PLA Silk"),
-    ("Bambu Lab", "Titan Gray", "#5F6367", "PLA Silk"),
-    ("Bambu Lab", "Blue", "#008BDA", "PLA Silk"),
-    ("Bambu Lab", "Purple", "#8671CB", "PLA Silk"),
-    ("Bambu Lab", "Candy Red", "#D02727", "PLA Silk"),
-    ("Bambu Lab", "Candy Green", "#018814", "PLA Silk"),
-    ("Bambu Lab", "Rose Gold", "#BA9594", "PLA Silk"),
-    ("Bambu Lab", "Baby Blue", "#A8C6EE", "PLA Silk"),
-    ("Bambu Lab", "Pink", "#F7ADA6", "PLA Silk"),
-    ("Bambu Lab", "Mint", "#96DCB9", "PLA Silk"),
-    ("Bambu Lab", "Champagne", "#F3CFB2", "PLA Silk"),
-    ("Bambu Lab", "White", "#FFFFFF", "PLA Silk"),
+    # Bambu Lab PLA Silk+ (from store page). Filed under "PLA Silk+", the line
+    # the AMS reports for these rolls; "PLA Silk" is what it reports for the
+    # Dual/Tri Color silk, which has no rows here.
+    ("Bambu Lab", "Gold", "#F4A925", "PLA Silk+"),
+    ("Bambu Lab", "Silver", "#C8C8C8", "PLA Silk+"),
+    ("Bambu Lab", "Titan Gray", "#5F6367", "PLA Silk+"),
+    ("Bambu Lab", "Blue", "#008BDA", "PLA Silk+"),
+    ("Bambu Lab", "Purple", "#8671CB", "PLA Silk+"),
+    ("Bambu Lab", "Candy Red", "#D02727", "PLA Silk+"),
+    ("Bambu Lab", "Candy Green", "#018814", "PLA Silk+"),
+    ("Bambu Lab", "Rose Gold", "#BA9594", "PLA Silk+"),
+    ("Bambu Lab", "Baby Blue", "#A8C6EE", "PLA Silk+"),
+    ("Bambu Lab", "Pink", "#F7ADA6", "PLA Silk+"),
+    ("Bambu Lab", "Mint", "#96DCB9", "PLA Silk+"),
+    ("Bambu Lab", "Champagne", "#F3CFB2", "PLA Silk+"),
+    ("Bambu Lab", "White", "#FFFFFF", "PLA Silk+"),
     # Bambu Lab PLA Sparkle (from store page)
     # Bambu Lab PLA Sparkle (from store page)
     ("Bambu Lab", "Classic Gold Sparkle", "#CEA629", "PLA Sparkle"),
     ("Bambu Lab", "Classic Gold Sparkle", "#CEA629", "PLA Sparkle"),
     ("Bambu Lab", "Slate Gray Sparkle", "#8E9089", "PLA Sparkle"),
     ("Bambu Lab", "Slate Gray Sparkle", "#8E9089", "PLA Sparkle"),

+ 6 - 8
backend/app/core/compat.py

@@ -1,11 +1,9 @@
-"""Compatibility shims for older Python versions."""
+"""Kept for the modules that import StrEnum from here.
 
 
-import sys
+Python 3.11 is the minimum, so this is enum.StrEnum itself; new code can
+import it from enum directly.
+"""
 
 
-if sys.version_info >= (3, 11):
-    from enum import StrEnum
-else:
-    from enum import Enum
+from enum import StrEnum
 
 
-    class StrEnum(str, Enum):
-        """Drop-in replacement for enum.StrEnum on Python < 3.11."""
+__all__ = ["StrEnum"]

+ 12 - 2
backend/app/core/config.py

@@ -7,7 +7,7 @@ from pydantic import Field
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "1.2.5.7"
+APP_VERSION = "1.3.0"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
 
@@ -75,9 +75,11 @@ class Settings(BaseSettings):
     database_url: str = _external_db_url or f"sqlite+aiosqlite:///{_db_path}"
     database_url: str = _external_db_url or f"sqlite+aiosqlite:///{_db_path}"
 
 
     # Database connection pool sizing. ``None`` = use the built-in, dialect-aware
     # Database connection pool sizing. ``None`` = use the built-in, dialect-aware
-    # default (PostgreSQL: pool_size 20 + max_overflow 60; SQLite: 20 + 200).
+    # default (PostgreSQL: pool_size 20 + max_overflow 60; SQLite: 10 + 90).
     # Large PostgreSQL printer farms can raise these via the DB_POOL_SIZE /
     # Large PostgreSQL printer farms can raise these via the DB_POOL_SIZE /
     # DB_MAX_OVERFLOW / DB_POOL_TIMEOUT / DB_POOL_RECYCLE env vars (issue #2572).
     # DB_MAX_OVERFLOW / DB_POOL_TIMEOUT / DB_POOL_RECYCLE env vars (issue #2572).
+    # A large farm still on SQLite can raise DB_MAX_OVERFLOW, but is better
+    # served by moving to PostgreSQL (#2883).
     # Make sure PostgreSQL ``max_connections`` comfortably exceeds
     # Make sure PostgreSQL ``max_connections`` comfortably exceeds
     # (pool_size + max_overflow) x number of app worker processes.
     # (pool_size + max_overflow) x number of app worker processes.
     db_pool_size: int | None = Field(default=None, gt=0)
     db_pool_size: int | None = Field(default=None, gt=0)
@@ -89,6 +91,14 @@ class Settings(BaseSettings):
     # overflow connections age out via pool_recycle instead of churning the whole
     # overflow connections age out via pool_recycle instead of churning the whole
     # pool. Override with DB_POOL_USE_LIFO. No effect on SQLite. (#2572)
     # pool. Override with DB_POOL_USE_LIFO. No effect on SQLite. (#2572)
     db_pool_use_lifo: bool | None = Field(default=None)
     db_pool_use_lifo: bool | None = Field(default=None)
+    # pool_pre_ping (PostgreSQL default on): every checkout first runs a liveness
+    # ping, which SQLAlchemy's asyncpg dialect wraps in its own transaction, so
+    # each pooled session costs an extra ``BEGIN; ROLLBACK`` round-trip that
+    # shows up in ``pg_stat_database.xact_rollback``. Set DB_POOL_PRE_PING=false
+    # to skip it; ``pool_recycle`` then bounds how long a dead connection can
+    # survive, but a connection dropped by the server or a proxy in between is
+    # no longer replaced transparently. No effect on SQLite.
+    db_pool_pre_ping: bool | None = Field(default=None)
 
 
     # Logging
     # Logging
     log_level: str = "INFO"  # Override with LOG_LEVEL env var or DEBUG=true
     log_level: str = "INFO"  # Override with LOG_LEVEL env var or DEBUG=true

File diff suppressed because it is too large
+ 707 - 58
backend/app/core/database.py


+ 89 - 0
backend/app/core/fd_limit.py

@@ -0,0 +1,89 @@
+"""Lift the open-file soft limit to the hard limit at startup (#2883).
+
+Docker, systemd and most native installs start Bambuddy with a soft
+``RLIMIT_NOFILE`` of 1024 and a far higher hard limit. A process may raise its
+own soft limit up to the hard one without privileges, so doing it here reaches
+every install through the code. A ``ulimits`` block in docker-compose.yml would
+not: existing installs keep their own compose file, and on a host whose hard
+limit is lower than the block asks for, the container would not start.
+
+At 1024, running out of descriptors is what turned #2883 into a corrupted
+database: every new SQLite connection failed with "disk I/O error" for 44
+hours. Above 1024 open descriptors the failure is milder. paho-mqtt waits on
+its socket with ``select()``, which cannot take a descriptor numbered 1024 or
+higher; it reports that as a lost connection and reconnects, so printer
+connections drop while the database keeps working.
+"""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+# What startup found and did, for the support bundle. None until it has run, or
+# on a platform without RLIMIT_NOFILE (Windows).
+startup_status: dict | None = None
+
+# Tried in order when the hard limit is unlimited. macOS reports RLIM_INFINITY
+# but refuses a soft limit above kern.maxfilesperproc, so a finite value has to
+# be picked; 10240 is the usual OPEN_MAX there.
+_UNLIMITED_HARD_TARGETS = (65536, 10240)
+
+
+def _describe(value: int, infinity: int) -> int | str:
+    return "unlimited" if value == infinity else value
+
+
+def raise_open_file_limit() -> dict | None:
+    """Raise the soft open-file limit as far as the hard limit allows.
+
+    Never lowers it, and never raises: a limit that cannot be changed is logged
+    and startup carries on with it. Returns what was found and done, which is
+    also kept in ``startup_status``.
+    """
+    global startup_status
+    try:
+        import resource
+    except ImportError:
+        return None
+
+    infinity = resource.RLIM_INFINITY
+    try:
+        soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
+    except (OSError, ValueError) as e:
+        logger.warning("Could not read the open-file limit: %s", e)
+        return None
+
+    status: dict = {
+        "soft_at_start": _describe(soft, infinity),
+        "hard": _describe(hard, infinity),
+        "soft": _describe(soft, infinity),
+        "raised": False,
+    }
+    startup_status = status
+
+    targets = _UNLIMITED_HARD_TARGETS if hard == infinity else (hard,)
+    targets = tuple(t for t in targets if soft != infinity and t > soft)
+    if not targets:
+        logger.info("Open-file limit: %s (hard limit %s)", status["soft"], status["hard"])
+        return status
+
+    error: Exception | None = None
+    for target in targets:
+        try:
+            resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard))
+        except (OSError, ValueError) as e:
+            error = e
+            continue
+        status["soft"] = target
+        status["raised"] = True
+        logger.info("Raised the open-file limit from %s to %s (hard limit %s)", soft, target, status["hard"])
+        return status
+
+    status["error"] = str(error)
+    logger.warning(
+        "Could not raise the open-file limit above %s (hard limit %s): %s",
+        soft,
+        status["hard"],
+        error,
+    )
+    return status

+ 1 - 2
backend/app/core/local_config.py

@@ -26,11 +26,10 @@ exists). Here we just expose state so the frontend can render accordingly.
 from __future__ import annotations
 from __future__ import annotations
 
 
 import logging
 import logging
+import tomllib
 from pathlib import Path
 from pathlib import Path
 from typing import Literal, TypedDict
 from typing import Literal, TypedDict
 
 
-import tomllib
-
 log = logging.getLogger(__name__)
 log = logging.getLogger(__name__)
 
 
 DEFAULT_PATH = Path("/etc/bambuddy/local.toml")
 DEFAULT_PATH = Path("/etc/bambuddy/local.toml")

+ 214 - 6
backend/app/core/logging_filters.py

@@ -9,7 +9,9 @@ import them without pulling in ``backend.app.main``'s startup graph.
 
 
 Also holds :data:`URL_CREDENTIALS_PATTERN` and
 Also holds :data:`URL_CREDENTIALS_PATTERN` and
 :func:`redact_url_credentials`, the single place where the shape of a
 :func:`redact_url_credentials`, the single place where the shape of a
-credentialed URL is defined for the whole backend.
+credentialed URL is defined for the whole backend, and
+:data:`QUERY_TOKEN_PATTERN` / :class:`QueryTokenRedactFilter` for tokens
+carried in a query string.
 """
 """
 
 
 from __future__ import annotations
 from __future__ import annotations
@@ -17,6 +19,7 @@ from __future__ import annotations
 import asyncio
 import asyncio
 import logging
 import logging
 import re
 import re
+from urllib.parse import urlsplit
 
 
 # ``scheme://user:secret@host`` — the only URL shape that carries a secret.
 # ``scheme://user:secret@host`` — the only URL shape that carries a secret.
 # Both userinfo parts exclude ``/`` so the match can never run past the
 # Both userinfo parts exclude ``/`` so the match can never run past the
@@ -38,13 +41,27 @@ import re
 # makes the work per offset constant. 63 is far above any real scheme (the
 # makes the work per offset constant. 63 is far above any real scheme (the
 # longest registered one is under 20 characters), and a longer pseudo-scheme
 # longest registered one is under 20 characters), and a longer pseudo-scheme
 # still gets its secret masked — the match simply starts from a later offset.
 # still gets its secret masked — the match simply starts from a later offset.
-URL_CREDENTIALS_PATTERN = re.compile(
-    r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://)(?P<user>[^/:@\s]+):(?P<secret>[^/\s]+)@"
-)
+URL_CREDENTIALS_PATTERN = re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://)(?P<login>[^/\s]+)@")
+
+
+def _mask_login(match: re.Match[str]) -> str:
+    # The login runs to the last @ before the path. It may hold an unencoded ?
+    # or #, which cannot be told from a query: masked rather than risk it. Its
+    # user part (up to the first ':') survives; without a ':' it may be a
+    # token, so it all goes.
+    login = match.group("login")
+    shown = f"{login.split(':', 1)[0]}:[REDACTED]" if ":" in login else "[REDACTED]"
+    return f"{match.group('scheme')}{shown}@"
 
 
 
 
 def redact_url_credentials(text: str | None) -> str | None:
 def redact_url_credentials(text: str | None) -> str | None:
-    """Mask the password in every ``scheme://user:secret@host`` URL in *text*.
+    """Mask the login secret in every ``scheme://login@host`` URL in *text*.
+
+    The login runs to the last ``@`` before the path (``?`` and ``#``
+    included, when in doubt it is masked), so an empty user
+    (``:secret@``), an unencoded ``@`` in the user and a bare token
+    (``token@``, masked whole) are covered as well as ``user:secret@``.
+    Also used for camera URLs shown to those who may not edit the printer.
 
 
     Subprocesses echo their input URL back at us — ffmpeg prints the RTSP
     Subprocesses echo their input URL back at us — ffmpeg prints the RTSP
     input in its ``Input #0`` line, so logging its stderr verbatim publishes
     input in its ``Input #0`` line, so logging its stderr verbatim publishes
@@ -57,7 +74,162 @@ def redact_url_credentials(text: str | None) -> str | None:
     """
     """
     if not text or "://" not in text or "@" not in text:
     if not text or "://" not in text or "@" not in text:
         return text
         return text
-    return URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>\g<user>:[REDACTED]@", text)
+    return URL_CREDENTIALS_PATTERN.sub(_mask_login, text)
+
+
+# ``?token=<value>`` and its kin. The SPA passes its short-lived WebSocket and
+# media tokens in the query string (a browser cannot set headers on a
+# WebSocket upgrade, an <img> or a <video>), and uvicorn logs every request
+# path with its query: the access line for each GET, and the "WebSocket ...
+# [accepted]" line for each upgrade. Those reach the console, so `docker logs`,
+# and from there anything that collects container logs -- the appliance's
+# support bundle among them. The value ends at the next ``&``, whitespace or
+# quote, which is where uvicorn's format closes the path.
+QUERY_TOKEN_PATTERN = re.compile(r"(?P<key>[?&](?:token|access_token|api_key|url)=)[^&\s\"']+")
+
+
+def redact_query_tokens(text: str | None) -> str | None:
+    """Mask the value of every token-bearing query parameter in *text*."""
+    if not text or "=" not in text:
+        return text
+    return QUERY_TOKEN_PATTERN.sub(r"\g<key>[REDACTED]", text)
+
+
+# Loggers whose lines name a device by its whole URL, where the path or query
+# can be the device's credential in a shape no pattern recognises (a webhook id,
+# an ``auth_key``). Read back, their URLs are reduced to scheme and host.
+HOST_ONLY_LOGGERS = ("backend.app.services.rest_smart_plug", "httpx")
+
+# Runs to whitespace only: a quote or angle bracket may sit inside a stored
+# URL, and ending there would leave the rest of it in the line.
+_URL_IN_TEXT = re.compile(r"[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://\S+")
+
+
+def url_host_only(url: str | None) -> str:
+    """A URL reduced to scheme and host (with port) for a log line.
+
+    The login, path, query and fragment go. An ``@`` after the part the parser
+    took for the host means a login it could not find (an unencoded ``?``,
+    ``/`` or ``#`` in the password): that part may then hold the login, so it
+    goes as well.
+    """
+    try:
+        parts = urlsplit(url or "")
+    except ValueError:
+        return "<invalid URL>"
+    if not parts.scheme or not parts.netloc:
+        return "<invalid URL>"
+    if "@" in (url or "")[len(parts.scheme) + 3 + len(parts.netloc) :]:
+        return f"{parts.scheme}://<hidden>"
+    return f"{parts.scheme}://{parts.netloc.rsplit('@', 1)[-1]}"
+
+
+# Masked to the end of the line, in lines from those loggers: the REST plug
+# service's messages carry raw input (a refused URL, unparsed headers) or an
+# error's own text, which may quote a URL or a header's value. Covers every
+# message it has logged.
+_MASKED_TAILS = re.compile(r"(invalid URL: |headers JSON: |returned error: |REST smart plug at \S+?: ).*")
+
+
+# An HTTP client refusing a header quotes the header's value, commonly a token,
+# whichever service sent it: masked to the end of the line.
+_REFUSED_HEADER = re.compile(r"(Illegal header (?:value|name) ).*")
+_UNMASKED_REFUSED_HEADER = re.compile(r"Illegal header (?:value|name) (?!\[REDACTED\])")
+
+
+def redact_refused_headers(text: str | None) -> str | None:
+    """Mask the header an HTTP client quotes when it refuses one."""
+    if not text or "Illegal header" not in text:
+        return text
+    return _REFUSED_HEADER.sub(r"\1[REDACTED]", text)
+
+
+def _redact_refused_header_arg(value: object) -> object:
+    """*value* as masked text when its text quotes a refused header (an
+    exception, a string), else *value* itself."""
+    text = value if isinstance(value, str) else str(value)
+    return redact_refused_headers(text) if "Illegal header" in text else value
+
+
+class RefusedHeaderRedactFilter(logging.Filter):
+    """Mask a refused header's value in a record before a handler writes it.
+
+    The value reaches a record as an argument (``"...: %s", e``), in the
+    message itself, or in the traceback of ``logger.exception``, so the
+    rendered message and traceback are checked, not the format string. A
+    record with nothing to mask is left untouched.
+
+    Attach to the handlers that write lines (a handler filter sees records
+    from every logger that propagates to it) and to uvicorn's loggers, which
+    do not propagate to root.
+    """
+
+    _formatter = logging.Formatter()
+
+    def filter(self, record: logging.LogRecord) -> bool:  # noqa: A003 — stdlib API name
+        try:
+            message = record.getMessage()
+        except Exception:  # noqa: BLE001 — a bad format string is the handler's error to report, not the caller's
+            message = ""
+        if "Illegal header" in message:
+            # Masked in the arguments, keeping their shape: uvicorn's access
+            # formatter unpacks them.
+            if isinstance(record.args, tuple):
+                record.args = tuple(_redact_refused_header_arg(a) for a in record.args)
+            elif isinstance(record.args, dict):
+                record.args = {k: _redact_refused_header_arg(v) for k, v in record.args.items()}
+            try:
+                masked = record.getMessage()
+            except Exception:  # noqa: BLE001 — the masked format string no longer fits its arguments
+                masked = message
+            if _UNMASKED_REFUSED_HEADER.search(masked):
+                # The quote is (also) in the format string: the rendered
+                # line, masked, replaces both.
+                record.msg = redact_refused_headers(message)
+                record.args = None
+        if record.exc_info and not record.exc_text:
+            # Formatters reuse exc_text once set, so the masked text is what
+            # every handler then writes.
+            record.exc_text = self._formatter.formatException(record.exc_info)
+        if record.exc_text and "Illegal header" in record.exc_text:
+            record.exc_text = redact_refused_headers(record.exc_text)
+        return True
+
+
+def redact_device_urls(text: str | None) -> str | None:
+    """Every URL in *text* reduced to :func:`url_host_only`, and the tails in
+    ``_MASKED_TAILS`` masked."""
+    if not text:
+        return text
+    text = _MASKED_TAILS.sub(r"\1[REDACTED]", text)
+    if "://" not in text:
+        return text
+    return _URL_IN_TEXT.sub(lambda match: url_host_only(match.group(0)), text)
+
+
+class QueryTokenRedactFilter(logging.Filter):
+    """Mask query-string tokens in uvicorn's request and WebSocket log lines.
+
+    Rewrites the record rather than dropping it: the line is still the record
+    of a request, only its credential goes. Uvicorn passes the path as a
+    ``%s`` argument (``'%s - "%s %s HTTP/%s" %d'`` for HTTP,
+    ``'%s - "WebSocket %s" [accepted]'`` for an upgrade), so the arguments are
+    rewritten as well as the message.
+
+    Attach to ``logging.getLogger("uvicorn.access")`` and
+    ``logging.getLogger("uvicorn.error")`` -- the latter carries the WebSocket
+    lines. A logger-level filter runs whichever handler then writes the record,
+    so the console is covered as well as ``bambuddy.log``.
+    """
+
+    def filter(self, record: logging.LogRecord) -> bool:  # noqa: A003 — stdlib API name
+        if isinstance(record.msg, str):
+            record.msg = redact_query_tokens(record.msg)
+        if isinstance(record.args, tuple):
+            record.args = tuple(redact_query_tokens(a) if isinstance(a, str) else a for a in record.args)
+        elif isinstance(record.args, dict):
+            record.args = {k: redact_query_tokens(v) if isinstance(v, str) else v for k, v in record.args.items()}
+        return True
 
 
 
 
 class WriteRequestsOnlyFilter(logging.Filter):
 class WriteRequestsOnlyFilter(logging.Filter):
@@ -95,6 +267,42 @@ class WriteRequestsOnlyFilter(logging.Filter):
         return any(token in message for token in self._WRITE_VERB_TOKENS)
         return any(token in message for token in self._WRITE_VERB_TOKENS)
 
 
 
 
+class ShutdownCancelledRequestFilter(logging.Filter):
+    """One INFO line for a request uvicorn cancels at the end of a shutdown.
+
+    The service caps the graceful shutdown (``--timeout-graceful-shutdown``)
+    because an MJPEG camera stream never ends by itself. When the cap is
+    reached uvicorn logs "Cancel N running task(s)" and then, for each
+    cancelled request, "Exception in ASGI application" with the full
+    ``CancelledError`` traceback, both at ERROR. That happens on every restart
+    with a camera view open and is not an error. Only that cancellation is
+    rewritten, recognised by uvicorn's own cancel message; any other failure,
+    and any other cancellation, keeps its level and traceback.
+
+    Attach to ``logging.getLogger("uvicorn.error")`` before any filter that
+    renders the traceback.
+    """
+
+    _CANCEL_MESSAGE = "timeout graceful shutdown exceeded"
+
+    def filter(self, record: logging.LogRecord) -> bool:  # noqa: A003 — stdlib API name
+        exc = record.exc_info[1] if record.exc_info else None
+        if isinstance(exc, asyncio.CancelledError) and self._CANCEL_MESSAGE in str(exc):
+            record.msg = "Request closed at shutdown, still open after the grace period (a camera stream, for example)"
+            record.args = None
+            record.exc_info = None
+            record.exc_text = None
+            self._as_info(record)
+        elif exc is None and isinstance(record.msg, str) and record.msg.endswith(self._CANCEL_MESSAGE):
+            self._as_info(record)
+        return True
+
+    @staticmethod
+    def _as_info(record: logging.LogRecord) -> None:
+        record.levelno = logging.INFO
+        record.levelname = logging.getLevelName(logging.INFO)
+
+
 class CancelledPoolNoiseFilter(logging.Filter):
 class CancelledPoolNoiseFilter(logging.Filter):
     """Drop SQLAlchemy connection-pool log records driven by request cancellation.
     """Drop SQLAlchemy connection-pool log records driven by request cancellation.
 
 

+ 19 - 0
backend/app/core/permissions.py

@@ -50,6 +50,10 @@ class Permission(StrEnum):
     QUEUE_DELETE_OWN = "queue:delete_own"
     QUEUE_DELETE_OWN = "queue:delete_own"
     QUEUE_DELETE_ALL = "queue:delete_all"
     QUEUE_DELETE_ALL = "queue:delete_all"
     QUEUE_REORDER = "queue:reorder"
     QUEUE_REORDER = "queue:reorder"
+    # Without it, every job the user queues waits until someone with
+    # queue:update_all starts it (#1620). Only the waiting is enforced here;
+    # queue:create still decides whether they may queue at all.
+    QUEUE_START_UNREVIEWED = "queue:start_unreviewed"
 
 
     # Library
     # Library
     LIBRARY_READ = "library:read"
     LIBRARY_READ = "library:read"
@@ -166,6 +170,10 @@ class Permission(StrEnum):
     MAKERWORLD_VIEW = "makerworld:view"  # Resolve MakerWorld URLs and view model metadata
     MAKERWORLD_VIEW = "makerworld:view"  # Resolve MakerWorld URLs and view model metadata
     MAKERWORLD_IMPORT = "makerworld:import"  # Download 3MFs from MakerWorld into the library
     MAKERWORLD_IMPORT = "makerworld:import"  # Download 3MFs from MakerWorld into the library
 
 
+    # Manyfold Integration (#1471)
+    MANYFOLD_VIEW = "manyfold:view"  # Browse and search the connected Manyfold library
+    MANYFOLD_IMPORT = "manyfold:import"  # Download Manyfold files into the library
+
     # API Keys (admin-level)
     # API Keys (admin-level)
     API_KEYS_READ = "api_keys:read"
     API_KEYS_READ = "api_keys:read"
     API_KEYS_CREATE = "api_keys:create"
     API_KEYS_CREATE = "api_keys:create"
@@ -233,6 +241,7 @@ PERMISSION_CATEGORIES = {
         Permission.QUEUE_DELETE_OWN,
         Permission.QUEUE_DELETE_OWN,
         Permission.QUEUE_DELETE_ALL,
         Permission.QUEUE_DELETE_ALL,
         Permission.QUEUE_REORDER,
         Permission.QUEUE_REORDER,
+        Permission.QUEUE_START_UNREVIEWED,
     ],
     ],
     "Library": [
     "Library": [
         Permission.LIBRARY_READ,  # legacy — kept for back-compat with custom roles
         Permission.LIBRARY_READ,  # legacy — kept for back-compat with custom roles
@@ -343,6 +352,10 @@ PERMISSION_CATEGORIES = {
         Permission.MAKERWORLD_VIEW,
         Permission.MAKERWORLD_VIEW,
         Permission.MAKERWORLD_IMPORT,
         Permission.MAKERWORLD_IMPORT,
     ],
     ],
+    "Manyfold": [
+        Permission.MANYFOLD_VIEW,
+        Permission.MANYFOLD_IMPORT,
+    ],
     "API Keys": [
     "API Keys": [
         Permission.API_KEYS_READ,
         Permission.API_KEYS_READ,
         Permission.API_KEYS_CREATE,
         Permission.API_KEYS_CREATE,
@@ -406,6 +419,7 @@ DEFAULT_GROUPS = {
             Permission.QUEUE_UPDATE_OWN.value,
             Permission.QUEUE_UPDATE_OWN.value,
             Permission.QUEUE_DELETE_OWN.value,
             Permission.QUEUE_DELETE_OWN.value,
             Permission.QUEUE_REORDER.value,
             Permission.QUEUE_REORDER.value,
+            Permission.QUEUE_START_UNREVIEWED.value,
             # Library - own items only
             # Library - own items only
             Permission.LIBRARY_READ_OWN.value,
             Permission.LIBRARY_READ_OWN.value,
             Permission.LIBRARY_UPLOAD.value,
             Permission.LIBRARY_UPLOAD.value,
@@ -414,6 +428,9 @@ DEFAULT_GROUPS = {
             # MakerWorld integration
             # MakerWorld integration
             Permission.MAKERWORLD_VIEW.value,
             Permission.MAKERWORLD_VIEW.value,
             Permission.MAKERWORLD_IMPORT.value,
             Permission.MAKERWORLD_IMPORT.value,
+            # Manyfold integration
+            Permission.MANYFOLD_VIEW.value,
+            Permission.MANYFOLD_IMPORT.value,
             # Orca Cloud — needed for the Slice modal's Orca Cloud preset
             # Orca Cloud — needed for the Slice modal's Orca Cloud preset
             # picker to populate. Workshops that use Orca Cloud presets
             # picker to populate. Workshops that use Orca Cloud presets
             # need every operator to be able to authenticate. Bambu Cloud
             # need every operator to be able to authenticate. Bambu Cloud
@@ -522,6 +539,8 @@ DEFAULT_GROUPS = {
             Permission.WEBSOCKET_CONNECT.value,
             Permission.WEBSOCKET_CONNECT.value,
             # MakerWorld browsing only (no import — that writes to library)
             # MakerWorld browsing only (no import — that writes to library)
             Permission.MAKERWORLD_VIEW.value,
             Permission.MAKERWORLD_VIEW.value,
+            # Manyfold browsing only, for the same reason
+            Permission.MANYFOLD_VIEW.value,
         ],
         ],
         "is_system": True,
         "is_system": True,
     },
     },

+ 280 - 0
backend/app/core/printer_scope.py

@@ -0,0 +1,280 @@
+"""Which printers a caller may see and control (#1727).
+
+Permissions answer *what* a caller may do; a printer scope answers *on which
+printers*. The two are checked separately: a route first passes its
+permission gate, then refuses any printer outside the caller's scope.
+
+How a scope is derived:
+
+* Auth disabled, or an admin user: every printer.
+* A user: the union of the printers of each of their groups that has
+  ``restrict_printers`` set, a group's printers being the ones picked for it
+  plus every printer whose location it was given. A user in no such group sees every printer, so
+  installs that never configure this behave exactly as before. A group
+  without the flag doesn't contribute, so permission groups (Operators,
+  Viewers) combine with team groups without widening them.
+* An API key: its own ``printer_ids`` (None = all), narrowed to its owner's
+  scope, so a key can never reach a printer its owner can't.
+
+A printer outside the scope is reported as missing (404), never as forbidden,
+so its id isn't confirmed to a caller who can't see it.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from dataclasses import dataclass
+
+from fastapi import HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.group import Group, group_locations, group_printers
+
+
+@dataclass(frozen=True)
+class PrinterScope:
+    """The printers a caller may use. ``printer_ids=None`` means all of them."""
+
+    printer_ids: frozenset[int] | None = None
+
+    @property
+    def is_unrestricted(self) -> bool:
+        return self.printer_ids is None
+
+    def allows(self, printer_id: int | None) -> bool:
+        """Whether ``printer_id`` is in scope. ``None`` (no printer) always is."""
+        if printer_id is None or self.printer_ids is None:
+            return True
+        return printer_id in self.printer_ids
+
+    def ensure(self, printer_id: int | None) -> None:
+        """Raise the same 404 a missing printer gets when ``printer_id`` is out of scope."""
+        if not self.allows(printer_id):
+            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Printer not found")
+
+    def intersect(self, other: PrinterScope) -> PrinterScope:
+        if self.printer_ids is None:
+            return other
+        if other.printer_ids is None:
+            return self
+        return PrinterScope(self.printer_ids & other.printer_ids)
+
+    def where(self, column):
+        """A WHERE clause limiting ``column`` (a printer id column) to the scope.
+
+        Returns None when unrestricted so callers can skip the filter. Rows
+        whose printer is NULL stay visible: they aren't bound to any printer
+        (orphaned archives, queue items waiting for a model match).
+        """
+        if self.printer_ids is None:
+            return None
+        return column.is_(None) | column.in_(self.printer_ids)
+
+    def where_strict(self, column):
+        """Like ``where`` but also drops rows with no printer."""
+        if self.printer_ids is None:
+            return None
+        return column.in_(self.printer_ids)
+
+
+ALL_PRINTERS = PrinterScope()
+
+
+def ensure_model_target_allowed(user, scope: PrinterScope) -> None:
+    """Refuse an "any printer of a model" job from a limited caller that is no user.
+
+    The scheduler keeps such a job within its *creator's* scope, but a job
+    queued through an API key records no creator, or its owner as creator, and
+    a key may be limited to fewer printers than its owner, so a limited key
+    could otherwise reach the rest of the fleet through it. A key comes as
+    ``None`` or as its ``ApiKeyActor``. Users are unaffected: their jobs carry
+    their id.
+    """
+    from backend.app.core.auth import ApiKeyActor
+
+    if (user is None or isinstance(user, ApiKeyActor)) and not scope.is_unrestricted:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="A caller limited to certain printers must queue to a specific printer, not to any printer of a model",
+        )
+
+
+async def group_printer_ids(db: AsyncSession, group_id: int) -> list[int]:
+    result = await db.execute(
+        select(group_printers.c.printer_id)
+        .where(group_printers.c.group_id == group_id)
+        .order_by(group_printers.c.printer_id)
+    )
+    return list(result.scalars().all())
+
+
+async def group_location_names(db: AsyncSession, group_id: int) -> list[str]:
+    result = await db.execute(
+        select(group_locations.c.location)
+        .where(group_locations.c.group_id == group_id)
+        .order_by(group_locations.c.location)
+    )
+    return list(result.scalars().all())
+
+
+async def groups_printer_ids(db: AsyncSession, group_ids: Iterable[int]) -> frozenset[int]:
+    """Every printer the given groups reach, picked or through a location."""
+    from backend.app.models.printer import Printer
+
+    ids = list(group_ids)
+    if not ids:
+        return frozenset()
+    picked = select(group_printers.c.printer_id).where(group_printers.c.group_id.in_(ids))
+    located = (
+        select(Printer.id)
+        .join(group_locations, group_locations.c.location == Printer.location)
+        .where(group_locations.c.group_id.in_(ids))
+    )
+    result = await db.execute(picked.union(located))
+    return frozenset(result.scalars().all())
+
+
+async def resolve_user_printer_scope(db: AsyncSession, user) -> PrinterScope:
+    """Scope of a loaded ``User`` (``groups`` must already be loaded)."""
+    if user.is_admin:
+        return ALL_PRINTERS
+    restricted = [g.id for g in user.groups if g.restrict_printers]
+    if not restricted:
+        return ALL_PRINTERS
+    return PrinterScope(await groups_printer_ids(db, restricted))
+
+
+async def location_grantees(db: AsyncSession, locations: Iterable[str | None]) -> list[str]:
+    """Names of the restricted groups granted any of ``locations``.
+
+    A printer moving between these locations changes who can reach it.
+    """
+    names = [loc for loc in locations if loc]
+    if not names:
+        return []
+    result = await db.execute(
+        select(Group.name)
+        .join(group_locations, group_locations.c.group_id == Group.id)
+        .where(group_locations.c.location.in_(names), Group.restrict_printers.is_(True))
+        .distinct()
+        .order_by(Group.name)
+    )
+    return list(result.scalars().all())
+
+
+async def resolve_user_id_printer_scope(db: AsyncSession, user_id: int | None) -> PrinterScope:
+    """Scope of the user with ``user_id``; no user (VP, auth off) means every printer.
+
+    For background work acting on a user's behalf, such as the scheduler
+    choosing a printer for a queued job. A deleted or deactivated user gets an
+    empty scope rather than everything, so their leftover jobs don't spread
+    onto printers they were never allowed to use.
+    """
+    from sqlalchemy.orm import selectinload
+
+    from backend.app.models.user import User
+
+    if user_id is None:
+        return ALL_PRINTERS
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
+    user = result.scalar_one_or_none()
+    if user is None or not user.is_active:
+        return PrinterScope(frozenset())
+    return await resolve_user_printer_scope(db, user)
+
+
+def api_key_own_scope(api_key) -> PrinterScope:
+    """The key's own ``printer_ids`` allowlist, without its owner's narrowing."""
+    if api_key.printer_ids is None:
+        return ALL_PRINTERS
+    return PrinterScope(frozenset(int(pid) for pid in api_key.printer_ids))
+
+
+async def drop_printers_from_api_keys(db, deleted: Iterable[int] | None = None) -> None:
+    """Take deleted printers out of the API keys limited to them.
+
+    *deleted*: the printers being deleted, or None for every id that no longer
+    has a printer (the start-up repair). SQLite gives a freed id to the next
+    printer created, so a stale id would hand the key that printer. A key whose
+    last printer goes keeps an empty list, which reaches no printer; None would
+    reach all of them. *db* is a session or a connection.
+    """
+    from sqlalchemy import update
+
+    from backend.app.models.api_key import APIKey
+    from backend.app.models.printer import Printer
+
+    if deleted is None:
+        existing = set((await db.execute(select(Printer.id))).scalars().all())
+
+        def keeps(printer_id: int) -> bool:
+            return printer_id in existing
+    else:
+        gone = set(deleted)
+
+        def keeps(printer_id: int) -> bool:
+            return printer_id not in gone
+
+    for key_id, printer_ids in (await db.execute(select(APIKey.id, APIKey.printer_ids))).all():
+        if not isinstance(printer_ids, list):
+            continue  # all printers
+        kept = [printer_id for printer_id in printer_ids if keeps(printer_id)]
+        if kept != printer_ids:
+            await db.execute(update(APIKey).where(APIKey.id == key_id).values(printer_ids=kept))
+
+
+async def drop_printers_from_obico(db, deleted: Iterable[int] | None = None) -> None:
+    """Take deleted printers out of either AI failure detection provider's list.
+
+    As drop_printers_from_api_keys: SQLite would enrol the next printer given a
+    stale id, sending its camera to the ML server and pausing its prints. An
+    empty or missing list means all printers and is left as it is; a list whose
+    last printer goes becomes "[]", which watches none.
+    """
+    import json
+
+    from sqlalchemy import update
+
+    from backend.app.models.printer import Printer
+    from backend.app.models.settings import Settings
+
+    gone = set(deleted) if deleted is not None else None
+    existing = set()
+    if gone is None:
+        existing = set((await db.execute(select(Printer.id))).scalars().all())
+    for key in ("obico_enabled_printers", "octoeverywhere_enabled_printers"):
+        raw = (await db.execute(select(Settings.value).where(Settings.key == key))).scalar()
+        if not raw:
+            continue
+        try:
+            printer_ids = json.loads(raw)
+        except ValueError:
+            continue
+        if not isinstance(printer_ids, list):
+            continue
+        if gone is None:
+            kept = [printer_id for printer_id in printer_ids if printer_id in existing]
+        else:
+            kept = [printer_id for printer_id in printer_ids if printer_id not in gone]
+        if kept != printer_ids:
+            # Written as the settings page writes it ("[1,3]"); "[1, 3]" reads there
+            # as an unsaved change and saves itself again.
+            value = json.dumps(kept, separators=(",", ":"))
+            await db.execute(update(Settings).where(Settings.key == key).values(value=value))
+
+
+async def ensure_printers_exist(db: AsyncSession, printer_ids: Iterable[int | None] | None) -> None:
+    """400 for an id with no printer, before it is stored as a limit.
+
+    SQLite enforces no foreign keys and gives a freed id to the next printer
+    created, which a stored stale id would hand whatever it limits.
+    """
+    from backend.app.models.printer import Printer
+
+    wanted = {printer_id for printer_id in printer_ids or () if printer_id is not None}
+    if not wanted:
+        return
+    found = set((await db.execute(select(Printer.id).where(Printer.id.in_(wanted)))).scalars().all())
+    if missing := sorted(wanted - found):
+        raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown printer: {', '.join(map(str, missing))}")

+ 84 - 0
backend/app/core/response_redaction.py

@@ -0,0 +1,84 @@
+"""A refused header's value masked in every JSON response.
+
+An HTTP client refusing a header quotes the header's value, and the value is
+usually a credential: a stored token saved with a stray space or line break.
+That error text reaches the API from many places -- a provider's stored error,
+a backup or restore result, a connection test, a status endpoint, rows stored
+before any of them masked it -- so it is masked once, where every JSON body
+leaves, rather than at each place that may hand one on. Logs are masked by
+``logging_filters.RefusedHeaderRedactFilter``.
+"""
+
+from __future__ import annotations
+
+import re
+
+from starlette.datastructures import MutableHeaders
+from starlette.types import ASGIApp, Message, Receive, Scope, Send
+
+_MARKER = b"Illegal header "
+# Inside a JSON string: masked up to the string's closing quote, so the body
+# stays valid JSON.
+_REFUSED_HEADER_IN_JSON = re.compile(rb'(Illegal header (?:value|name) )(?:[^"\\]|\\.)*')
+
+
+def redact_refused_headers_json(body: bytes) -> bytes:
+    """*body*, a JSON document, with every quoted refused header masked."""
+    if _MARKER not in body:
+        return body
+    return _REFUSED_HEADER_IN_JSON.sub(rb"\1[REDACTED]", body)
+
+
+class RefusedHeaderResponseMask:
+    """ASGI middleware masking refused headers in JSON response bodies.
+
+    Install it innermost (added before any other middleware): outer
+    ``@app.middleware`` layers re-stream a body in chunks. A body with a
+    Content-Length is collected and masked whole, with the length fixed up; a
+    streamed one is masked chunk by chunk.
+    """
+
+    def __init__(self, app: ASGIApp) -> None:
+        self.app = app
+
+    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+        if scope["type"] != "http":
+            await self.app(scope, receive, send)
+            return
+
+        start: Message | None = None
+        sized = False
+        chunks: list[bytes] = []
+
+        async def masking_send(message: Message) -> None:
+            nonlocal start, sized
+            if message["type"] == "http.response.start":
+                headers = MutableHeaders(raw=message["headers"])
+                media_type = headers.get("content-type", "").split(";", 1)[0].strip().lower()
+                if not (media_type == "application/json" or media_type.endswith("+json")):
+                    await send(message)
+                    return
+                start = message
+                sized = "content-length" in headers
+                if not sized:
+                    await send(start)
+                return
+            if start is None or message["type"] != "http.response.body":
+                await send(message)
+                return
+            if not sized:
+                await send({**message, "body": redact_refused_headers_json(message.get("body", b""))})
+                return
+            chunks.append(message.get("body", b""))
+            if message.get("more_body", False):
+                return
+            original = b"".join(chunks)
+            body = redact_refused_headers_json(original)
+            if body is not original:
+                # Only a masked body changes length; a HEAD response's empty
+                # body keeps the length of the GET body it stands for.
+                MutableHeaders(raw=start["headers"])["content-length"] = str(len(body))
+            await send(start)
+            await send({"type": "http.response.body", "body": body, "more_body": False})
+
+        await self.app(scope, receive, masking_send)

+ 122 - 0
backend/app/core/row_visibility.py

@@ -0,0 +1,122 @@
+"""Which owned rows (archives, queue jobs, library files) a caller reaches through something that holds them."""
+
+from backend.app.core.auth import ApiKeyActor
+from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.user import User
+
+
+def owned_row_clauses(
+    model, actor: User | ApiKeyActor | None, all_permission: Permission, own_permission: Permission
+) -> list | None:
+    """WHERE clauses for the rows of ``model`` the caller may reach by ownership, or None for none.
+
+    Their own unless they may reach everyone's (a row without an owner then
+    stays out). Auth off (no actor) reaches all.
+    """
+    if actor is None:
+        return []
+    # A key acts with its owner's rights to these rows, not with its own scope
+    # flags (a project key needs no archive or queue flag, #1893), so it never
+    # reaches a row its owner could not. A legacy key without an owner keeps
+    # every row.
+    user = actor.owner if isinstance(actor, ApiKeyActor) else actor
+    if user is None or user.has_permission(all_permission.value):
+        return []
+    if user.has_permission(own_permission.value):
+        return [model.created_by_id == user.id]
+    return None
+
+
+def visible_row_clauses(
+    model: type[PrintArchive] | type[PrintQueueItem],
+    printer_scope: PrinterScope,
+    actor: User | ApiKeyActor | None,
+    all_permission: Permission,
+    own_permission: Permission,
+    *,
+    by_runs: bool = False,
+) -> list | None:
+    """WHERE clauses for the archive or queue rows the caller may reach, or None for none.
+
+    The rows the caller reaches through the archives or the queue themselves:
+    on their printers, and by ``owned_row_clauses``. ``by_runs``: archives
+    printed on one of their printers too (``archive_scope_clause``), for a
+    listing that shows them as the caller sees them (the archives routes'
+    ``_scoped``); others show an archive's latest print as it is.
+    """
+    owned = owned_row_clauses(model, actor, all_permission, own_permission)
+    if owned is None:
+        return None
+    clause = (
+        archive_scope_clause(printer_scope)
+        if model is PrintArchive and by_runs
+        else printer_scope.where(model.printer_id)
+    )
+    if clause is not None:
+        return [clause, *owned]
+    return owned
+
+
+def archive_scope_clause(printer_scope: PrinterScope):
+    """The archives a printer scope sees (#1727), as a WHERE clause; None
+    when it sees every printer. An archive is a file printed as runs, maybe
+    on several printers (archive redesign): it is seen on its own printer
+    (the latest run's, or none) and on every printer one of its runs printed
+    on, a print running now included.
+    """
+    from sqlalchemy import or_, select
+
+    from backend.app.models.print_log import PrintLogEntry
+
+    own = printer_scope.where(PrintArchive.printer_id)
+    if own is None:
+        return None
+    # The table, not the entity: the ORM hides open runs from every read of
+    # PrintLogEntry, and a print running on the caller's printer counts.
+    runs = PrintLogEntry.__table__
+    printed_here = select(runs.c.archive_id).where(
+        runs.c.archive_id.is_not(None), runs.c.printer_id.in_(printer_scope.printer_ids or ())
+    )
+    return or_(own, PrintArchive.id.in_(printed_here))
+
+
+def whole_archive_scope_clause(printer_scope: PrinterScope):
+    """The archives every print of which the scope sees, as a WHERE clause;
+    None when it sees every printer. What changes or removes the whole
+    archive (its file, its video, the archive itself) needs this: it would
+    change other printers' prints too (archive redesign)."""
+    from sqlalchemy import and_, not_, select
+
+    from backend.app.models.print_log import PrintLogEntry
+
+    own = printer_scope.where(PrintArchive.printer_id)
+    if own is None:
+        return None
+    runs = PrintLogEntry.__table__
+    printed_elsewhere = select(runs.c.archive_id).where(
+        runs.c.archive_id.is_not(None),
+        runs.c.printer_id.is_not(None),
+        runs.c.printer_id.not_in(printer_scope.printer_ids or ()),
+    )
+    return and_(own, not_(PrintArchive.id.in_(printed_elsewhere)))
+
+
+def may_reach_row(
+    created_by_id: int | None,
+    actor: User | ApiKeyActor | None,
+    all_permission: Permission,
+    own_permission: Permission,
+) -> bool:
+    """Whether the caller may reach one row owned by ``created_by_id``, printer scope aside.
+
+    The rule of ``visible_row_clauses`` for a row already loaded.
+    """
+    if actor is None:
+        return True
+    user = actor.owner if isinstance(actor, ApiKeyActor) else actor
+    if user is None or user.has_permission(all_permission.value):
+        return True
+    return user.has_permission(own_permission.value) and created_by_id is not None and created_by_id == user.id

+ 325 - 0
backend/app/core/sqlite_fk.py

@@ -0,0 +1,325 @@
+"""Selected ON DELETE rules of the foreign keys, applied by hand on SQLite.
+
+The SQLite database runs with ``PRAGMA foreign_keys`` off: turning it on breaks
+the migrations' table rebuilds (measured: every test errors with "no such table:
+main.print_archives"). So a delete there applies none of the models' ON DELETE
+CASCADE / SET NULL rules. Rows pointing at the deleted row keep its id, and
+SQLite hands the highest freed id to the next row created, which then inherits
+them: a queue item prints another archive, a project shows another project's
+files.
+
+Only the references in APPLIED are handled: those where a dangling id does harm
+and the model's rule is what the app wants. Not every model rule is (deleting a
+printer would make a printer-scoped notification provider global, and delete
+finished queue history), so a reference is added here deliberately, never by
+default.
+
+Hooks apply them at every ORM delete, so no delete path needs to remember:
+
+- ``after_delete`` on every mapped class collects the ids of ``db.delete(obj)``,
+  and ``after_flush`` applies the rules once per flush, one statement per rule;
+- ``do_orm_execute`` applies them before ``db.execute(delete(Model)...)``.
+
+Rule statements go straight to the connection and don't trigger the hooks again.
+``sweep_orphans`` repairs what earlier deletes left behind; init_db runs it at
+every start. Nothing here does anything on PostgreSQL, which enforces the rules
+itself.
+"""
+
+import logging
+from functools import cache
+from typing import Any
+
+from sqlalchemy import Column, Connection, Delete, ForeignKey, Table, delete, event, exists, select, text, update
+from sqlalchemy.orm import Mapper, ORMExecuteState, Session
+
+from backend.app.core.database import Base
+
+logger = logging.getLogger(__name__)
+
+# (child table, child column) whose ON DELETE rule is applied on SQLite.
+APPLIED: frozenset[tuple[str, str]] = frozenset(
+    {
+        # print_archives
+        ("active_print_spoolman", "archive_id"),
+        ("archive_aliases", "archive_id"),
+        ("budget_reservations", "print_archive_id"),
+        ("library_folders", "archive_id"),
+        ("pending_uploads", "archived_id"),
+        ("pipeline_runs", "source_archive_id"),
+        ("print_batches", "archive_id"),
+        ("print_log_entries", "archive_id"),
+        ("print_queue", "archive_id"),
+        ("project_bom_items", "archive_id"),
+        ("spool_usage_history", "archive_id"),
+        # spool
+        ("spool_usage_history", "spool_id"),
+        ("telegram_pending_verdicts", "archive_id"),
+        ("wallet_transactions", "print_archive_id"),
+        # library_files. print_queue.library_file_id is CASCADE in the model, but
+        # library deletes run release_queue_references first, which keeps the rows
+        # (finished plates count towards a batch); the rule then finds nothing.
+        ("library_file_tags", "file_id"),
+        ("pipeline_runs", "sliced_library_file_id"),
+        ("pipeline_runs", "source_library_file_id"),
+        ("print_archives", "library_file_id"),
+        ("print_batches", "library_file_id"),
+        ("print_queue", "library_file_id"),
+        ("print_queue_variants", "library_file_id"),
+        # print_queue
+        ("pipeline_jobs", "queue_entry_id"),
+        ("print_log_entries", "queue_item_id"),
+        ("print_queue_variants", "queue_item_id"),
+        ("wallet_transactions", "print_queue_id"),
+        # print_batches
+        ("print_batch_plates", "batch_id"),
+        ("print_queue", "batch_id"),
+        # projects
+        ("library_files", "project_id"),
+        ("library_folders", "project_id"),
+        ("pending_uploads", "project_id"),
+        ("print_archives", "project_id"),
+        ("print_batches", "project_id"),
+        ("print_queue", "project_id"),
+        ("project_bom_items", "project_id"),
+        # printers: what the next printer given the id would otherwise take
+        # over. Only these: providers, plugs and virtual printers go with the
+        # printer in its delete route, and the rest is decided separately. The
+        # print being tracked would be restored for a new printer that is already
+        # printing, charging its filament to the deleted printer's spools.
+        ("active_print_sessions", "printer_id"),
+        ("active_print_spoolman", "printer_id"),
+        ("pipeline_jobs", "assigned_printer_id"),
+        ("slicer_pipelines", "target_printer_id"),
+        # A deleted printer's spools would stay "assigned" to it (SA-1).
+        ("spool_assignment", "printer_id"),
+        # A profile default's K profile would select a calibration slot on
+        # the next printer given the id (#1326).
+        ("filament_profile_default_k_profile", "printer_id"),
+        # filament_profile_default: the next default given the id would take
+        # over its presets and K profiles (#1326).
+        ("filament_profile_default_k_profile", "profile_default_id"),
+        ("filament_profile_default_preset", "profile_default_id"),
+        # external_links: the next link given the id would show the count an
+        # app reported for the deleted one.
+        ("external_link_badges", "link_id"),
+        # smart_plugs: a printer delete now deletes its plugs; their energy
+        # history would pass to the next plug given the id.
+        ("smart_plug_energy_snapshots", "plug_id"),
+        # users: everything a user owns goes with them or loses its owner, or a
+        # new user given the freed id inherits it (wallet, cost centres, private
+        # folders). Without an owner, folders and private cost centres are left
+        # to admins, not opened to everyone.
+        ("announcement_reads", "user_id"),
+        ("api_keys", "user_id"),
+        ("connected_app_grants", "user_id"),
+        ("connected_apps", "created_by_id"),
+        ("cost_center_members", "user_id"),
+        ("cost_centers", "owner_user_id"),
+        ("file_variant_groups", "created_by_id"),
+        ("library_files", "created_by_id"),
+        ("library_folders", "created_by_id"),
+        ("long_lived_tokens", "user_id"),
+        ("pipeline_runs", "created_by"),
+        ("print_archives", "created_by_id"),
+        ("print_batches", "created_by_id"),
+        ("print_log_entries", "created_by_id"),
+        ("print_queue", "created_by_id"),
+        ("scheduled_dryings", "created_by_id"),
+        ("slicer_pipelines", "created_by"),
+        ("sponsor_toast_state", "user_id"),
+        ("user_email_preferences", "user_id"),
+        ("user_groups", "user_id"),
+        ("user_oidc_links", "user_id"),
+        ("user_otp_codes", "user_id"),
+        ("user_totp", "user_id"),
+        ("user_wallets", "user_id"),
+        ("wallet_transactions", "created_by_user_id"),
+        ("wallet_transactions", "user_id"),
+    }
+)
+
+_PENDING = "bambuddy_sqlite_fk_pending"
+_TABLES = "bambuddy_sqlite_fk_tables"
+# Keeps any one statement's IN list small.
+_CHUNK = 500
+_MAX_DEPTH = 32
+
+
+def _applies(connection: Connection) -> bool:
+    """Only SQLite: PostgreSQL enforces the rules itself."""
+    return connection.dialect.name == "sqlite"
+
+
+def _existing_tables(connection: Connection) -> set[str]:
+    """Tables the database has (cached on the connection); a model without one has no rows to fix."""
+    tables = connection.info.get(_TABLES)
+    if tables is None:
+        result = connection.execute(text("SELECT name FROM sqlite_master WHERE type = 'table'"))
+        tables = connection.info[_TABLES] = {row[0] for row in result}
+    return tables
+
+
+def _single_primary_key(table: Table) -> Column | None:
+    columns = list(table.primary_key.columns)
+    return columns[0] if len(columns) == 1 else None
+
+
+@cache
+def _applied_foreign_keys() -> tuple[ForeignKey, ...]:
+    """The model foreign keys of APPLIED references to a single-column primary key."""
+    from backend.app.core.database import load_all_models
+
+    found = []
+    for table in load_all_models().sorted_tables:
+        for fk in table.foreign_keys:
+            if (
+                (table.name, fk.parent.name) in APPLIED
+                and fk.ondelete in ("CASCADE", "SET NULL")
+                and isinstance(fk.column.table, Table)
+                and fk.column is _single_primary_key(fk.column.table)
+            ):
+                found.append(fk)
+    return tuple(found)
+
+
+@cache
+def _rules(parent_table: str) -> tuple[tuple[Table, Column, str], ...]:
+    """(child table, child column, ON DELETE action) of the APPLIED references to *parent_table*."""
+    return tuple(
+        (fk.parent.table, fk.parent, str(fk.ondelete))
+        for fk in _applied_foreign_keys()
+        if fk.column.table.name == parent_table and isinstance(fk.parent.table, Table)
+    )
+
+
+def _apply(connection: Connection, parent_table: str, ids: Any, depth: int = 0) -> None:
+    """Apply the rules for rows of *parent_table* whose primary key is in *ids* (a list or a select)."""
+    rules = _rules(parent_table)
+    if not rules or depth > _MAX_DEPTH:
+        return
+    if isinstance(ids, list):
+        for start in range(0, len(ids), _CHUNK):
+            _apply_chunk(connection, rules, ids[start : start + _CHUNK], depth)
+    else:
+        _apply_chunk(connection, rules, ids, depth)
+
+
+def _apply_chunk(connection: Connection, rules: tuple, ids: Any, depth: int) -> None:
+    tables = _existing_tables(connection)
+    for child, column, action in rules:
+        if child.name not in tables:
+            continue
+        if action == "SET NULL":
+            connection.execute(update(child).where(column.in_(ids)).values({column.name: None}))
+            continue
+        child_key = _single_primary_key(child)
+        if child_key is not None:
+            # By depth, not by table: a table that refers to itself recurses too.
+            _apply(connection, child.name, select(child_key).where(column.in_(ids)), depth + 1)
+        connection.execute(delete(child).where(column.in_(ids)))
+
+
+@event.listens_for(Base, "after_delete", propagate=True)
+def _collect_orm_delete(mapper: Mapper, connection: Connection, target: Any) -> None:
+    table = mapper.local_table
+    session = Session.object_session(target)
+    if session is None or not isinstance(table, Table) or not _rules(table.name) or not _applies(connection):
+        return
+    pending = session.info.setdefault(_PENDING, {})
+    pending.setdefault(table.name, []).append(mapper.primary_key_from_instance(target)[0])
+
+
+@event.listens_for(Session, "after_flush")
+def _apply_orm_deletes(session: Session, flush_context: Any) -> None:
+    pending = session.info.pop(_PENDING, None)
+    if not pending:
+        return
+    connection = session.connection()
+    # The parent rows are already gone; with foreign keys off that doesn't stop
+    # the rules from finding their dependents.
+    for table, ids in pending.items():
+        _apply(connection, table, ids)
+
+
+@event.listens_for(Session, "after_soft_rollback")
+def _forget_pending(session: Session, previous_transaction: Any) -> None:
+    # A flush that failed after collecting must not leave ids for the next one.
+    session.info.pop(_PENDING, None)
+
+
+@event.listens_for(Session, "do_orm_execute")
+def _before_bulk_delete(state: ORMExecuteState) -> None:
+    statement = state.statement
+    if not state.is_delete or not isinstance(statement, Delete):
+        return
+    table = statement.table
+    if not isinstance(table, Table) or not _rules(table.name):
+        return
+    key = _single_primary_key(table)
+    if key is None:
+        return
+    connection = state.session.connection()
+    if not _applies(connection):
+        return
+    # The session would flush pending changes only after this hook; flush now so
+    # the rules see them (a queue item just moved off the archive must not go).
+    if state.session.autoflush:
+        state.session.flush()
+    where = statement.whereclause
+    _apply(connection, table.name, select(key).where(where) if where is not None else select(key))
+
+
+def _still_not_null(connection: Connection, column: Column) -> bool:
+    """Whether the column is NOT NULL in the database, whatever the model says."""
+    rows = connection.execute(text(f'PRAGMA table_info("{column.table.name}")'))
+    return any(row[1] == column.name and row[3] for row in rows)
+
+
+async def sweep_orphans(conn: Any) -> None:
+    """Repair APPLIED references to rows that no longer exist (SQLite only).
+
+    A SET NULL reference to a missing row is cleared and a CASCADE row is
+    deleted, which is what the rule would have done at the delete. Repeated
+    until a pass changes nothing, since a deleted row can leave its own
+    dependents behind.
+    """
+
+    def run(sync: Any) -> None:
+        connection = sync.connection() if isinstance(sync, Session) else sync
+        if not _applies(connection):
+            return
+        tables = _existing_tables(connection)
+        rules = [
+            fk for fk in _applied_foreign_keys() if fk.parent.table.name in tables and fk.column.table.name in tables
+        ]
+        refused = {fk for fk in rules if fk.ondelete == "SET NULL" and _still_not_null(connection, fk.parent)}
+        for fk in refused:
+            # A migration that would have dropped it failed (and said so); clearing
+            # the column would fail too, and take the start of the app with it.
+            logger.warning("Not clearing %s.%s: the column is still NOT NULL", fk.parent.table.name, fk.parent.name)
+        rules = [fk for fk in rules if fk not in refused]
+        for _ in range(10):
+            changed = 0
+            for fk in rules:
+                child, column, referenced = fk.parent.table, fk.parent, fk.column
+                missing = column.is_not(None) & ~exists(
+                    select(1).select_from(referenced.table).where(referenced == column)
+                )
+                if fk.ondelete == "SET NULL":
+                    result = connection.execute(update(child).where(missing).values({column.name: None}))
+                else:
+                    result = connection.execute(delete(child).where(missing))
+                if result.rowcount:
+                    changed += result.rowcount
+                    logger.info(
+                        "%s %d %s row(s) whose %s no longer exists",
+                        "Cleared" if fk.ondelete == "SET NULL" else "Deleted",
+                        result.rowcount,
+                        child.name,
+                        referenced.table.name,
+                    )
+            if not changed:
+                return
+
+    await conn.run_sync(run)

+ 153 - 0
backend/app/core/static_assets.py

@@ -0,0 +1,153 @@
+"""Serving the frontend build's /assets directory (#3175).
+
+Plain ``StaticFiles`` sends every file uncompressed and with no
+Cache-Control, so a phone downloads the whole bundle in full and the
+browser asks again on every load. This subclass adds two things:
+
+- gzip for text assets when the browser accepts it. Each file is
+  compressed once and kept in memory, keyed by path, size and mtime: Vite
+  never changes a hashed file in place, and a rebuild that does replace one
+  changes the key. Compressing per request rather than shipping ``.gz``
+  copies keeps ``static/`` (tracked in git) at its current size.
+- ``immutable`` caching for the files Vite names by content hash. The
+  pdf.js runtime data under ``assets/pdfjs/`` keeps its upstream names, so
+  it is revalidated instead.
+
+Only /assets uses this. An app-wide compression middleware would also
+reach the camera streams and the WebSocket.
+"""
+
+from __future__ import annotations
+
+import gzip
+import os
+import re
+from collections import OrderedDict
+
+import anyio
+from starlette.datastructures import Headers
+from starlette.responses import FileResponse, Response
+from starlette.staticfiles import NotModifiedResponse, StaticFiles
+from starlette.types import Scope
+
+# Vite's output name: <name>-<8-char hash>.<ext>, directly in assets/.
+_HASHED_ASSET_RE = re.compile(r"^[^/\\]+-[A-Za-z0-9_-]{8}\.[A-Za-z0-9]+$")
+
+IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"
+REVALIDATE_CACHE_CONTROL = "no-cache"
+
+_COMPRESSIBLE_SUFFIXES = (".js", ".mjs", ".css", ".json", ".svg", ".map", ".wasm", ".txt", ".html")
+# Below this the gzip header costs more than it saves.
+_MIN_COMPRESS_SIZE = 1024
+# The whole build gzips to a few MB; the cap only matters if a pile of
+# rebuilds runs against one long-lived process.
+_CACHE_MAX_BYTES = 64 * 1024 * 1024
+
+
+def accepts_gzip(accept_encoding: str | None) -> bool:
+    """Whether an Accept-Encoding header allows gzip (q=0 refuses it)."""
+    if not accept_encoding:
+        return False
+    qualities: dict[str, float] = {}
+    for part in accept_encoding.split(","):
+        coding, *params = part.strip().split(";")
+        q = 1.0
+        for param in params:
+            name, _, value = param.strip().partition("=")
+            if name.strip().lower() == "q":
+                try:
+                    q = float(value)
+                except ValueError:
+                    q = 0.0
+        qualities[coding.strip().lower()] = q
+    if "gzip" in qualities:
+        return qualities["gzip"] > 0
+    return qualities.get("*", 0.0) > 0
+
+
+class _GzipCache:
+    """Compressed bodies, least recently used dropped first."""
+
+    def __init__(self, max_bytes: int) -> None:
+        self.max_bytes = max_bytes
+        self._entries: OrderedDict[tuple[str, int, int], bytes] = OrderedDict()
+        self._size = 0
+
+    async def get(self, path: str, stat_result: os.stat_result) -> bytes:
+        key = (path, stat_result.st_size, stat_result.st_mtime_ns)
+        body = self._entries.get(key)
+        if body is not None:
+            self._entries.move_to_end(key)
+            return body
+        body = await anyio.to_thread.run_sync(_gzip_file, path)
+        # Two first requests for one file can both get here; the second
+        # simply replaces the first's entry.
+        if key in self._entries:
+            self._size -= len(self._entries.pop(key))
+        if len(body) <= self.max_bytes:
+            self._entries[key] = body
+            self._size += len(body)
+            while self._size > self.max_bytes:
+                _, dropped = self._entries.popitem(last=False)
+                self._size -= len(dropped)
+        return body
+
+    def clear(self) -> None:
+        self._entries.clear()
+        self._size = 0
+
+
+def _gzip_file(path: str) -> bytes:
+    with open(path, "rb") as f:
+        # mtime=0 keeps the output identical for identical input.
+        return gzip.compress(f.read(), compresslevel=6, mtime=0)
+
+
+_gzip_cache = _GzipCache(_CACHE_MAX_BYTES)
+
+
+def _gzip_etag(etag: str) -> str:
+    """The gzip variant's ETag. It must differ from the identity one, or a
+    cache could answer a gzip request with the uncompressed body."""
+    return etag[:-1] + '-gzip"' if etag.endswith('"') else etag + "-gzip"
+
+
+class AssetStaticFiles(StaticFiles):
+    async def get_response(self, path: str, scope: Scope) -> Response:
+        response = await super().get_response(path, scope)
+        if response.status_code not in (200, 304):
+            return response
+
+        response.headers["Cache-Control"] = (
+            IMMUTABLE_CACHE_CONTROL if _HASHED_ASSET_RE.match(path) else REVALIDATE_CACHE_CONTROL
+        )
+        if not path.lower().endswith(_COMPRESSIBLE_SUFFIXES):
+            return response
+        response.headers["Vary"] = "Accept-Encoding"
+
+        if (
+            not isinstance(response, FileResponse)
+            or response.status_code != 200
+            or scope["method"] != "GET"
+            or response.stat_result is None
+            or response.stat_result.st_size < _MIN_COMPRESS_SIZE
+        ):
+            return response
+        request_headers = Headers(scope=scope)
+        # Ranges are byte offsets into the identity body; leave them to FileResponse.
+        if "range" in request_headers or not accepts_gzip(request_headers.get("accept-encoding")):
+            return response
+
+        headers = dict(response.headers)
+        headers.pop("content-length", None)
+        headers.pop("accept-ranges", None)
+        headers["content-encoding"] = "gzip"
+        etag = headers.get("etag")
+        if etag:
+            headers["etag"] = _gzip_etag(etag)
+            if_none_match = request_headers.get("if-none-match")
+            if if_none_match and headers["etag"] in [t.strip().removeprefix("W/") for t in if_none_match.split(",")]:
+                return NotModifiedResponse(Headers(headers))
+
+        body = await _gzip_cache.get(str(response.path), response.stat_result)
+        return Response(content=body, status_code=200, headers=headers)

Some files were not shown because too many files changed in this diff