Conversation
…cks, notifications, API; frontend design preview Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tings, kiosk, i18n; backend loop-safe scheduling; tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… stream test, CSP, PWA icons, Docker image, CI, docs, screenshots Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…heet again Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…visible while editing Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…while typing; sheets reset on open; upload errors are shown Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…racters are missing Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…wording, UniFi over API keys Settings sheets show every change on the card before it is saved: a preview endpoint fetches with draft options, closing a sheet discards. Adapter texts and value labels stay English on the server and are translated in the frontend by their English wording (texts.de.json), guarded by tests that fail when a text has no German entry. Cards: whole card opens its link, floating controls, status dot with the reason in words, red veil with the reason on a failed fetch, no more cut titles, history line only once it has something to say, minimum size equals the size a card was created with, free placement without cards pushing each other, board option to push cards up. Icons: one Symbol field with suggestions and an overview of every symbol and logo; the nexapps logos ship with the app; the failed-image state is per address. Passwords get an eye and a confirmation field. Weather takes a place name. Health checks reuse one client per TLS mode (a second of latency on Windows), a broken check no longer stops the others, ping without process support fails readably, uptime bars travel with every result and can show 24 h, 6 h, 1 h or one bar per check. UniFi: Integration API with an API key (Network 9+) first, local account as fallback, gateway found by model name, throughput in Mbit/s, five widgets (network, console, devices, findings, WLANs), confirmed against a Dream Machine SE on Network 10.6. Nexview: all findings named, confirmed live, beta removed. A Problems widget lists every yellow or red card of a board; each card can hand its alarm to it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…gy containers and VMs; pages can be deleted
Plex: "Sign in with Plex" fetches the token through plex.tv (PIN, polled
every two seconds) and offers the account's own server, local address first.
A "Recently added" widget shows the newest movies, series or albums as a
poster grid; posters and stream thumbnails now come through the server
(GET /widgets/{id}/image), so no service token appears in an image address
any more. The library card chooses what to show (everything, movies, series,
music) and how (big number with chips, or a row of icons with numbers); a
fetch may pick another renderer through meta.renderer. The streams line no
longer runs behind the library count.
Synology: containers of the Container Manager with CPU and memory, start,
stop and restart (confirmed against DSM 7); virtual machines of the Virtual
Machine Manager with CPU and memory usage per guest and power actions; DSM
error numbers are named.
Boards: a page can be deleted from an empty page and, with a confirmation
naming its cards, from the board settings; "Move to page" appears only with
two or more pages and resets on open. After saving widget settings the card
keeps its preview until fresh data arrives instead of flashing old numbers.
The counters row got more air between icon, number and label.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…erator cards
Plex: the history is read page by page (the card stopped after 500 of 649
plays), devices are counted by id instead of name (two Apple TVs were one),
empty account slots are no longer listed, and the four operator cards
(findings, server load, users and devices, top of the week) land.
Jellyfin and Emby: recently added with covers (one tile per series),
findings (pending restart, failed or running tasks, scans, failed sign-ins,
errors in the activity log, plugins, low disk space), users and devices,
and the most played titles, all read from the activity log. Emby writes its
internal user number into the log; it is resolved through /Users/{number}.
Neither API reports the server's own load, so there is no load card.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, twelve adapters out of beta Reolink: one adapter for a Home Hub, an NVR or a single camera. Cameras (state, battery where the device lists one, current detections), Camera (snapshot every few seconds or live video) and Findings. Snapshots come through the image proxy with the device's session token and are never cached; live video is the camera's HTTP-FLV stream relayed by the server and played in the browser with mpegts.js, loaded only when a live card is on the board. The device allows few sessions: one token per integration, logout when the server stops (new Adapter.close hook, called by the collector), a minute of quiet after the device refused a login. Measured at a hub with seven cameras: 10 fps fluent, 20 fps clear, both H.264. Images and streams: Adapter.image_source and Adapter.stream_source name the real request; the image route honours cache_seconds, the new stream route relays bytes as they arrive (64 kB chunks made the player stutter). The Content-Security-Policy allows media-src blob:, with a guard. App tiles may follow an integration: name and icon as suggestions, link and reachability check take the service's address on every read, so a changed address is changed once. Beta: every widget of every connected adapter ran against a live service; Emby, Jellyfin, Lidarr, Plex, Radarr, Reolink, SABnzbd, Seerr, Sonarr, Synology, UniFi and Home Assistant lose the mark, a guard keeps the list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The bar at the top is the same on every page now: unread notices, dark and light as two segments, the language, and the account behind its picture. It lives in one component, because the board bar and the bar on every other page had already drifted apart. Accounts get a picture, checked by its first bytes and served only to signed-in browsers, and an e-mail address, unique across accounts so a password reset can end at exactly one of them. An administrator can set someone else's password without knowing the old one. The installation gets a mail server of its own for what has to go out before anybody is signed in. The settings are cut in two: what belongs to a person under /settings, what belongs to the installation under /system, with the connections, users, address, mail server, sign-in providers and an About page that says what nexdeck stands on. Old /settings addresses still lead to the right page. Notifications were rebuilt after nexview: a row of services with their logos, the ways as tiles, a step-by-step guide beside the fields, several ways per service, saving and testing in one button. Channel texts and event names are translated by their English wording like the adapters, and a guard keeps a new one from slipping through in English. Seven tests prove what each channel actually sends, against local catchers. Boards: pages are listed and can be deleted, the owner is named instead of telling every administrator that every board is his, a tick decides what stands in the menu, and the list holds what belongs to you unless you ask for all of them. Connections can be locked so only administrators build cards on them. Fixed on the way: a guest with no board could not even sign out, the counts said "1 Seiten", and a board test raced the collector and failed under load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three blocks of adapters, each with demo data, German wording, recorded answers and a mutation probe per parser. The ten that were missed most: Bazarr, Overseerr, Jellyseerr, Immich, Traefik, Nginx Proxy Manager, PeaNUT, OPNsense, pfSense, Nextcloud, Scrutiny, Tautulli, Gotify, ntfy. The media corner: Audiobookshelf, Navidrome, Komga, Kavita, Calibre-Web, Tdarr, Unmanic, FileFlows, Maintainerr, Jellystat, Frigate. Network and access: Tailscale, Headscale, Gluetun, Technitium, NextDNS, MikroTik, authentik. Skipped for want of a documented API: WG-Easy, OpenWrt, Omada, Authelia. The rest of the house: Grafana, Coolify, Paperless-ngx, Syncthing, Proxmox Backup Server, FRITZ!Box, evcc. Skipped: Dockge speaks only over its socket, Komodo does not document its state values, and the ESPHome dashboard marks its REST endpoints deprecated. Twelve adapters then ran against real services in Docker and lost the beta mark, and three of them were wrong: - qBittorrent 5 answers the sign-in with 204 and no body instead of "200 Ok.". Reading only the old answer, the connection test failed while every card worked, because the session cookie arrives either way. Three tests now hold both generations apart. - Grafana put the folder's random uid on the card where its name belongs; "file" carries the title. - Glances reported the three files Docker mounts into every container as file systems, each with the host disk's size. New cards are also named in the language they were added in, and Technitium's habit of answering the day range with zeros on a fresh server is written down where the next reader will find it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Widget options, a query string and a request path each carried a connection number that nothing checked. The merged calendar resolved whatever stood in its sources, the JSON card let a request path replace the whole address while still sending the bearer token, the Docker discovery listed the containers of any engine, and a log card needed only "may look". One check now decides all four. Tokens learn to expire and to be withdrawn. An API token outlived the password change that was meant to lock its owner out; a kiosk token had no end, and rode in every image, video and event address because none of those can carry a header. A display hands its token in once and gets a signed cookie. Withdrawing keeps the row, so the same hash can never be granted again. Timestamps keep their zone. SQLite has no type for a moment in time and the column dropped the offset, so comparisons raised TypeError and the browser read the text as local time: the same notice showed one time live and another after a reload. New - A search card, so the way out is not only behind Ctrl+K. It carries the shortcuts on its buttons, and the field never gives way to them. - Wake-on-LAN stands on its own. A connection meant one "integration" per machine before a card could exist; the card holds its settings now, and draws either as details with a button or as one big button. Fixed - YouTube could not resolve a single handle. From an EU address the channel page is the consent wall, and the page's first "channelId" belongs to a recommended video, not to the channel. - The widget library could not find a card by its technical name, so "wol", "pbs" and "npm" found nothing. - A card could not be made smaller than the size it was created with, which made min_size dead on all 195 widgets. The floor is now what the adapter calls its minimum. - Two settings pages reached for button classes that were never written, and rendered as bare text. A test now holds every own class against the stylesheet. - Ctrl+K answered "nothing matches" to a shortcut on its own. It shows the target and waits for a word. Removed - Reddit. Measured from an ordinary connection: the public listings refuse an honest user agent, a browser one is refused as well once the address has been seen a few times, and the Atom feeds allow about one request a minute. A card with three subreddits failed two of them every round. A free Reddit app of type script would bring it back as a connection like Twitch.
Everything in it is an allowlist and nothing is switched off. Two rules fired on documentation on every single run: the placeholder token in docs/api.md and the passwords the test suites use on themselves. A guard that cries wolf twice a day is a guard somebody stops reading, and then the one real finding goes past. Checked both ways on 06.09.2026: with this file gitleaks still catches a GitHub token, a Slack token and an AWS key, and the whole history of seventeen commits comes back clean. The scanners themselves live in .git/hooks, which git never tracks, because their patterns name a real family and a real network.
The old screenshot was the demo board as it fell out of the wizard: half the cards empty, holes in the grid, and a poster row of initials on a gradient. It showed that the thing runs, not what it is for. Three boards laid out by hand instead, each answering one question, plus the same board as a wall display. Everything in them runs against demo connections; the covers on the media board are the one exception and were taken with the owner's own library, checked frame by frame for a name, an address or a face before it went in. The list of services is grouped now rather than one paragraph of seventy-nine names, and the count is checked against the registry: 79 named, plus the calendar and the JSON API widget as building blocks. Fixed while taking the pictures - A kiosk could not read the search targets, so the search card on a wall board drew "no search target is set up yet" next to a link into settings that nobody standing at a wall can open. A target is a name and an address with a placeholder in it. Found and written down, not fixed - Cards overlap on a phone: the chip row wraps, the card runs out of room, and the content leaves through the top. No phone screenshot until that is done.
docker-compose.yml pointed at ghcr.io/nexapps/nexdeck while the workflow
publishes to ghcr.io/${{ github.repository }}, which is derkezorm. Every
quick start would have failed on the first pull with a name nobody owns.
Four badges, each of which resolves: the CI run, the container package,
the licence and the number of integrations, which is checked against the
registry in the same commit that changes it.
The About section and a fifth badge. The address answers nothing today, which is invisible while the repository is private and would be a dead link at the top of the front page the moment it is not. Written down next to the open item for the project page.
Six things that were on the list for today. Forgotten passwords. A link on the sign-in page, but only where a mail server is set up: offering it without one is a door onto a wall. The form answers the same sentence whether the account exists, has no address, or the mail failed, because anything else turns it into a list of who has an account here. Redeeming a link ends every session and every API token of the account. A second factor. Offered, not forced, with an operator switch that demands it from everybody. The QR code is drawn in this process; a secret handed to a foreign QR service is not a secret. Ten recovery codes, shown once. Kiosk links and API tokens are untouched: a wall display has nobody to type a code. A dial for cards that measure a share of something. The needle shows the share and the card still shows its own number, because a dial that replaces "38 MB/s" with "79%" answers a question nobody asked. Backups, and putting one back. The archive carries secret.key, so it is an AES ZIP with a password: without the key a restored database leaves every connection unreadable, and with the key in the clear the file is everything somebody needs. A ZIP and not a format of our own, because the day you need a backup is a day nexdeck may be what stopped working. Restoring reads first, describes what it found, and only replaces anything once the administrator types their own name. A log window in the settings, with four levels and a request number on every line. Along the way it turned out 21 of 23 routers wrote nothing at all, so the window would have opened onto an empty list. Notifications that fire. Three of seven events were in the catalogue and were never emitted from anywhere. The e-mail channel now uses the installation's mail server instead of asking every person for a host, a port and a password, and it only exists where that server is set up. Also: Portainer asked for environment number 1 and never checked it, so the connection test passed and every card failed a moment later. The field is empty by default now and the adapter finds the environment itself.
The release run refused the tag: `ruff check app tests` found an unsorted import block and a variable that is assigned and never read. I had only run the check over `app`, so nothing said so here. The unused one was `secret = _secret(client)` in the nonsense-codes test. The call has to stay, or every code would be refused for the trivial reason that the account has no secret to check against, and the test would pass while proving nothing. It is an assertion now.
Boards can be put in the order the menu shows them. The number was already in the database; nothing could change it. The whole order goes back in one call, because two boards swapping places sent as two writes can land either way round. A card can be told which of its pieces to show. A widget declares what it has, the tick boxes and the filtering follow from that, and thirty adapters do not each write the same list for the thirty-first to forget. Two shapes occur and both are handled: a row of its own, and a field inside a row of a list. Every list card can also be told which rows to show at all, and nothing picked means all of them, so a new disk or a new container turns up by itself. Findings became something a card is switched on for, not off. A default is retroactive, so a migration writes the old answer down for the cards that were made under it: they stay loud, new ones start quiet. A UniFi switch, port by port, with a dropdown that asks the console which switches there are. Some questions cannot be written into a spec, and typing a name works until there are fourteen of them. What hangs on a port is not in this card and cannot be: the Integration API tells a client which device it uplinks to and never which port. Four things that were wrong. The dial drew every value between 50 and 67 per cent the long way round the face. The flag that picks which of two arcs to draw flips past a half turn, which on a 270 degree face is two thirds of the way and not half. The dial printed "of 100%" under a card that measures a percentage. The ceiling was arithmetic, not an answer, and now only a ceiling somebody named is written down. Switching a piece off changed nothing until the page was reloaded. There were two ways to a card, the collector and the preview, and the passes hung on the collector alone. One function now, and a test that says so. Portainer asked for environment number 1 and never checked it, so the connection test passed and every card failed a moment later. Also: a forgotten-password mail carried a link with no host in it, because that one place read the public address only from the settings while the rest of the app falls back to the environment variable. It is refused rather than sent half now. And the profile card had a save button that looked like it belonged to the name above it.
Five things that made the one worker stand still, found in the deep read of 07.09.2026 and each measured before and after. The board import ran on the event loop while doing quadratic work: placing a card copied the whole layout of every breakpoint and reassigned the page's JSON column, so the next flush rewrote it all again. Measured with YAML anchors, which make the file tiny and the board enormous: 500 cards took 5.9 s, 1000 took 17.8 s, 2000 took 59.3 s, and 2 MB were allowed. The route is now synchronous, so it runs in a worker thread; the placement measures the page once and writes the layout once; and an import brings in at most 50 pages and 300 cards. The connection pool and the worker threads were SQLAlchemy's and anyio's defaults, 5 plus 10 and 40, and that appeared nowhere: not in the settings, not in the README, not in a comment. Both are settings now, and the thread count stays below the pool so a request waits for a thread rather than for a connection it cannot get. Off the event loop as well: the housekeeping pass that condenses three tables every five minutes, and the write of every reachability result, which also reads a day of history to redraw the tile's bars. The telling stays on the loop, because filling an asyncio queue is only safe there. The JWKS client is built once per provider and fetched in a thread. It used to be built fresh on every sign-in, so cache_keys=True cached into an object that was thrown away immediately, and the fetch blocked the loop. The slot for a live video relay is taken before the first await and given back on every way out. Between the check and the counting there used to be a network round trip, so a dozen browsers opening at the same second all passed a limit of twelve. test_layout_placement.py keeps the old placement beside the new one as a reference and runs both, because the point of the rewrite was that nothing about the result changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The name of an action was checked against the adapter's own list and its
parameters against nothing at all, and several adapters put a parameter
straight into a path. Two ways out of that, both reachable with a kiosk token,
which needs no account:
POST /widgets/{id}/actions/start with {"id": "../volumes/prune?x="}. Measured
with the pinned httpx 0.28.1: /containers/../volumes/prune?x=/start goes out
as /volumes/prune, because httpx normalises the path and a question mark ends
it. The compose file the README hands out mounts the Docker socket, and access
to that socket is root on the host. The same shape sat in portainer.py and
proxmox.py.
POST /widgets/{id}/actions/lock.unlock on any Home Assistant card. No card ever
offered it; the adapter split the name at the dot and called the service, with
a token that may do anything in the house.
Two locks now. The collector compares the requested action and its parameters
against the actions the card last put in front of whoever is looking, and
refuses anything that was never on screen. And the three adapters that build a
path check what they are about to put in it, because the names of containers
and guests come from the foreign service: a container called ".." would be
offered by the card like any other.
GET /api/v1/history/prune is a POST for administrators. It rewrites the whole
history and was open to every account down to a guest, and a GET carries no
X-Nexdeck-Request header, so the one thing that stops another site from making
a browser call it did not apply either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o go The server sits inside the network and reaches what a member's browser cannot: nexdeck's own API, the router, the hypervisor. A notification channel takes an address from any member and reports the answer back as an HTTP status, which made that field a way of asking what else is listening. Web Push, the RSS card and the target of a reachability check are the same shape. The guard that was supposed to cover this compared the host against five spellings of the metadata address and nothing else, so 127.0.0.1 walked straight through. Two ranges are barred and nothing else. Private networks stay open on purpose: a homelab dashboard that cannot reach 192.168.x is no use to anyone. The rule splits by who put the address there, and that came out of a red test rather than a plan. An administrator pointing a connection at http://127.0.0.1:7878 is an ordinary Radarr on a host-network install, and test_cache_bounds.py has said so since before any of this. So loopback is barred for addresses a member typed and allowed for a connection; the link-local range and the metadata names are barred for everybody. NEXDECK_ALLOW_LOOPBACK_TARGETS=1 lifts the first, and is documented. The check hangs on the client, not on the call, because httpx runs a request hook for every hop of a redirect as well: checking only the address somebody typed leaves the second one open, and a service that answers 302 decides where the third request goes. Eighteen places built their own client; they all go through outbound_client() now, and a guard in test_guards.py stops the nineteenth. TCP and ping checks do not go through httpx and ask for themselves. A check whose address cannot be worked out yet reports "unknown" instead of running against the empty string, which fails the way an unreachable host fails: an outage was opened and every administrator was told that a service was down that nobody had ever named. The usual way in is an app tile on a Wake-on-LAN connection, which has no address field at all. The e-mail channel keeps the promise its own help text makes and falls back to the address on the account. Names are deliberately not resolved. Catching localtest.me would mean a second lookup in front of every request: measured here, a name with a dot that does not exist costs about 50 ms and a bare name like "radarr" costs 2.7 s, because Windows falls back to LLMNR and NetBIOS. The draft that did resolve turned an eleven minute test run into more than twenty-five. The limit is written down in the code and in a test. Carried along, and named here rather than hidden: the four new guards in test_guards.py sit in one block of the file, so the two that belong elsewhere came with this commit. test_every_field_that_asks_for_a_credential_is_marked_secret belongs to the secrets work, and the two about what a guest may change belong to the sign-in work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
httpx logs the full address of every request at INFO, and the deep log levels turn httpx up to exactly that. Several services take their key in the query string, which is not the adapter doing anything wrong: Kavita wants apiKey, Technitium wants token, Synology's auth.cgi takes the DSM password that way. The line then sat in data/logs/nexdeck.log and in docker logs, and the deep level exists precisely so somebody can download that file and attach it to an issue. A filter on the handler now replaces the value of every known credential parameter before the line is written, whoever wrote it. The JSON API connection has one field that asks for credentials in so many words, placeholder "X-Api-Key: abc", and it was not marked secret. So it was stored in the clear and handed unmasked to every account down to a guest through GET /api/v1/integrations. A guard walks every adapter field and asks for secret=True wherever the name or the label says credential; two exceptions are named with their reason. The board export replaced the fields marked secret and wrote out everything else, and exporting needs only "view", which a board is shared at all the time. For a connection the administrator reserved for himself that is exactly the part he reserved; a viewer now gets the name and the kind, which is all an import needs to match it up again. The encryption key is derived with PBKDF2 and 210000 rounds instead of a single SHA-256. Without data loss: the old key stays for reading, MultiFernet writes with the new one and reads with either, and values are rewritten as they are saved. An operator who set NEXDECK_SECRET_KEY to something short now costs an attacker with the database file 210000 rounds per guess instead of one hash. secret.key is written with mode 0600; it used to take whatever the umask allowed, which under Docker is 0644. pyzipper 0.3.6 has carried a known encryption bypass since May (GHSA-crqm-m339-7m2p): a precedence bug meant every entry was written as AE-1 with its plaintext CRC32 in the header. Bumped to 0.4.0, and checked that archives written by the old version still open. The tool that finds such things was already in the repository and had never been run; it is a CI step now, and that step can turn red without anybody changing a line, which is the point. The Plex PIN moves out of the address into the body: the browser polls that every two seconds while somebody agrees at plex.tv, so the code stood in nexdeck's log and in every line of the proxy in front of it. .gitignore learns about ./data, which is where the compose file in the README puts secret.key and the database when somebody runs it inside a checkout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The security headers were set for every path except /api/, on the grounds that JSON needs none. Two addresses under it do not answer with JSON: the icon proxy hands out SVG fetched from a public collection, and the image proxy passes through whatever content type the service sent. SVG is not a picture but a document that runs script, and it was served from nexdeck's own address with no policy on it at all, without a sign-in. Should something land in homarr-labs/dashboard-icons or selfhst/icons with a script element, a link to it would have run that script beside the session cookie. The same sandbox policy that routers/assets.py has had all along now applies under /api/ as well, and the image proxy refuses image/svg+xml outright. NEXDECK_CORS_ORIGINS='*' now stops the server from starting instead of quietly doing the worst thing. Starlette does not send a literal star when credentials are allowed: it echoes back whatever Origin asked and sets Access-Control-Allow-Credentials with it, and the preflight then waves through X-Nexdeck-Request, the one header that stops another site acting as the signed-in user. Measured against a running instance. The setting was in no documentation at all, which is how an operator hitting a CORS error would have reached for it. A custom stylesheet barred @import and allowed url(), and url() fetches just as well: a background image on a foreign address tells that address the IP and the user agent of everyone who opens a board. data: stays allowed. The iframe card refuses nexdeck's own address. allow-same-origin has to stay or most services stop working inside a frame, so what gets refused is the address: a same-origin frame with allow-scripts reaches the app around it and can take its own sandbox off. Signing out loads the page again. Setting the user to null left the boards, the notices and everything react-query holds in memory, so the next person to sign in at the same browser saw them until fresh data arrived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OpenID Connect had no test of any kind. A grep for "oidc" over every backend
and frontend test file found two hits, both entries in the list of public
addresses, while it is a complete sign-in that also creates accounts. Six of
the findings of 07.09.2026 sat inside it.
The return path opened a session outright, so an account with a confirmed
authenticator app was let in without a code as long as it came through a
provider, while the password path has asked for one since the beginning. The
provider proves who somebody is; it does not prove the second factor. Whoever
runs authentik or Authelia with MFA in front ticks a new box on the provider,
off by default, and is not asked twice. The half-finished sign-in travels in a
short-lived cookie rather than in the address, because a redirect address ends
up in every proxy log.
The setting that asks a second factor of everybody did nothing. The sign-in
worked out must_set_up, wrote it into a log line and threw it away, and the
session it had already opened was a full one. An account that owes the
installation a factor now reaches its own page, the setup and the way out, and
the app says so instead of quietly breaking.
An account without a password could switch its second factor off without
proving anything: the check sat behind has_usable_password, and an account made
through OIDC has never had one. It takes a current code now.
An empty sub was accepted as an identity, so the second person whose provider
omits the claim would have walked into the account of the first. pyjwt does not
require the claim.
The role beats ownership. board_permission answered "owner" for the owner
before it looked at the role, while the same function already pushed a guest
down from "edit" and "act" on every share, so somebody moved to guest kept full
rights on the boards that were already theirs, actions included.
The rest, each small: an API token can no longer mint another one, so revoking
the first takes the access back; the kiosk cookie takes its Secure flag from
the same place the session cookie does instead of from NEXDECK_PUBLIC_URL; the
sign-in spends the same time on an account that does not exist as on one that
does; sessions that have run out are swept, so the list stops offering to end
sessions that ended months ago; two OIDC crashes became messages; removing a
provider names the accounts it would lock out and wants force=true; an open
event stream asks again on every heartbeat whether it may still be there; and
the Plex sign-in routes belong to whoever may create a connection.
A password change still ends every session including its own, which a test has
said all along. What was missing is that the interface never noticed and simply
went blank; it says so now and sends the person back to the sign-in.
The guard that asks which right an address wants found one thing by itself:
POST /widgets/{id}/refresh took the same level as looking at the board, so a
guest, or anybody with a view-only share, could ask the server to call a
service as fast as it would answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The retry test moved the fake clock as soon as the snapshot appeared, but React had not yet run the effects of that render, and one of them arms the 30-second retry. Run on its own, not one timer was armed when the clock moved, so the retry landed past the 30 seconds and the test failed every time; in a full run it failed once under load. An empty act runs those effects first. Checked on a copy: the old test alone fails, the fixed one passed 15 of 15 runs, under CPU load as well, and three mutations of the retry turn it red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine fixes from the check against 0.12.1, each with a test that was red before it, and every fix undone once on a copy to see its test notice: - Apprise channels are for administrators only. Apprise follows redirects in its own HTTP library, where no address check reaches, so a member's channel could ask what listens beside the server. Members no longer see the kind and cannot add or change one, and an existing one refuses to send. localhost and names under it now count as loopback for members. - Uploads are served to a session or a kiosk cookie only, and cached privately. The running number in the address made every picture of every account countable without signing in. - A board file or an import with an unreadable layout is refused before the old cards are deleted, and provisioning rolls back when a file fails while it is being written. - Addresses a member typed are checked on every redirect hop: RSS cards and HTTP reachability checks go through a client with the member rule, and the Wake-on-LAN card checks its host and its broadcast address. - The test button of a channel takes five presses per account in ten minutes. - The board stream, the log stream and the video relay ask again at least every 25 seconds whether the viewer may still be there, busy or not. - A Web Push endpoint moves to another account only with the same keys. - A restore refuses archive names like avatars/.. before anything is replaced. - A secret.key from before 07.09.2026 is narrowed to 0600 when it is read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_due() takes the moment to compare against and used it for today and tomorrow, but asked ago() for the overdue age against the real clock. The test fixes that moment at 11.09.2026 11:00 UTC, so from 12.09.2026 09:00 UTC on, the task due on 10.09. read as two days overdue instead of one, and the full run turned red without any change to the adapter. ago() gets the same moment now, as its docstring asks for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight points from the check against 0.12.1, each with a test that was red before its fix, and every fix undone once on a copy to see its test notice: - A message on a live board closes by itself again. Its timer hung on the close handler, which a page passes anew on every render, so every card answer started the five seconds over. - The "what is new" window closes at once, and the note to the server follows. With the server gone or the session expired no way out worked until the page was loaded again. - NEXDECK_URL_BASE is gone from the README, the compose file, .env.example and the backend. It only ever moved the cookie paths; the interface never knew a sub path, so setting it left a page that did not load. - docs/provisioning.md says what an import through the interface really does: it reuses connections by name and creates none. - The problems card names a tile whose reachability check fails. App tiles have no live state, so a red tile stood next to "Everything is fine". - The command bar keeps the keyboard inside while it is open, with the same focus trap as Sheet and Dialog, now in lib/useFocusTrap.ts. Five fields that only a placeholder named, and one with no name at all, carry an aria-label. - Testing a saved connection through the API answers an unreadable key or an unexpected failure with a message, not with a bare 500. - A backup leaves the chart history out, as CACHE_TABLES always meant to: it named a table that does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t of the address The two faults of the third roadmap stage, each with a test that was red before its fix, and every fix undone once on a copy to see its test notice: - Shared outbound clients keep no cookies any more. httpx keeps every Set-Cookie, and the collector shares one client across every connection, so a session one connection's answer set went along with the next request to the same host: on What's Up Docker a wrong password got in. Clients built for one connection that sign in with a cookie ask for keep_cookies: Deluge, qBittorrent and UniFi. Every adapter was read for a silent dependence on the shared jar; Calibre-Web had one and now takes its session from the sign-in's own redirect instead of the page after it. - A kiosk display takes its token out of the address once it is in and keeps it on the display; a reload of /k comes back in with it, and /k/:token is now optional in the router. The page a kiosk link opens is sent with Referrer-Policy no-referrer, so that first load no longer writes the token into proxy logs as a Referer. The player treats /k alone as a wall display as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The points of the roadmap that are faults rather than features, each fix with a test that was red before it, and every fix undone once on a copy to see its test notice: - Refresh now reaches the service at most once per five seconds for each card. Presses inside the gap share the first press's fetch and its answer; a save in between starts a new one. Before, whoever may act on a board could send the server at a service as fast as it answers. - One helper reads the public address: the setting, else NEXDECK_PUBLIC_URL. Web Push and the rescue link read only the environment, so an address set in the interface signed push messages as mailto:admin@localhost. A guard keeps every other module from reading it. - The Web Push key pair is made under a lock and read again inside it; two first calls at once made two pairs or failed on the unique key. - A fresh database records the newest schema instead of running every migration over tables create_all has just built. A database with tables and no version still gets every step. - Two routes answer 404 instead of failing an assert when a widget's page is gone. - A refused address reads "127.0.0.1 is not an address nexdeck calls." with one space, not two. - ChangeDetection counts the age of every row from one moment, which a test can fix. - backend/tools/ci_local.py runs the tests job of ci.yml on this machine, read from the workflow itself: installs only with --install, the version check with --tag, and a condition it does not know stops it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four roadmap stages since 0.12.1 in one release: the security gaps of the first stage, the faults people saw in the second, the cookie and kiosk token faults of the third, and the essential leftovers. No new features. Apprise is for administrators only, NEXDECK_URL_BASE is gone, and refresh now asks a service at most once per five seconds for each card. CHANGELOG.md lists every point, and the what's new window explains the ones that work differently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both from GitHub issues, each written against the documented shapes and held against mutations of the adapter. - Frigate (issue #1) had no credential fields at all, so a card on port 8971 or behind a proxy came back with "the service rejected the credentials, check the API key or the password" and nowhere to put either. It signs in at POST /api/login now, carries the JWT out of the sign-in's cookie as a bearer token, and signs in once more when a token is turned down. A refused sign-in is remembered for a minute: Frigate rate-limits failed logins per address, and three cards on one board would otherwise spend that budget on their first refresh and lock the operator out of Frigate's own login page. Where Frigate's own authentication is off, the card says so instead of blaming the password. Its connection test counted detection_fps, a number, as a camera. - Nomad (issue #2): jobs, nodes and cluster, with buttons to stop a job and to scale a service job's task group by one. A job's state comes from the task-group summary rather than from Status alone, because a job reads running while one of its groups has two failed allocations. The count for a scale is read from /v1/job/:id/scale when the button is pressed, not taken from the card: a group of one whose allocation has died shows nothing running, and counting down from there would ask Nomad for minus one. Stopping is DELETE /v1/job/:id, and a scale request carries a Message because Nomad refuses one without it. - The nodes card shows what is allocated, not what is used: the server API has no live reading, it sits behind each client on its own. Where the token may read one namespace, the rows show no share at all rather than one namespace's share of a node. - plus and minus join the frontend's symbol set, so the scale buttons are not two grey boxes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two wishes from GitHub in one release: Nomad, HashiCorp's orchestrator, with jobs, nodes and cluster cards and buttons to stop or scale a job, and an account for Frigate, whose authenticated port refused every card because the integration had no field for one. CHANGELOG.md lists every point, and the what's new window explains what a Nomad node's numbers mean: allocated, not used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- TrueNAS reads through JSON-RPC over wss://.../api/current when the address is https. The REST API answers a Read-Only Administrator's key with 403 on 25.10; the current API gives it everything the cards need. Over http the REST API stays: TrueNAS revokes a key for good once it arrives over ws://, measured on 25.10.7, so the key never goes there. A refused handshake falls back to REST (TrueNAS before 25.04, or a proxy without WebSockets). Tested against a TrueNAS 25.10.7 VM. Issue #4. - TrueNAS alerts show their day instead of ten digits of a timestamp. - Jellystat's most watched card sends the type it is asked for and reads the flat list Jellystat returns. Issue #7. - pfSense's connection test asks /system/version, which exists, and the system card reads temp_c. Issue #5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three integrations reported on GitHub that answered wrongly: TrueNAS refused a read-only administrator's key on 25.10, Jellystat's most watched card got a 503, and the pfSense connection test failed with a 404 while its cards worked. CHANGELOG.md lists every point; the what's new window explains the TrueNAS change and why http:// stays on the old API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every card ran against a TrueNAS SCALE 25.10.7 on 18.09.2026, with a read-only administrator's key and a full one, over https and http. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The five parts the cards read are non-null fields of Unraid's Query type, so an error in one of them, such as a switched-off VM service or a key without permission for Docker, nulled the whole answer and every card of the connection showed the same error. Each part is now its own request, each card asks only for what it shows, and a failed part leaves a question mark. The connection test names what it could not read. A candidate for issue #3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unraid no longer empties every card when one part of it cannot be read, and TrueNAS is out of beta after every card ran against a TrueNAS 25.10.7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Built against the API contract of nexmail 0.17.0, which is not released yet, so the adapter is beta. Two cards: unread mail with the total over one row per mailbox, a mailbox whose sign-in fails marked red; latest mail with sender and subject, unread rows in bold, each row opening the message in nexmail. Mailboxes are picked from the ones shared on the key. A key that may only count gets no list card: adapters can now bar a card for a connection (Adapter.barred), and the library asks before it adds one. Every refusal identifier of nexmail reads as what to do about it. Shared pieces: a choices field with a list as its default picks several values, a list card can carry its total on top (meta.headline), and a row can ask for attention without being a fault (emphasis). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nexmail: unread mail and latest mail through nexmail's API keys, out of beta after both cards ran against nexmail 0.17.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Frigate (#1): the cameras are read from the current /api/stats layout, where they sit under their own key; the old reading listed "cameras" and "embeddings" as cameras at 0 fps. The score comes from data.top_score, measured on the reporter's event. New cards: a camera's latest picture, today's detections by kind, and a health card that stays empty while everything runs; detections carry their thumbnail. Pictures are fetched by /latest/<camera> and /thumb/<detection> only. Nomad (#2): failures are counted from the allocations that exist, not from the job summary, whose Failed is a tally that never goes down. Measured on the reporter's cluster: Failed 3 in the summary, one running allocation left. Radarr (#8): the upcoming card shows the next of the three release dates and names its kind, instead of the digital release first. Demo mode for everything shows on the board, a passed connection test says the cards still show invented data, and a switch held by NEXDECK_DEMO is locked. The setup wizard names System > Integrations. The chip row wraps on cards from three rows up (a height container query) and keeps scrolling on smaller ones, where wrapping printed over the number on a phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Frigate reads its cameras and scores from the current answers and gains a camera, a today and a health card; Nomad counts only failures that still stand; Radarr shows the next release; demo mode is said on the board; the chip row wraps on tall cards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
role="status" is a live announcement, and the board already has one, the edit-mode hint. With two on the demo board the first-start end-to-end test found both and stopped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
role="note" is taken as well: the phone's hint to arrange cards on a wider screen carries it, and the phone end-to-end test found two. The notice is read where it stands and needs no role. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nexpulse joins the board with five cards: the latest result with a running test live and a button to test now, the history of speed or ping, a period summary, the recent tests and latency under load graded A+ to F. What a key may do comes from nexpulse's /api/v1/me; nothing is posted to find it out. A button pointed at an action without a target says so instead of "offers nothing". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full administrator's key over http reaches the REST API on 25.10 and it works, quietly: from 25.10.1 every call raises a deprecation alert on the NAS, and TrueNAS 26 removes the API. The version is read once an hour through the same API and a TrueNAS of 25.04 or later is refused with what to change: https://, where the current JSON-RPC API is spoken and a user-linked read-only key is enough. Older versions keep the REST fallback.
DerKezorm
left a comment
There was a problem hiding this comment.
Thanks for sending this back upstream, and for the clear write-up. The direction is right: a TrueNAS that raises an alert on every REST call should not be fed REST calls, and the docs confirm both the alerts from 25.10.1 and the removal in 26.
I ran the branch locally. Your 10 tests pass. Two things need fixing before I can merge it, both reproduced with a small probe test:
1. The system card freezes on an older TrueNAS
_rest() reads /system/info with cache=3600 for the version check and then hands the same answer to the card:
fresh[method] = info if method == "system.info" else await self._get(...)So on a TrueNAS before 25.04, which is exactly the group that should see no change, load and uptime stay the same for an hour. My probe: fetch the system card, move the clock two minutes on, quadruple loadavg in the mock, fetch again. The card still shows 12.5 % instead of 50 %. The same probe passes on main. "Test connection" (cache=0) gets the hour-old answer as well.
Suggestion: keep only the parsed version for the hour (for example in ctx.cache["truenas:version"], next to truenas:legacy), and read system.info for the card with the card's own cache as before.
2. The hint is wrong behind a reverse proxy
The first call site is the https:// branch: the WebSocket was turned down with something other than 404, which is most likely a proxy that does not pass WebSockets on. On 25.04 and later the user now reads "Change the URL to https://", while the URL already is https://. What they have to change is the proxy. The AuthFailed branch right below already says so ("A reverse proxy in front of TrueNAS has to pass WebSockets on").
Suggestion: let _rest() take the hint from the caller, or raise from the two call sites with their own hint. For the https:// branch the message should name the HTTP status of the handshake and the proxy.
Minor, no blocker: the hourly version check is itself a REST call, so a 25.10.1 box still gets about one alert an hour instead of one per refresh. Fine for now; worth one sentence in the docstring so nobody is surprised.
A test for each of the two cases would be welcome. I can share my probes if that helps.
|
Heads-up: I rewrote the history of That makes this PR show the whole old history. When you push the fixes from the review, please rebase onto the new Sorry for the extra step. |
Over http:// with a full administrator's key, and behind a proxy that does not pass WebSockets on, the adapter fell back to the REST API, which still answers on 25.04 and later. Measured on 25.10.7: TrueNAS counts every call that signs in over REST and raises one alert, "Deprecated REST API usage", with the count of the last 24 hours. It only goes away at no call at all. A plain GET of /api/current without the key says 400 where the current API exists and 404 where it does not, and moves no count. It is asked once an hour; where the API exists nothing goes over REST and the card says what to change, https:// for an http:// address, the proxy for an https:// one. The version REST reports is the second line for a proxy that says 404. A 404 in front of a current TrueNAS is no longer remembered as an old TrueNAS. Pointed out in pull request #9.
|
Thanks again for raising this. I ended up building it on What the measurement showed, in case it helps HexDeck:
The two points from the review are covered as well: only the version is remembered for the hour, never the Not measured: what a TrueNAS before 25.04 answers for |
Taken over from nexdeck 0.16.1 (DerKezorm, measured on TrueNAS 25.10.7), which supersedes the fix sent upstream as DerKezorm/nexdeck#9. TrueNAS keeps one alert with the count of REST calls of the last 24 hours, so it only goes away at none: reading the version over REST once an hour kept it alive. A plain GET of /api/current, without the key, tells a TrueNAS with the current API apart (400) from one without (404), and nothing goes over REST where the current API exists. Only the version is remembered for the hour, never the system/info answer the system card reads load and uptime from, so an older TrueNAS's card no longer freezes for an hour; behind a reverse proxy the hint names the proxy and the handshake's status instead of asking for https://.
|
Thanks for measuring it properly and for the write-up. HexDeck has taken over the 0.16.1 version of the adapter as is (credit in NOTICE.md). Closing the fork branch on my side. |
What
The TrueNAS adapter keeps the REST API (
/api/v2.0) as a fallback forhttp://addresses and for a TrueNAS without/api/current. On TrueNAS 25.04 and later that fallback still works with a full administrator's key overhttp://, and it works quietly: from 25.10.1 every call to the REST API raises a deprecation alert on the NAS, and TrueNAS 26 removes the API altogether.This change reads the version once an hour through the same REST call (
/system/info, which the fallback needs anyway) and, on 25.04 or later, refuses with a message that says what to change: usehttps://, where the adapter already speaks the current JSON-RPC API and a user-linked read-only administrator's key is enough. Versions before 25.04 keep the REST fallback exactly as before.Why
Measured on a 25.10.x box behind the adapter: the alerts pile up one per card refresh. Users who never open the NAS UI do not see them; those who do think something is wrong with the NAS.
Tests
test_the_rest_api_is_refused_on_a_truenas_that_deprecates_it: 25.10.1 overhttp://with a full key →AdapterError(code="deprecated_api"), the hint nameshttps://, and nothing but the version check reaches the REST API.test_http_with_a_full_key_keeps_working_over_restnow mocks/system/infowithTrueNAS-SCALE-24.10.2, so it covers what it always meant to: the REST path on a TrueNAS that has nothing else.pytest backend/tests/test_truenas.py: 10 passed;ruff checkclean.Found while working on a fork (HexDeck, AGPL-3.0, attribution kept); sending the fix back where it belongs.