# Changelog All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added - **Group identical monitoring findings, and fix SOA MNAME on many zones with one review.** The same zone-monitoring finding on several zones now shows as one entry in the bell and on the Notifications page ("SOA MNAME is not one of the nameservers, 117 zones"), which expands to the zone list and dismisses as a whole. Zone Monitoring gets **Review and fix all** for the SOA MNAME finding: one review with every zone's SOA before/after, then one Apply. Each zone is saved through the same route as the one-zone Fix, with its own `soa.update` check, audit row and NOTIFY. Zones you can't change are listed and skipped, and the saved zones are re-checked in one pass. ([#183](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/183)) ### Fixed - **The multi-PowerDNS demo and test stacks sometimes failed to start.** MariaDB's healthcheck passed against the socket-only server the image runs while loading the schema, so the PowerDNS peers started before MariaDB accepted network connections and kept restarting. The check now connects over TCP, as PowerDNS does. Only `docker-compose-combined.yml` and `docker-compose-multi-primary.yml` change; the app image is unaffected. ([#221](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/221)) ### Security - **`zod` 4.4.3 → 4.6.7**, fixing [GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5) (high): validating an oversized collection against a size constraint could exhaust memory. Every route validates its input with Zod, so treat this one as reachable. - **`undici` 8.11.2 → 8.12.3** (the PowerDNS client's HTTP agent), and 7.29.1 → 7.30.1 inside `jsdom`, fixing [GHSA-4qv8-2p9j-2685](https://github.com/advisories/GHSA-4qv8-2p9j-2685), [GHSA-f72v-6w45-q472](https://github.com/advisories/GHSA-f72v-6w45-q472) and [GHSA-xjj6-f33q-929c](https://github.com/advisories/GHSA-xjj6-f33q-929c) (high). - **`nodemailer` 10.0.10 → 10.1.7**, fixing three address-parser advisories: [GHSA-8c8m-p3m8-cjg8](https://github.com/advisories/GHSA-8c8m-p3m8-cjg8), [GHSA-m943-m63m-w62w](https://github.com/advisories/GHSA-m943-m63m-w62w) and [GHSA-85x2-q946-cp5h](https://github.com/advisories/GHSA-85x2-q946-cp5h) (moderate). - **`body-parser` 2.3.0 → 2.4.0** and **`fast-uri` 3.1.8 → 3.1.9** (both through the MCP SDK), fixing [GHSA-27r3-jxmh-p828](https://github.com/advisories/GHSA-27r3-jxmh-p828) (high) and four `fast-uri` advisories (moderate). The MCP endpoint uses the SDK's web-standard transport, not its Express server, so `body-parser` isn't reachable here; the update clears the audit gate. ## [1.12.1] - 2026-10-23 Fixes for catalog zones from 1.12.0. **No schema change, no migration.** Pull the new tag and recreate the container. If a catalog Producer zone made the header read DESYNCED, it now settles; see the [upgrade notes](./docs/09-UPGRADING.md#upgrading-to-1121-from-1120) for what changes on PowerDNS 4.6 to 4.8. ### Fixed - **On PowerDNS 4.7 and 4.8, deleting a catalog zone with members wasn't guarded.** Those versions leave `catalog` empty in the zone list, so every catalog looked empty: the delete went through without the confirmation, the Members column showed 0 and the banner listed nobody. The delete route now reads each zone's catalog there and asks for confirmation when any zone is a member, or when it can't read them all. The zones list and banner show membership as unknown instead of zero. ([#216](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/216)) - **PowerDNS 4.6 was offered catalog zones it doesn't have.** The create form no longer lists the Producer kind for a backend older than 4.7, and the create and settings routes refuse `Producer` and `catalog` there with a message that says why. ([#217](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/217)) - **A catalog Producer zone turned the fleet DESYNCED.** Sync checks expected every secondary to hold a copy of it, but a Producer only reaches the secondaries that consume it (a Consumer zone of the same name). A secondary serving the member zones the classic way showed the catalog as missing: the header chip read DESYNCED everywhere, the System tab showed `MISSING`, and the poller counted the secondary as drifting. A Producer now counts only on mirrors that hold it, across the zones list, the zone page, the sync API, the header chip, drift tracking and the servers page. On the System tab a catalog zone's Kind reads "Catalog producer" or "Catalog consumer" in place of the badge, and a Producer shows "-" for DNSSEC and, when nothing consumes it, for Sync, with a tooltip that says why. ([#219](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/219)) ## [1.12.0] - 2026-10-23 Zone-scoped roles now work end to end, catalog zones are handled as system zones, and this release carries security updates for Next.js and DOMPurify. **One small migration on Postgres** (`role_assignments.scope_id` from `uuid` to `text`), applied automatically at boot; SQLite is unchanged. Pull the new tag and recreate the container. IdP group mappings that were saved but never took effect start granting access at the next sign-in, so review them first (see the [upgrade notes](./docs/09-UPGRADING.md#upgrading-to-1120-from-1110)). The RBAC fixes come from [@Nefise-Akar](https://github.com/Nefise-Akar) (#199 to #203), thanks Nefise. ### Added - **Zone-scoped role assignments from Admin → Users.** The user page's assignment panel has a **Zone** scope with a name field. The role applies to that zone only, under the same privilege ceiling as other assignments. Assigning to a zone no backend serves still works, with a warning. ([#203](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/203)) - **Catalog zones are system zones** (RFC 9432, kinds `Producer` and `Consumer`). They move to a **System** tab on the zones list with a catalog badge and a **Members** column, and Forward, Reverse and All leave them out. A catalog zone's page explains what it is and lists its members. A Producer's records and DNSSEC are read-only, since PowerDNS generates them. Deleting a Producer that has members needs the phrase `yes, delete catalog `. Zone settings and the create form gain a **Catalog** field that defaults to the server's `default-catalog-zone` and only accepts a Producer zone on that backend, and Producer is now a kind you can create. The notification bell reports member zones naming a catalog that doesn't exist, and a `default-catalog-zone` that is missing or isn't a Producer. Search, the Add record wizard, Zone Monitoring and Reverse DNS skip catalog zones. ([#209](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/209)) ### Fixed - **Zone-scoped roles granted nothing.** A role scoped to `zone:`, typically from an IdP group mapping, was stored on the session but ignored by every zone page and route. Every zone surface now honours it, and it only ever grants zone-level permissions on that exact zone (see [ADR-0024](./docs/adr/0024-zone-scoped-roles-grant-zone-access.md)). ([#199](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/199)) - **The sidebar showed pages the user couldn't open, and hid ones they could.** An entry now appears exactly when the page's own access check would let the user in. ([#202](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/202)) - **Team- and server-scoped IdP mappings granted nothing.** The OIDC, SAML and LDAP forms saved the target as a slug, while the permission check compares ids. Sign-in now resolves the target, and a target that no longer exists is dropped and recorded in the audit log. ([#200](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/200)) - **The IdP mapping editor dropped half-filled rows without a word.** A partly filled row now blocks the save with a message naming what's missing, zone targets are normalised, and the provider page warns about a zone target no backend serves. ([#201](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/201)) ### Security - **`next` 16.4.0 → 16.4.2** (and `eslint-config-next` to match), fixing [GHSA-2464-fhr4-w9xw](https://github.com/advisories/GHSA-2464-fhr4-w9xw) (critical) and [GHSA-p3m5-59mp-jv5r](https://github.com/advisories/GHSA-p3m5-59mp-jv5r) in the Image Optimization API. The app runs with `images.unoptimized`, so the vulnerable endpoint isn't reachable here; the update clears the audit gate. - **`dompurify` 3.4.16 → 3.4.17** (through `isomorphic-dompurify`, which sanitizes uploaded SVG logos), fixing [GHSA-cg2g-xh64-v4q7](https://github.com/advisories/GHSA-cg2g-xh64-v4q7) (critical), [GHSA-mrv2-3pqr-j5jr](https://github.com/advisories/GHSA-mrv2-3pqr-j5jr) and [GHSA-c6gr-qvv4-5v3f](https://github.com/advisories/GHSA-c6gr-qvv4-5v3f). The logo sanitizer uses none of the affected modes (`SAFE_FOR_TEMPLATES`, `IN_PLACE`, an XHTML parser type). - **`ip-address` 10.7.3 → 10.7.4** (through the MCP SDK's rate limiter), fixing [GHSA-rxp7-6hxx-x8q5](https://github.com/advisories/GHSA-rxp7-6hxx-x8q5) (moderate). - **`vitest` 4.1.11 → 4.1.12** (with `@vitest/ui` and `@vitest/coverage-v8`; development dependencies, not in the image), fixing [GHSA-xp26-3454-mjgp](https://github.com/advisories/GHSA-xp26-3454-mjgp) and [GHSA-qxg6-89mg-rqv8](https://github.com/advisories/GHSA-qxg6-89mg-rqv8) in `@vitest/mocker`. - **The audit CSV export copied query-string keys onto a plain object**, so a `__proto__` key addressed its prototype (CodeQL `js/remote-property-injection`). The filters are now built with own properties only. It wasn't exploitable: only `audit.read` holders reach the route, assigning a string to `__proto__` is a no-op, and the filter schema drops unknown keys. ## [1.11.0] - 2026-10-22 Adds PowerDNS Authoritative 5.1 to the supported versions. **No schema change, no migration.** Pull the new tag and recreate the container. If a 5.1 backend serves LUA records, see the [upgrade notes](./docs/09-UPGRADING.md#upgrading-to-1110-from-1101). ### Added - **PowerDNS Authoritative 5.1 support.** 5.1 joins the compatibility matrix with its own monthly workflow and README badge. 5.1 makes LUA records immutable unless `enable-lua-record-updates=yes`, even with `enable-lua-records` on: creates, edits and deletes all come back as 422. The Lua gate now reads that setting too, for deletes as well as upserts, on both the RRset editor and zonefile import, and tells the operator which setting to change. The backend capability snapshot carries the setting, so the record editor stops offering `LUA` on a locked daemon. Daemons older than 5.1 don't report the setting and behave as before. ([#195](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/195)) ## [1.10.1] - 2026-10-21 Fixes from running 1.10.0 in production. **No schema change, no migration.** Pull the new tag and recreate the container. Most of the fixes come from [@Enard52](https://github.com/Enard52) (#186 to #189), thanks Ensar. ### Changed - **Zones under names reserved for private use default to the internal horizon.** A zone at or under `.internal`, `home.arpa`, `.local`, `.test`, `.invalid` or `.localhost` can never be delegated on the internet, yet with no classification it counted as public, so Zone Monitoring reported it as undelegated, unreachable from public resolvers and served from a private address. Unclassified, such a zone is now internal everywhere: the zones list, Reverse DNS and Zone Monitoring. An explicit **Public** on its Zone settings tab is stored and kept, and the Create zone form's Internal switch starts on for such a name. ([#181](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/181), [#189](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/pull/189), thanks @Enard52) ### Fixed - **A generated `METRICS_TOKEN` was printed in full to the boot log.** With `METRICS_TOKEN` unset, the app generates one at boot, and the `/metrics` bearer credential then reached every log collector. The log now shows only a fingerprint and says how to pin a token; the configuration and hardening guides are corrected. ([#182](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/182), [#188](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/pull/188), thanks @Enard52) - **Reverse DNS: the name picker on an Ambiguous row covered the status badge.** The picker kept its full text width and the Status column was narrower than its chip. The picker now truncates inside its column at every width (every select in the app truncates a long label now), and the Status column fits its widest chip. ([#185](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/185), [#187](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/pull/187), thanks @Enard52) - **"1 address(es)"** in the Reverse DNS drift finding, and in the backend form's AXFR / DNS addresses label, now reads properly. ([#184](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/184), [#186](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/pull/186), thanks @Enard52) - **A zone re-check slower than its cooldown left no cooldown.** The 20-second limit on re-checking one zone counted from when the check started, so a check that took longer (slow or unreachable nameservers) could be repeated at once. It now also counts from when the check ends. - **The header status chip ran under the search button on a phone.** Below `sm` it shows only its connection and sync dots; the words stay for screen readers. ### Security - **`vite` 8.0.16 → 8.3.4** (development dependency, not in the image), fixing [GHSA-587h-7wmj-h5pm](https://github.com/advisories/GHSA-587h-7wmj-h5pm) (high), [GHSA-r825-w8mm-5pxw](https://github.com/advisories/GHSA-r825-w8mm-5pxw) and [GHSA-698v-4r3x-3vcv](https://github.com/advisories/GHSA-698v-4r3x-3vcv) in its bundled `launch-editor`. ([#192](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/pull/192)) ### Documentation - Every screenshot is retaken on the 1.10.0 look, with the new Change log page. `npm run demo:seed:ptr` seeds the combined demo stack with a reverse zone covering every PTR status, for the Reverse DNS and Zone Monitoring shots. ([#190](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/pull/190)) - The 1.10.0 entry below now matches its published release notes. ## [1.10.0] - 2026-10-21 Feature release. The headline is **automatic PTR maintenance**: saving or deleting an A/AAAA record can keep its PTR in step, with a fleet-wide **Reverse DNS** report to reconcile what is already there. Alongside it: **Zone Monitoring** (delegation, nameservers, DNSSEC chain and open AXFR checked from the outside), an **Add record** wizard that waits for propagation, a **global search** palette, a **Notifications** page, and a realtime stream that recovers on its own after a tab sleeps. Every new page has an MCP tool. **One small additive migration** (a boolean column on `pdns_servers`, off by default), applied automatically at boot. New optional environment variables only. Search, Notifications, Add record and the editor's per-save "Sync PTR" checkbox are there from the start (no PTR is written unless it is ticked); the opt-in features below stay off until you turn them on (see the [upgrade notes](./docs/09-UPGRADING.md#upgrading-to-1100-from-191)). **Opt-in features.** Zone monitoring and the Reverse DNS checks are **off by default** because they add background load (a periodic read of every zone on every backend) and send DNS queries outside your network. Turn them on with `ZONE_MONITOR_ENABLED=true` and `REVERSE_DNS_ENABLED=true`. While a feature is off, its page, sidebar entry, buttons, API routes and MCP tools do not exist. Automatic PTR maintenance on save is opt-in through the `auto_ptr` setting and does not need either variable. ### Added - **Global search answers from a prebuilt index.** The index is rebuilt in the background after each change and hot-swapped in, an open search palette updates itself over the realtime stream, and recent answers are cached in the palette, so a keystroke never waits on a backend or the database. ([#169](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/169)) - **Global search.** A header palette (`Ctrl K` / `⌘K`, `/`, or the **Search…** button) that fuzzy-matches zones, records (names and values), config (settings, admin pages, backends, clusters, users, teams, roles, zone templates, sign-in providers), the PowerDNS request log and the audit log, with **All / Zones / Records / Config / Requests / Audit** tabs, match counts, highlighted hits and full keyboard navigation. Every source is filtered by the user's own permissions and secrets are never indexed or returned; docs searches hand off to powerdns-authadmin.org. Also `GET /api/search` and the MCP `search` tool. ([#169](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/169)) - **Automatic PTR maintenance (auto-PTR).** Saving or deleting an A/AAAA record can create, update or remove the matching PTR in a reverse zone hosted on any backend, including RFC 2317 classless zones. Off by default; enabled globally (`auto_ptr` setting, also in provisioning), per zone (`X-AUTHADMIN-AUTO-PTR` metadata) or per save (editor checkbox, `ptr` / `ptrTakeover` on the RRset PATCH, `ptr` / `ptr_takeover` on the MCP record tools). Reverse zones are never created, a PTR that points at a different name is never replaced without an explicit takeover and never deleted, the user needs record permissions in the reverse zone, and every outcome is reported per address and audited (`record.ptr-sync`). The review dialog previews the reverse changes before Save. ([#164](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/164)) - **Reverse DNS page** (`/zones/auto-ptr`, **opt-in: `REVERSE_DNS_ENABLED=true`**): every A/AAAA record across the fleet diffed against its PTR - ok, missing, mismatch, ambiguous, orphan, unhosted - with per-row, selected or all-fixable rectification through the same audited path. Record tables link each A/AAAA row to its PTR and each PTR row to every forward record pointing at the address. Matching is scoped to the zone horizon: public forward records only match public reverse zones and internal ones only internal, and an unclassified reverse zone over private address space counts as internal. Addresses no hosted reverse zone covers are looked up through the public or internal resolvers instead, so the row shows whether the upstream PTR matches, and a rate-limited **Refresh external lookups** button (`POST /api/admin/pdns/auto-ptr/external-refresh`) refreshes them on demand (`EXTERNAL_PTR_INTERVAL_SECONDS`). The sidebar entry carries the number of addresses needing attention. ([#164](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/164)) - **Add record wizard** (`/zones/new-record`, **Add record** next to **Add zone**): horizon → zone type-ahead across every backend → record with a live "already exists / available" check → the editor's review and Save → a propagation step that waits for every secondary or cluster peer (Finish when all are in sync, Skip with a confirmation before). Orphan rows on the Reverse DNS page deep-link into it. Backed by three new read endpoints, zone search, records at a name and per-zone sync status, which are also MCP tools (`find_zones`, `records_at`, `zone_sync_status`), alongside `reverse_dns_report`, `reverse_dns_links`, `reverse_dns_rectify`, `zone_monitoring` and `zone_monitoring_refresh`, so everything the new pages do is available to an assistant. ([#167](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/167)) - **Notifications page and a compact bell.** The bell now previews the three most pressing active notifications with a count by severity and **Show all**; `/notifications` lists every notification with status, severity and source filters, search, an accordion for the detail, per-row and bulk dismissal, and live refresh. New `GET /api/admin/notifications`, `POST /api/admin/notifications/dismiss` and MCP tools `list_notifications` / `dismiss_notifications`. A notification is shown, and can be dismissed, only by users who can read what it is about: a zone finding needs `zone.read` on that zone, so users whose only access is a zone grant now get the bell for their own zones and nothing else. ([#170](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/170)) - **Zone Monitoring** (`/zones/monitoring`): every hosted zone checked from the outside on a schedule. The delegation is read by walking from the root servers to the parent rather than through a recursive resolver, so the NS set, glue and DS the internet actually sees are compared with the zone's apex and DNSKEYs. Each nameserver is probed for reachability, the AA flag, its serial and its NS set, and for an unsigned AXFR; the configured public or internal resolvers are asked what they answer; SOA timers, MNAME and RNAME are checked; reverse-DNS drift is tallied per zone. Findings have stable codes and three severities (error / warn / info) with a what-to-do detail, horizon-aware rules (internal zones skip the root walk and are judged against internal resolvers), bounded load (three zones at a time, spaced across the interval, 3 s timeouts, no retry loops) and rate-limited manual re-checks. Error and warn findings reach the notification bell as `monitor..` advisories after two sightings (at once after a manual re-check), visible to users who can read zones, and the sidebar shows a per-page count. Own wire-format DNS client (`lib/dns/wire.ts`). **Opt-in: off unless `ZONE_MONITOR_ENABLED=true`**; `ZONE_MONITOR_*` variables in the configuration guide. ([#166](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/166)) ### Changed - **One expandable-row control across the log tables.** The zone change log, PowerDNS request log and audit log share one chevron accordion (one open row per table), with a shared toggle chip and a scrollable dialog for long lists. Reverse DNS and Zone Monitoring sit under **Zones** in the sidebar with counts in the app's chip style, and `DataTable` cards no longer leave a gap under an empty action cell. ([#172](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/172)) - **Both maintainers are code owners of every path.** Every change needs an approving review from a code owner other than its author, and branch protection now applies to admins too. ([#175](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/pull/175)) - **Filter chips show their counts at a glance.** A chip whose count is 0 renders neutral instead of in its status colour, on the Reverse DNS, Zone Monitoring and Notifications filters, and so does a chip whose count is still loading. The Reverse DNS status chips are ordered by count, highest first; "Not hosted" always follows Missing, Mismatch, Ambiguous and OK when those have a count, and empty statuses come last. Zone Monitoring's verdict chips follow the same rule (Info never ahead of Error, Warning or OK). Until the first counts land, both pages show placeholder pills instead of chips that jump into place. The zones list's scope and horizon filters use the same chips. - **One compact button size.** Every button in the app has the compact size of the page toolbars, keeping its own colour; Add zone and Add record stay green. - **Search covers zones, records and config by default.** The request log and the audit log are searched only from their own tabs, which sit apart after a "Logs" divider and are never remembered as the opening tab, so the everyday search does not scan the logs. The MCP `search` tool follows the same rule (`scope: requests` / `scope: audit`). Zone and record results show where they live and their state: backend, cluster or standalone, internal or public, plus a zone's kind, DNSSEC, serial and RRset count, or a record's zone, TTL, value count and disabled values. - **Accept single-nameserver zones per backend.** A standalone backend's settings have **Don't warn about single-nameserver zones on this backend**; zone monitoring then reports `nameserver.count` as accepted information, kept on the page and out of the notification bell. Hidden, and ignored, for a backend in a group, where the group provides the redundancy. Also `monitor_accept_single_ns` in provisioning (optional, so a boot does not override the UI). Adds the `pdns_servers.monitor_accept_single_ns` column. - **Change log.** `/admin/changes` gathers what people changed, everywhere: records, zones, DNSSEC, users and access (accounts, passwords, MFA, passkeys, tokens, sessions, roles, teams, zone grants) and configuration, in the zone page's own colour-coded layout plus a Zone column and a Category filter. A zone's **Change history** tab is now **Change log** and links to it with **Show all changes**. The sidebar has a new **Logs** section: Change log, Request log, Audit log. Gated on global `audit.read`, like the logs beside it. - **Suggested fixes with a reviewed diff.** Zone Monitoring's "SOA MNAME is not one of the nameservers" finding suggests the zone's first nameserver and has a **Fix** button: Review changes shows the SOA before and after, and Save applies it through the zone's own SOA route (needs `soa.update`), then re-checks the zone. `a.misconfigured.dns.server.invalid.` is now recognised as a placeholder MNAME. On Reverse DNS, orphan rows list the fixes (add the forward record, or remove the PTR), and removals and bulk rectifies open Review changes with the PTR diff for each reverse zone before Apply. "3 addressss" now reads "3 addresses". - **The app takes the project website's look.** The colour palette (light and dark), the Inter and JetBrains Mono typefaces (self-hosted, no font CDN; the CSP is unchanged), softer corner radii, a translucent top bar and matching chart colours. The search pill sits centred in the top bar and scales with the window, names what it searches, and keeps your recent searches (in this browser only); its focus ring no longer draws a doubled border in Safari. ### Fixed - **A focus ring outlined the whole page in Safari after saving with Enter.** Regions the app focuses by script (the main area, dialogs, search results) are never tab stops and no longer draw the ring; real controls keep theirs. - **The notifications panel and user menu opened behind dashboard cards.** The top bar now sits on its own layer, under the mobile navigation drawer. - **A new zone could take up to 15 s to appear in search.** A zone change no longer waits behind a slower rebuild queued for request-log churn, and rebuilds start at most once per 5 s so a burst of changes does not trigger back-to-back fleet scans. - **Zone Monitoring showed "Next run: N seconds ago" while checks were running.** Opening the page right after a start begins a catch-up check of zones not seen yet; the first scheduled full cycle fired during it and was dropped rather than queued, so "Last run" stayed empty and "Next run" counted into the past for up to a whole interval. A full cycle asked for during a partial one now runs as soon as it ends, the schedule re-arms itself after every run so "Next run" is the real next start, and the header says "due now" instead of a time in the past. ([#177](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/177)) - **Header chip stuck on "connecting" after a tab slept.** Safari (and Chrome on battery) freezes background tabs and hands back a dead or permanently connecting event stream that never errors. The server heartbeat is now a message the page can measure, and the realtime provider reopens the stream when it is silent past two heartbeats, stuck connecting, or closed - on a 5 s watchdog and immediately when the tab is shown, the window focused or the network returns. Pages receive a `realtime.reconnected` event and refetch; the page itself never reloads. ([#168](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/168)) - **Integration stack sometimes failed to start with "Address already in use".** Containers with dynamic addresses could take the ones pinned for the primary and secondaries; dynamic allocation now uses the upper half of the compose subnet. ([#171](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/171)) - **Unit tests broke from a checkout whose path contains a space.** The vitest `server-only` alias and the audit resource-id contract test now resolve paths with `fileURLToPath`. ([#173](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/173)) ## [1.9.1] - 2026-10-17 Focus fix for the Create zone form. **No schema change, no migration.** Pull the new tag and recreate the container. ### Fixed - **Zone name input lost focus after the first character on Create zone.** The shared form field rendered its control as a lone element until a sibling appeared next to it (the "Stored as" preview that shows on the first keystroke), and React remounted the input on that change of shape. The field now renders its children in one stable shape, so every field whose control is followed by a conditional note keeps focus. ([#161](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/161)) ## [1.9.0] - 2026-10-09 Feature release. The headline is an **MCP server** (`POST /api/mcp`) so Claude Code, Codex and other MCP clients can manage records in natural language through the same RBAC, validation and audit path as the UI. Alongside it: a security hardening pass (read-only backends now refuse writes, privilege ceilings applied consistently, login CSRF closed, the password-reset link kept out of the audit log), an accessibility pass (keyboard operability, labelled forms, WCAG AA contrast), the OIDC/SAML session-expiry bounce fix, and a correctness pass over migrations, backup/restore, the PowerDNS client and zonefile import. **No schema change, no migration.** SQLite installs that upgraded through 1.1.0 should read the [upgrade notes](./docs/09-UPGRADING.md#upgrading-to-190-from-184) - a migration bug at that version may have deleted per-zone grants. ### Added - **MCP server: manage DNS from Claude Code, Codex and other AI clients.** `POST /api/mcp` speaks the Model Context Protocol (Streamable HTTP, stateless) and authenticates with a personal access token. Nine tools cover listing backends and zones, reading records, and replacing / appending / deleting records, plus creating and deleting zones. Every call runs as the token's owner through the existing RBAC, validation, concurrency and audit path; write tools are only shown to tokens that can write. Setup for each client in [`docs/14-MCP.md`](docs/14-MCP.md). ### Security - dependency advisories - `next` 16.3.6 → 16.4.0 (and `eslint-config-next` 16.2.6 → 16.4.0) - six high-severity advisories published after 1.8.4: pending `use cache` fill leaking Draft Mode content ([GHSA-3w37-wq28-93x7](https://github.com/advisories/GHSA-3w37-wq28-93x7)), cache poisoning of SSG/ISR pages on self-hosted apps ([GHSA-4jqv-mc3x-m676](https://github.com/advisories/GHSA-4jqv-mc3x-m676)), information disclosure in the dev server's MCP endpoint ([GHSA-39w2-rjm5-chcv](https://github.com/advisories/GHSA-39w2-rjm5-chcv)), information disclosure in App Router metadata image routes via a `dynamicParams` bypass ([GHSA-f87g-xv8r-7p7x](https://github.com/advisories/GHSA-f87g-xv8r-7p7x)), SSG/ISR cache poisoning leading to cross-user content substitution and persistent denial of service ([GHSA-mcj8-r9mp-w47p](https://github.com/advisories/GHSA-mcj8-r9mp-w47p)), and SSRF in Image Optimization ([GHSA-cjq9-62q9-8jv4](https://github.com/advisories/GHSA-cjq9-62q9-8jv4)). `npm audit --omit=dev` is back to 0 vulnerabilities. ### Security - **The password-reset link is no longer written to the audit log when SMTP is off.** Any `audit.read` holder could request a reset for another local account, read the link from the audit panel and take the account over. The link now goes to the server log only (warn level); the admin "Reset password" flow is the supported out-of-band path. Signup-verification, re-verification and email-change links stay in the audit row: they only complete an action the account holder initiated and are not a credential. - **Read-only backends now refuse writes.** `write_mode = read_only` only steered the backend pickers; any write route accepted the backend by slug. The gateway now rejects zone, record, metadata, DNSSEC key, TSIG and autoprimary writes to a read-only backend. NOTIFY and cache flush stay allowed. - **Privilege ceilings applied consistently.** `user.update` can no longer disable, force a password change on, or relax MFA for a user who holds global permissions the actor lacks; `role.assign` can no longer remove a role that grants permissions the actor lacks; adding a team member now checks the actor against every zone grant the team holds; cloning a zone requires `zone.read` on the source; a team-scoped `role.assign` no longer assigns global roles at user creation. - **Unauthenticated endpoints rate-limited and cross-site hardened.** The passkey challenge/verify endpoints and the DynDNS endpoint share the login rate limit (the challenge endpoint could evict every pending MFA challenge and reveal token from the server-side store). Pre-session JSON endpoints (login, LDAP login, MFA, passkeys) now require `application/json` and a same-origin `Origin` / `Sec-Fetch-Site`, closing login CSRF. Anonymous reveal-store entries have their own quota and can never evict a user's. - **Passwordless passkey sign-in requires user verification** (PIN or biometric) regardless of the global `WEBAUTHN_USER_VERIFICATION` default; as a second factor, `preferred` still applies. - **Disabled accounts can't sign in through OIDC, SAML or LDAP.** The callbacks created a session row and a "login success" audit line before the per-request check refused them; they now fail with `account-disabled` and an `auth.login.failure` audit entry. - **SAML: `require_encrypted_assertion` is now enforced** (it was stored but never checked), and SP-initiated single logout is audited as `auth.logout` with an HttpOnly flag cookie. - Provisioning refuses a `roles[]` entry whose slug is a system role instead of silently rewriting the seeded role's permissions on every boot. - The admin password-reset reveal token is bound to the target user, so the audit row names the account whose password was revealed rather than the `[id]` the caller typed; PAT plaintext reveals are now audited (`auth.token.revealed`). ### Changed - "Skip to content" link and labelled navigation landmarks; the Sync chip names the actual state ("lagging", "missing", "error") instead of "desynced"; hover-only badge tooltips are repeated as screen-reader text; "PowerDNS" replaces "PDNS" in operator-facing copy; list tables name the next action when empty; the admin list pages show loading skeletons. - Backup export awaits its audit row; backup restore caps the body at 64 MiB; RRset PATCH bodies are bounded (500 changes, 1000 records per RRset). - Team member role changes are audited as `team.member.role_changed`, OIDC discovery probes as `oidc.provider.tested`, instead of borrowing unrelated actions. ### Fixed - **Session expiry showed the local login form even when the default sign-in method is OIDC or SAML.** An expired or revoked session lands on `/login?flash=session-required`, and the page treated any flash as a reason not to auto-bounce to the default provider. A lost session now bounces to the IdP like a plain visit (carrying `next`); explicit sign-out, `?force-local=1` and real error codes still keep the form. ([#153](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/153)) - **SAML sign-in was rejected with "InResponseTo is not valid".** The AuthnRequest id was recorded in a throwaway node-saml instance while the Response was validated by another, so `validateInResponseTo: always` never found it. The expected id (from the signed cookie) is now seeded into the validating instance, and a Response with no `InResponseTo` is refused as unsolicited. - Query-string validation failures on TSIG delete, cryptokey delete and metadata delete returned 500 instead of 400; malformed bodies on forgot-password, reset-password and email-verify did the same. - Bulk zone export/import no longer echo raw upstream error strings; they're redacted like every other PowerDNS error. - **Keyboard operability.** Table column sorting is a real button (it was a click-only header that advertised `aria-sort`), the zone-kind / SOA-EDIT / metadata pickers are the shared select menu instead of mouse-only lists, the user menu follows the menu-button keyboard model, the zones filter tabs take arrow keys, and the mobile navigation drawer is `inert` while closed so phone users no longer tab through twenty hidden links. - **Form labels.** Every field caption is wired to its control (`htmlFor` / `id` / `aria-describedby`) through one shared `Field`; inputs in dense rows carry an `aria-label`; the eight panels that were `
`s with a button are real forms that submit on Enter; the import/export zone picker's checkbox has an accessible name. - **Dialogs and toasts.** Destructive confirms open with focus on Cancel, modals make the page behind them `inert`, the record editor no longer discards an edit on a stray backdrop click, settings panels with unsaved edits ask before the tab closes, error toasts stay until dismissed and auto-dismiss pauses on hover/focus, the TSIG wizard uses the shared dialog (focus trap + restore), and the health bell closes on Escape, speaks severity and reports a failed acknowledge. - **Contrast and motion.** Semantic colours used as text now meet WCAG AA in both themes via new `--color-success-fg` / `--color-error-fg` tokens, `--color-fg-subtle` is darker/lighter, and the header dot and dashboard skeletons honour `prefers-reduced-motion`. - **Small screens.** Provider group-mapping rows, the Before/After diff and the SRV / NAPTR / CAA / URI editors reflow below `sm`; team members and role assignments use the shared data table with its card layout. - **SQLite upgrades lost zone grants and metrics.** Release 1.1.0's migration rebuilt the `pdns_servers` table while SQLite foreign-key enforcement was still on (drizzle-kit's `PRAGMA foreign_keys=OFF` is a no-op inside the migrator's transaction), so the `DROP TABLE` cascaded into `zone_grants`, `metric_samples` and `pdns_server_stats` and nulled `pdns_requests.server_id`. The migration runner now disables foreign keys on the connection before applying migrations and verifies referential integrity afterwards, so this cannot recur on future table rebuilds. **If you run SQLite and upgraded through 1.1.0, check Admin → Users for missing per-zone grants** - they were deleted at that upgrade and need to be re-created (see [Upgrading](./docs/09-UPGRADING.md#unreleased)). - **Backup export and restore did not work.** On Postgres the export answered 500 whenever the audit log had rows (bigserial ids are not JSON-serialisable); on both databases the restore converted no timestamps, so every row was rejected while the response said `ok: true` and an audit entry claimed a restore had happened. Export now serialises ids and dates explicitly, restore converts them back by column type, counts real inserts (the result table shows Inserted / Skipped / Failed), reports `ok: false` when any row failed, and moves the Postgres audit-id sequence past the restored rows so the next audited action does not collide. **Backups taken with earlier versions are incomplete - take a fresh export after upgrading.** - **Retried writes could double-apply on PowerDNS.** A `POST` that timed out after PowerDNS had committed it (new cryptokey, new zone, new TSIG key, new autoprimary) was sent again, producing a second KSK or a spurious "already exists" error. Writes are now repeated only when safe: idempotent requests (GET/PUT/DELETE, PATCH with REPLACE/DELETE changetypes) or a failure that proves the request never reached the daemon (connection refused, DNS failure). - **Zonefile import mis-read common BIND syntax.** Blank-owner continuation lines (` 300 IN A 192.0.2.5` under a previous owner) imported as `300.` / `in.` records, and unit TTLs (`1h`) produced a record of type `1H` - all with no diagnostic. Blank owners now inherit the previous owner, TTL units are parsed on records and in `$TTL`, and a token that cannot be a record type is a reported error rather than a bogus record. - **Per-zone grants did not match some URL spellings.** Deleting a zone, saving zone settings or editing metadata as a grant holder returned 403 when the zone was requested without the trailing dot or in a different case, and the audit resource id varied with the caller's spelling. The three routes now canonicalise the zone name like every other route. - **Cloned, imported and exported zones had no entry in the zone's History.** Their audit rows were written without the backend prefix the change-log filters on. - **A slow backend held up every other write to it.** The per-backend lock was held across all retry attempts and their back-off (~31 s against a hung daemon), so concurrent saves queued N × 31 s. The lock now covers one attempt at a time. - **Mirrors of SOA-EDIT=EPOCH zones were permanently "lagging"** and raised a replication-drift advisory that never cleared. A mirror of such a zone is now judged by how recently it transferred (within one SOA refresh is in sync). - **Catalog Producer zones were not NOTIFYed on create**, leaving their Consumers to the SOA refresh; they are now treated as transferred zones everywhere (NOTIFY, sync checks, TSIG eligibility), consistent with the DNSSEC code that already did. - **Record edits on large zones were slow.** A single-record change fetched every record of the zone twice; on PowerDNS 4.5+ the editor now fetches only the record sets it touches (and DNSSEC disable/rectify/key listing no longer fetch records). - **The PowerDNS request log grew without bound.** `pdns_requests` rows older than `PDNS_REQUEST_LOG_RETENTION_DAYS` (new setting, default 7) are now pruned alongside the metrics tables. - Zones deleted outside the app (pdnsutil, another client) now refresh open zone lists instead of lingering until the next navigation; classless reverse zones (`0/25.2.0.192.in-addr.arpa.`) are addressed with PowerDNS' own zone-id encoding; the delete-record API validates the record type like the create/update API. ## [1.8.4] - 2026-10-07 Layout fix for the inline record editor introduced in 1.8.3. **No schema change, no migration.** Pull the new tag and recreate the container. ### Fixed - **Inline edit strip misaligned on structured record types** (MX, SRV, CAA, NAPTR, SSHFP, TLSA, URI, SVCB/HTTPS). The TTL label and the editor's own field labels sat on two baselines under a redundant "Value" label, and the Disabled checkbox floated at a fixed offset on the right. Every field in the strip now shares one label row and one input row, Disabled is a labelled switch on that row, and the MX Preference column is wide enough that its hint no longer wraps. ([#151](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/151)) ## [1.8.3] - 2026-10-07 A usability release: edit records in place, drive the records table and every select menu from the keyboard, and a handful of form fixes found on the way. **No schema change, no migration.** Pull the new tag and recreate the container. ### Added - **Inline record editing.** Enter on a row, double-click, or the row's Edit button opens an edit strip under the record for TTL, value (with the structured per-type editors) and the disabled flag. Enter stages the change into the usual Review diff, Esc cancels. Renames, type changes and comments go through the full dialog (`E`, the ⋯ button, or "Full editor…" from the strip, which carries the draft across). Both paths share one validation + staging routine, so there is still exactly one route to PowerDNS. - **Keyboard-first records table.** `/` focuses search (Esc clears, Esc again leaves), `N` adds a record, `↑`/`↓` move between rows, `Enter` edits in place, `E` opens the full editor, `Delete` deletes. The editor and review dialogs are forms: Enter reviews, Enter saves, Ctrl/⌘+Enter submits from a textarea, Esc steps back one level. A legend under the table lists the keys. - **Select menus are combo boxes.** Every `` (record type, zone kind, template, backend, page size, …) is keyboard-navigable: type-ahead on a closed menu picks the match straight away (`T` → TXT, `T` again → TLSA, `TL` → TLSA), arrows / Home / End / PgUp / PgDn move the highlight, Enter or Space picks, Esc closes. Menus with ten or more options show a filter box that matches label and description (`ipv6` → AAAA, `mail` → MX). The record-type menu now shows each type's plain-English name. ### Fixed - **Esc on an open select inside a dialog closed the dialog too.** The menu now owns the Escape key while it's open; one press closes the menu, the next closes the dialog. - **Clearing a number field in the MX / SRV / CAA / NAPTR / SVCB editors snapped it back to `0`**, so "select all, type 20" could produce `020`. Those fields now use the same draft-aware number input as TTL and the SOA panel (moved to `components/ui/number-input`). - **Form labels in the record editor weren't attached to their inputs.** Clicking "TTL" or "Value" now focuses the field, and screen readers announce the field name and hint. - **Select menus lost keyboard focus after a pick** made from the mouse or the filter box; focus now returns to the trigger so Tab continues from where the operator was. ## [1.8.2] - 2026-10-07 Two fixes to the 1.8 DNSSEC API, found during a production rollout. **No schema change, no migration.** Pull the new tag and recreate the container. ### Fixed - **`GET .../dnssec` `mirrors[]` flapped between in-sync and "missing"** whenever nobody had the app open in a browser. With no live subscriber, the poller drops to stats-only cycles, even with `PDNS_BACKGROUND_POLLING=true`, so zone state goes stale after 30 s. Pages re-observe through the layout, but an API request never did, and a stale cache was reported as the zone being missing. The route now observes the backends before reading. A mirror that hasn't been observed recently reports `error` ("Mirror not observed recently") instead of `missing`. The zone page also warms the cache itself instead of relying on the layout, which renders in parallel. - `GET /api/admin/pdns/zones/{zone}/dnssec` (and the DNSSEC tab) on an **unsigned** zone now returns the warnings Enable would give, such as LUA or ALIAS records on a replicated zone. Before, they only appeared once the zone was signed, so GET couldn't serve as a pre-flight check. ## [1.8.1] - 2026-10-07 Follow-up to 1.8.0's DNSSEC tooling. **No schema change, no migration.** Pull the new tag and recreate the container. ### Fixed - **Enabling DNSSEC on PowerDNS 4.6-4.8 could leave secondaries on the old serial.** PowerDNS caches zone metadata (`zone-metadata-cache-ttl`, 60 s by default), and those versions don't clear the cache when a zone PUT changes SOA-EDIT. The NOTIFY sent right after enabling could carry the pre-SOA-EDIT serial. The served serial then moved up to a minute later with no NOTIFY, and secondaries waited for their SOA refresh. Enable now flushes the zone's caches, which clears the metadata cache on 4.9+. When SOA-EDIT changed, it also sends a second NOTIFY once the cache TTL has passed (`followUpNotifyInSeconds` in the response). The same could happen on 4.9 when re-running Enable only to set SOA-EDIT on an already-signed zone. - **The DS set to publish no longer includes SHA-1.** The top-level `ds` of `GET .../dnssec` and `GET .../cryptokeys`, and the DS box on the DNSSEC tab, now leave out digest types 1 (SHA-1) and 3 (GOST). RFC 8624 rules SHA-1 out for DS, and registrars warn on or refuse it. Each key's own `ds` array still lists everything PowerDNS returns. ## [1.8.0] - 2026-10-07 DNSSEC sync fix and zone-level DNSSEC tooling. **No schema change, no migration.** Pull the new tag and recreate the container. Zones with SOA-EDIT (typically signed zones with presigned secondaries) stop reading as DESYNCED, and the header chip goes green again. ### Added - **Zone-level DNSSEC: Enable, Disable, Rectify.** The DNSSEC tab and new routes sign a zone the way PowerDNS intends: one `PUT /zones/{id}` with `dnssec: true`. In a single transaction PowerDNS adds its default keys, rectifies the zone and bumps the serial. AuthAdmin turns API-RECTIFY on and, for a zone that's transferred to secondaries and has no SOA-EDIT, sets `INCREMENT-WEEKS` so presigned secondaries re-transfer fresh signatures every week. It then checks that the served serial actually moved and NOTIFYs. **Rectify** recomputes ordername/auth and bumps the serial so presigned secondaries pick up the corrected NSEC chain. **Disable** asks for the zone name (`confirm=` on the API) and warns to remove the DS at the registrar first. The tab also shows signing state, NSEC/NSEC3, SOA-EDIT, the DS set to publish, and warnings for a transferred zone without SOA-EDIT or with LUA/ALIAS records (a presigned secondary can't sign those answers). Audited as `dnssec.enable`, `dnssec.disable` and `dnssec.rectify`. - **DNSSEC state over the API** for PAT clients: `GET /api/admin/pdns/zones/{zone}/dnssec` (signing state, keys, DS to publish, per-mirror sync state) and `GET /api/admin/pdns/zones/{zone}/cryptokeys` (id, keytype, active, published, flags, algorithm, bits, DNSKEY, DS, CDS; never private key material). Every DNSSEC route takes `serverSlug`. See [04-BACKENDS § DNSSEC](docs/04-BACKENDS.md#dnssec) for the rollout order and curl examples. ### Fixed - **SOA-EDIT / signed zones were permanently DESYNCED.** Sync checks compared the primary's stored serial with the serial the secondary holds. A secondary stores what the primary _serves_, which is the post-SOA-EDIT `edited_serial`. So every zone with SOA-EDIT (the normal setup for a signed zone with presigned secondaries) read as "ahead". One such zone turned the header chip red for the whole fleet and, after 15 minutes, raised a false "Replication is stuck" advisory. The zone page, zones list, header chip, servers page and the poller's drift tracking now share one rule that compares against the served serial. ([#146](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/146)) - The time-based SOA-EDIT kinds advance the served serial at the weekly boundary without a NOTIFY, so secondaries only follow at their next SOA refresh. For one SOA refresh after the boundary, a mirror that is behind on such a zone now shows as **refresh due** instead of desynced. - The record diff on the Sync tab no longer flags a signed zone's presigned mirror. The SOA is compared without its serial, and the RRSIG, DNSKEY, CDS, CDNSKEY and NSEC\* records a presigned secondary stores (and a primary doesn't) are counted rather than shown as drift. - **Adding a DNSSEC key no longer leaves NSEC denial broken.** PowerDNS' `POST /cryptokeys` doesn't rectify. On a zone whose records had never been rectified for DNSSEC, NXDOMAIN/NODATA proofs stayed broken until the next record edit. The cryptokey route now rectifies after creating a key (`rectified` in the response). ### Security - dependency advisories - `sharp` 0.35.4 → 0.35.5 (GHSA-wq5f-xc86-pv6w, librsvg) - `source-map-js` 1.2.1 → 1.2.2 (GHSA-68fv-2mgg-jv7q), pinned through `overrides` - `fast-copy` 4.0.3 → 4.1.1 (GHSA-jggr-w7fw-pc2j) ## [1.7.0] - 2026-10-02 Security patches, a scoped-access fix for the Teams page, and a configurable default TTL. **No schema change, no migration.** Pull the new tag and recreate the container. The new setting is optional and defaults to the old behaviour (3600). ### Added - **Configurable default TTL for new records.** The record editor no longer always starts at 3600. The value now comes from, in order: the zone's `X-AUTHADMIN-DEFAULT-TTL` metadata, the new **Default TTL for new records** setting (Admin → Settings, or `default_record_ttl` in the provisioning YAML), then 3600. The TTL field says which one it used. The per-zone value is ordinary PowerDNS metadata: set it from the zone's Metadata tab (needs `metadata.write`, audited like any metadata change), or add it to a zone template's metadata to seed it on every zone created from that template. Values are checked on write, so a typo is rejected instead of being saved and silently ignored. New zone-template rows use the global default too. ([#143](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/discussions/143)) ### Fixed - A **Team Owner scoped to one team** saw the Teams link in the sidebar but was bounced off `/admin/teams` with "missing team.read". The list page and `GET /api/admin/teams` only accepted a global grant. Both now accept `team.read` at any scope and list only the teams the caller can read. A global grant still lists every team, and the "Add team" button still needs a global `team.create`. ([#142](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/142)) ### Security - dependency advisories - `next` 16.3.4 → 16.3.6 - remote code execution in `next/og` `ImageResponse`. - `undici` 8.10.0 → 8.11.2 - TLS certificate validation bypass in `BalancedPool`, cross-origin cache poisoning, cookie disclosure through shared caches, and several denial-of-service fixes. The copy bundled with `jsdom` moves 7.29.0 → 7.29.1. - `nodemailer` 9.1.1 → 10.0.10 - TLS `servername` reused across transports (GHSA-6vj9-mwq6-2f5v) and malformed envelope recipients from quoted local parts (GHSA-g57g-f23g-4646). Version 10's only breaking change is a Node 20 minimum; the image runs Node 24. - Transitive `brace-expansion` and `dompurify` bumps. ## [1.6.2] - 2026-09-18 Sign-in reliability. **No schema change, no config change** - pull the new tag and recreate the container. ### Fixed - Sign-in could fail with **"Invalid request body."** against a form the visitor had filled in correctly, then blank both fields. It happened on a first visit - fresh profile, private window, or any cold cache - and cleared up on a retry, which made it look like flakiness rather than a bug. The cause was a hydration race. `/login` is server-rendered, so the form accepts keystrokes from the moment it paints, but its inputs are controlled by React state that doesn't exist until the client bundle loads. Anything typed in that window reached the DOM and nothing else: `required` passed (the browser validates the DOM), the submit handler serialised the still- empty state, and the server rejected `{"email":"","password":""}`. Rendering that error then wrote the empty state back into the fields. The pre-auth forms now take their values from the form itself - adopting whatever the fields already hold once React arrives, and re-reading them at submit - so what the visitor sees is what gets sent. That also covers password managers that assign `input.value` without firing an event React recognises, which failed the same way with no timing involved. Fixes the login, LDAP login, signup, forgot-password and reset-password forms. Reset-password was failing differently but just as confusingly: its client-side length check measured the empty state and reported "Password must be at least 12 characters." against a field holding a long password. ([#139](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/issues/139)) ## [1.6.1] - 2026-09-15 ### Fixed - Record editor: the underscore-label warning (DKIM/DMARC/SRV names) and the unrecognized-CAA-tag warning both said "Override if ..." even though warnings have no override control in the UI - only validation _errors_ get a "Save anyway" checkbox. The wording could be misread as an instruction to replace the underscore with a hyphen, which breaks the record it's describing. Both messages now say plainly that the value is fine as entered and no change is needed. ## [1.6.0] - 2026-09-14 Security release. **No schema change, no config change** - pull the new tag and recreate the container. ### Security - dependency advisories - `next` 16.2.12 → 16.3.4 - unauthenticated RCE on Windows-hosted servers ([GHSA-p293-qw3h-jr36](https://github.com/advisories/GHSA-p293-qw3h-jr36), critical) and unauthenticated RCE in the Image Optimization API via crafted AVIF files ([GHSA-2xp9-vwfh-vxw4](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4), critical). - `sharp` 0.35.3 → 0.35.4 (the `next`-driven override in `overrides.sharp`) - libheif memory-safety bugs ([GHSA-rgj7-g3m4-5g8c](https://github.com/advisories/GHSA-rgj7-g3m4-5g8c), high). - `nodemailer` 9.0.1 → 9.1.1 - the legacy `resolveContent()` signature bypassed `disableFileAccess`/`disableUrlAccess` ([GHSA-8m3c-c648-2xjj](https://github.com/advisories/GHSA-8m3c-c648-2xjj), moderate), an IDN/punycode domain allow-list bypass could route mail to an attacker-controlled domain ([GHSA-wmmp-3585-3rmp](https://github.com/advisories/GHSA-wmmp-3585-3rmp), moderate), address-list parsing was quadratic time, letting a crafted recipient list DoS the sender ([GHSA-2x7j-588g-ccc2](https://github.com/advisories/GHSA-2x7j-588g-ccc2), high), and an RFC 5322 comment-parsing quirk let recipient-domain validation be bypassed the same way ([GHSA-cc9r-2j5m-2m83](https://github.com/advisories/GHSA-cc9r-2j5m-2m83), moderate). Backs `lib/email`. - `js-yaml` 4.3.1 → 4.3.2 - `maxTotalMergeKeys` didn't count empty merge sources, so a crafted YAML document could burn CPU well past the configured limit ([GHSA-2883-xcg3-v3hh](https://github.com/advisories/GHSA-2883-xcg3-v3hh), high). Backs the provisioning YAML loader. - `@simplewebauthn/server` 13.3.1 → 13.3.3 - registration verification didn't fully chain attestation certificates to a trust anchor ([GHSA-6hxq-p678-4hr2](https://github.com/advisories/GHSA-6hxq-p678-4hr2), low). - `vitest` / `@vitest/coverage-v8` / `@vitest/ui` 4.1.7 → 4.1.11 - a redirect mock in `@vitest/mocker` allowed path traversal / arbitrary file read ([GHSA-82fw-gwwq-j7x9](https://github.com/advisories/GHSA-82fw-gwwq-j7x9), moderate). Dev-only. ## [1.5.6] - 2026-08-27 Feature release. **No schema change** - the upgrade is a pull-and-recreate. It ships one data-only migration that backfills permission lists; there is no DDL and no downtime beyond the container restart. Editing a zone's records and editing the zone itself used to be the same permission. A role built for a hosting customer - `record.create` / `record.update` / `record.delete` on their own zone - could also rewrite the SOA and was shown the Zone settings tab, which is where SOA-EDIT-API, the zone kind and the masters list live. There was no way to hand someone their records without handing them the zone's authority (#119). ### Added - the SOA, the apex NS and zone settings are their own permissions Four permissions split those surfaces out of `zone.read` / `record.update`: | Permission | Gates | | ----------------------- | ----------------------------------------------------------------------- | | `soa.read` | The **SOA** tab. Without it the tab is not rendered. | | `soa.update` | Every write to the SOA RRset, whatever sent it. | | `zone.settings.read` | The **Zone settings** tab (kind, masters, SOA-EDIT(-API), API-RECTIFY). | | `record.update.apex-ns` | Writing the zone's **apex NS** - its delegation. | Three things are worth knowing about how they compose: - **`soa.update` replaces the record permission rather than adding to it.** The SOA is modelled as its own resource, so `soa.update` alone edits it and no amount of `record.*` substitutes for it. `record.update.apex-ns` is the opposite - an _extra_ requirement layered on top of the matching `record.create` / `record.update` / `record.delete`, since an apex NS write is still a record write. - **NS below the apex stays an ordinary record.** A child delegation is content the zone serves, not the zone's own standing, so `record.*` covers it. - **A missing read permission removes the tab, not just its Save button.** A direct `?tab=soa` or `?tab=settings` link falls back to Records. (Holding `zone.delete` still surfaces the settings tab, because the Danger Zone lives there; the settings panel itself stays hidden.) Enforcement is server-side, in the RRset route, against each change's normalized name - so the SOA panel, the record editor and a hand-rolled `PATCH` all get the same answer. In the editor the apex NS rows render as `Delegation - locked` and a rename that would move a record onto them is refused before it can be staged. All four permissions are grantable per-zone, so `soa.update` can be opened on one customer's zone without going fleet-wide. [ADR-0023](./docs/adr/0023-zone-authority-permissions.md) records why the SOA is modelled as its own resource while the apex NS stays a record with an extra cost, and what was rejected on the way there. Thanks to [@vducros-neyrial](https://github.com/vducros-neyrial) for the report and the concrete self-service scenario behind it (#119). ### Fixed - the Zone settings panel answered to `record.update` in the UI `PUT /api/admin/pdns/zones/{zone}/settings` has always required `zone.update`, but the page enabled the panel's inputs on `record.update` for any non-mirror zone. A record editor got a fully interactive settings form whose Save then failed with a 403. The panel now reads `zone.update` on every zone kind, which is also what makes the settings tab meaningful to hide. ### Changed - Zone Editor no longer edits the SOA or the apex NS The seeded **Zone Editor** role is the one you hand to someone who manages records in a zone they don't own, so it gets `soa.read` and `zone.settings.read` (it still _sees_ both surfaces) but neither `soa.update` nor `record.update.apex-ns`. Those move to **Operator** and above, alongside the `zone.update` that role already held. **Read Only** gains both read permissions and no writes. System roles are re-seeded on every boot, so this applies the moment you upgrade. **Custom roles, zone grants and API tokens are backfilled instead**, by migration `0007_granular_zone_permissions_backfill`: anything that held `zone.read` gains `soa.read` + `zone.settings.read`, anything that held `record.update` gains `soa.update`, and anything that held any `record.*` write gains `record.update.apex-ns`. An upgrade therefore changes nothing about what your own roles can do - tightening one is an opt-in edit, and unticking `soa.update` is now all it takes. ## [1.5.5] - 2026-08-12 Bug-fix + dependency-security release. **No schema change** - the upgrade is a pull-and-recreate, with no migration to watch. Zonefile import silently corrupted any record whose quoted data contained parentheses. PowerDNS LUA records were the worst hit, but it also broke the app's own export → import path, so moving an affected zone between two backends mangled it. Import also skipped the Lua-records gate that the record editor has always enforced; it now answers to the same check. ### Fixed - zonefile import no longer rewrites quoted record data Importing a zonefile stripped **every** parenthesis from a record line, including parentheses inside quoted RDATA, and normalized whitespace inside quoted strings. Both happened silently - no diagnostic, no failure - so the import reported success over corrupted records. PowerDNS LUA records were the worst hit (every Lua expression is a function call), but any TXT value carrying parentheses or doubled spaces was affected too. This also broke the app's own export → import path: `formatZonefile()` writes record content verbatim, so moving a zone with a LUA record between two backends corrupted it. A quoted `(` additionally derailed multi-line tracking, swallowing every following line into the record until a stray `)` turned up. Comment stripping, quote handling, field splitting and parenthesis-depth accounting now share a single pass over each line, so quoted RDATA is opaque to all four - `;`, `(`, `)` and runs of whitespace inside a character-string are data, exactly as RFC 1035 §5.1 has it. An unbalanced `"` is now reported on the line where it occurs instead of consuming the rest of the file. Thanks to [@Der-Jan](https://github.com/Der-Jan) for finding and fixing this (#127, #128, #130). ### Fixed - zonefile import now answers to the Lua-records gate Creating a `LUA` record through the record editor is refused unless PowerDNS actually has Lua armed, verified live against the daemon and failing closed. Zonefile import wrote to the same daemon and skipped that check entirely, so a zonefile could land LUA records on a backend where the editor would refuse them, and where PowerDNS then serves nothing for them with no explanation anywhere in the UI. Import now applies the same gate. Because a zone being imported doesn't exist yet, only the daemon-global `enable-lua-records` setting can arm it (there is no zone to carry `ENABLE-LUA-RECORDS` metadata). A refusal fails just that zone and reports why in the per-zone result, leaving the rest of the import to proceed (#129). ### Security - dependency advisories Lockfile-only; every bump lands inside an existing semver range, so no declared dependency or override changed. - `dompurify` 3.4.12 → 3.4.13 - removing an `IN_PLACE` hook left a detached subtree executable, giving XSS ([GHSA-55q2-fjhq-7xh7](https://github.com/advisories/GHSA-55q2-fjhq-7xh7), moderate). Reaches us as a runtime dep through `isomorphic-dompurify`, which backs the inline-SVG brand-logo sanitizer. - `nanoid` 3.3.16 → 3.3.17 - custom generators loop indefinitely when `size` is zero ([GHSA-2v37-7h3g-55p8](https://github.com/advisories/GHSA-2v37-7h3g-55p8), high). Transitive through `postcss`; this is the one that was failing the `audit` CI gate. - `brace-expansion` 5.0.7 → 5.0.9 and 1.1.16 → 1.1.18 - DoS via unbounded intermediate arrays, bypassing the CVE-2026-14257 mitigation ([GHSA-rgw5-rvv9-x895](https://github.com/advisories/GHSA-rgw5-rvv9-x895), high) and unbounded expansion length ([GHSA-mh99-v99m-4gvg](https://github.com/advisories/GHSA-mh99-v99m-4gvg), high). Dev-only, through the ESLint plugins' `minimatch`. ## [1.5.4] - 2026-08-08 Feature + dependency-security release. **Adds one table (`zone_horizons`) - the first schema change since 1.5.0.** The migration runs automatically at container boot (ADR-0011) and creates an empty table; no existing row is read or written, so the upgrade is a pull-and-recreate. See [UPGRADING](./docs/09-UPGRADING.md#upgrading-to-154-from-153). ### Added - split-horizon zones ("This is an internal zone") Zones carry a **horizon** - public (the default) or internal - set with a toggle when you add a zone, or later on the zone's **Zone settings** tab. An internal zone is listed as its own row alongside a public zone of the same name instead of disappearing into the "duplicate zones hidden" notice, carries an `INTERNAL` badge next to the `CLUSTER` badge in the zones list, and shows that badge on its detail page so it's unambiguous which copy you have open. A Public / Internal filter appears above the list once at least one internal zone exists. The classification is AuthAdmin-side - PowerDNS has no way to tell two same-named zones apart - and is stored only when it differs from the default, so nothing changes for installs that don't use it. Cluster zones classify against the cluster (not the peer that happened to serve the write), a mirror inherits its managed primary's classification, and changes are audited as `zone.horizon.update`. Adds one table, `zone_horizons`; no data migration. See [FEATURES § 4.1.1](./docs/FEATURES.md#411-split-horizon-zones-public--internal) · [ADR-0022](./docs/adr/0022-zone-horizons.md) · (#121) ### Fixed - `enable-lua-records` now shows up in backend capabilities A daemon with Lua records armed said so nowhere in the UI: the record editor offered the `LUA` type (correctly), but no capability badge, no daemon-settings row, and nothing in the stored capability snapshot reflected it - the only way to find out was to open a zone and look for the type in a dropdown. The observed capability set now carries `enable-lua-records` (`no` / `yes` / `shared`), it renders as a `lua records` badge wherever backends are listed, and the setting appears verbatim in the backend's **Daemon settings** table. The zone page now reads the cached capability instead of issuing a live `/config` call on every render for editors, so enabling Lua in `pdns.conf` needs a backend refresh (**Admin → PowerDNS servers → Refresh**) to show up - the same contract as every other capability. Per-zone `ENABLE-LUA-RECORDS` metadata still overrides a daemon-level `no`. See [FEATURES § 3.10](./docs/FEATURES.md#310-observed-daemon-capabilities) · [ADR-0014](./docs/adr/0014-backend-capability-model.md) · (#122) ### Security - dependency advisories - `undici` 8.5.0 → 8.10.0 (and the transitive 7.28.0 → 7.29.0), clearing the open advisories for degenerate private cache directives, retry-interceptor response desynchronization, cookie attribute injection, Cache-Control whitespace disclosure, and blob-type CRLF injection. - `js-yaml` 4.3.0 → 4.3.1 - quadratic CPU consumption resolving `!!omap` ([GHSA-5p4m-2wfm-xmqj](https://github.com/advisories/GHSA-5p4m-2wfm-xmqj), high). This is the one that was failing the `audit` CI gate. - `postcss` 8.5.18 → 8.5.25 - attacker-controlled `sourceMappingURL` reading arbitrary `.map` files when `from` is unset ([GHSA-fxqj-rqcc-2cmp](https://github.com/advisories/GHSA-fxqj-rqcc-2cmp), moderate). The existing `"postcss": "$postcss"` override propagates the bump through `next`'s copy too. ## [1.5.3] - 2026-07-26 Feature + dependency-security release. No migration, no schema change. ### Added - PowerDNS Lua records Forward and reverse zone editors can now create and edit PowerDNS `LUA` records, with type-aware validation of the presentation format (` ""`, adjacent quoted chunks, `\DDD` escapes, the 255-octet AXFR boundary). Because a `LUA` record is server-side code, the editor only offers the type - and the write path only accepts it - when PowerDNS actually has Lua enabled for the zone. Enablement is read from PowerDNS itself and honours **both** signals the daemon uses: the global `enable-lua-records` setting (`GET /config`) **or** the per-zone `ENABLE-LUA-RECORDS` domain metadata. Verified against pdns-auth 4.9.16: the per-zone flag is not writable through the API (`pdnsutil` / `pdns.conf` own it), and it is returned only by the metadata **list** endpoint, so AuthAdmin never tries to set it and reads it where it actually appears. The write path re-reads this live on every Lua upsert and fails closed, so a stale tab or crafted request can't create a Lua record on a server that has Lua off. Thanks to @Der-Jan for the original feature (#115). ### Changed - metadata write-policy is now enforced, not just hinted Which zone-metadata kinds are read-only now lives in one place (`lib/pdns/metadata-policy`) that both the UI and the write path consult, so the metadata tab hides exactly the kinds the API refuses. Writes to a read-only kind (the DNSSEC signing state, the AXFR TSIG bindings, `ENABLE-LUA-RECORDS`) are declined at the boundary with a clear message instead of being forwarded to PowerDNS for a raw 422. All the kinds in that set were verified to reject `PUT` and `DELETE` on pdns-auth 4.9.16. ### Security - `next` and `postcss` advisories - `next` bumped `16.2.6 → 16.2.12`, clearing the high-severity App Router advisories fixed in 16.2.11 (middleware/proxy bypass, Server Actions DoS/SSRF, rewrite SSRF, and the moderate cache/disclosure issues). - `postcss` pinned forward `8.5.15 → 8.5.18` for the path-traversal in source-map auto-loading ([GHSA-r28c-9q8g-f849](https://github.com/advisories/GHSA-r28c-9q8g-f849)), a transitive of `next`. The advisory was published after 1.5.2 shipped; `npm audit --audit-level=high --omit=dev` is clean on 8.5.18. ## [1.5.2] - 2026-07-22 Dependency security release. No code change, no migration. ### Security - sharp / libvips CVEs (GHSA-f88m-g3jw-g9cj) `sharp` below 0.35.0 inherits four libvips CVEs (CVE-2026-33327, CVE-2026-33328, CVE-2026-35590, CVE-2026-35591). It reaches us as a transitive **runtime** dependency of `next`, which uses it to optimize images in production - and `next/image` is used here, so it isn't dead weight we could simply drop. `next@16` still declares `sharp: ^0.34.5`, and every 0.34.x is affected. `npm audit`'s only suggested remedy is downgrading to `next@14`, which is a far bigger regression than the bug. Pinned forward with an `overrides` entry to `^0.35.3` instead. Verified rather than assumed, since this forces a version outside the range Next declares: sharp loads against libvips 8.18.3, resize/webp/avif round-trips succeed, and a full production build completes. Remove the override once Next widens its range. ## [1.5.1] - 2026-07-22 Bug-fix release. No migration, no schema change, no config change. Three long-standing defects that predate 1.5.0 - none were introduced by it. ### Fixed - "Test connection" crashed the result panel Clicking **Test connection** on a _reachable_ backend blanked the panel with `Cannot read properties of undefined (reading 'supportsExtendPrune')`. The endpoint returned only `cache: { version }`, while the panel rendered `cache.capabilities.supportsExtendPrune` behind a guard that checked `cache` alone. Any successful test therefore dereferenced `undefined`. Present since 1.4.0. Both halves are fixed rather than just the crash: `refreshBackendHealth()` now returns the version snapshot explicitly as part of `BackendHealthOutcome`, the route sends the full shape (version, server id, capability flags), and the panel treats the snapshot as genuinely optional. That last part matters on its own - `listZones` can succeed while the version probe fails, so a successful test legitimately has no capability data, and the panel now says so instead of throwing. ### Fixed - favicon rendered inconsistently across browsers The icon drew its `{}` mark with ``, leaving the glyph at the mercy of whichever font each browser's favicon rasterizer resolved. Firefox looked right; Safari came out cramped and misaligned. The mark is now stroked paths, so it carries its own geometry and rasterizes identically everywhere. ### Fixed - error spam from `/favicon.ico` on every page load Production logs carried two recurring errors: ``` Failed to update prerender cache for /favicon.ico Error: LRUCache: calculateSize returned 0, but size must be > 0 TypeError: Response constructor: Invalid response status code 204 ``` Both came from one cause. The route answered with `204 No Content`, and 204 is a _null-body_ status: the empty response made Next's prerender cache compute a zero size and refuse to store it, and replaying the entry reconstructed `new Response(body, { status: 204 })`, which the Response constructor rejects. The route now serves the icon itself with a `200`. Present since the initial commit. ## [1.5.0] - 2026-07-22 Feature and security release. One additive migration (`pdns_servers.write_mode`, defaulting to `auto`), no breaking changes, no behaviour change for existing backends. ### Added - read-only backends (#109, #111) Backends can now be marked **Never write to this backend (read-only)**. A backend in that state is excluded from peer selection and from every backend picker, can't be the default backend, and renders with a `read-only` badge under its group's write target - while staying fully browsable and still polled for sync state and stats. This closes a gap that only shows up in one topology: a hidden primary whose zones are `Native` and reach the public nameservers through **database replication** rather than AXFR. Those public nodes usually run against a read-only database user, but PowerDNS reports them as `primary=no, secondary=no` - byte-identical to a standalone primary. Nothing in `/config` distinguishes the two, so AuthAdmin classified them as writable and the peer-selection strategy rotated writes onto them, which the database then rejected. Since PowerDNS cannot report the fact, it has to be declared. The new `pdns_servers.write_mode` column (`auto` | `read_only`) is that declaration, and it's deliberately narrow: it can only ever _remove_ a backend from write routing, never promote a mirror into a write target, and it does not touch AXFR topology derivation - marking a genuine AXFR primary read-only stops writes to it without breaking the secondaries that pull from it. Set it from the backend's edit page or with `write_mode: read_only` in provisioning YAML. Group composition now counts read-only members as mirrors, so a hidden primary with three read-only public nodes reports **Primary + secondaries** instead of claiming to be a multi-primary cluster with a peer-selection strategy that has nothing to choose from. See [FEATURES § 3.3](./docs/FEATURES.md#33-read-only-backends-write-mode-override), [Backends → Hidden primary](./docs/04-BACKENDS.md#hidden-primary--read-only-public-nameservers-native-zones), and the 2026-07-22 amendment in [ADR-0014](./docs/adr/0014-backend-capability-model.md). ### Security (#112, #113) - **`js-yaml` 4.2.0 → 4.3.0** ([GHSA-52cp-r559-cp3m](https://github.com/advisories/GHSA-52cp-r559-cp3m), high) - YAML merge-key chains could force quadratic CPU use. Reachable only through the provisioning applier, which parses operator-supplied YAML at boot, so this was not attacker-controlled - but it is a runtime dependency and worth closing. - **`dompurify`** ([GHSA-c2j3-45gr-mqc4](https://github.com/advisories/GHSA-c2j3-45gr-mqc4), low) - refreshed via `isomorphic-dompurify`. Impact was negligible: the SVG sanitizer is defense-in-depth behind `` rendering, which already runs SVG in the browser's secure static mode. - **`brace-expansion`** ([GHSA-3jxr-9vmj-r5cp](https://github.com/advisories/GHSA-3jxr-9vmj-r5cp), high) - devDependency only, via the eslint plugins' `minimatch`. Never shipped. - **OIDC icon-URL preview** - the admin form rendered a live `` preview of the icon field before validation, which CodeQL flagged as an XSS-through-DOM sink. Real-world impact was minimal (`javascript:` doesn't execute in ``, and persisted values were already restricted to `https://` or `data:image`), but the preview was the one path that skipped the allowlist. Both sides now share a single predicate, `isSafeIconUrl()`, so they can't drift. `npm audit` reports zero vulnerabilities as of this release. ## [1.4.3] - 2026-06-04 Feature and hardening release. No migration and no breaking changes. Adds TSIG key selection for creation-style zone workflows, extends the Settings read-only lock to Backup & Restore, and captures repository standards cleanup. See [Upgrading -> 1.4.3](./docs/09-UPGRADING.md#upgrading-to-143-from-142). ### Added - TSIG key zone workflows (#100, #101) The TSIG Keys admin page now shows each key's zone correlation, including mirrored domain usage when viewing replicated key copies on secondaries. Operators can edit that correlation from the key row through the same clean multi-select domain picker used in the create-key wizard. Applying a key to zones now triggers the work in the background and reports the summary after completion instead of requiring the modal to remain open while each request finishes. The Add Zone flow now includes a TSIG Keys section for Primary / Master zones. A key is selectable only when it exists on the primary and every secondary that must participate in AXFR; the most-used eligible key is selected by default. The same selector is now available on Zone Import, so imported Master zones can be created with transfer authentication in one pass. Both create and import validate the key server-side before applying it and audit the resulting `zone.tsig-transfer.set` action. ### Fixed - SETTINGS_RO now covers Backup & Restore (#102) `SETTINGS_RO=true` now blocks settings backup export and restore in the API as well as the UI. The Backup & Restore page shows the read-only deployment lock and disables download, upload, and restore controls, while the backend remains the security boundary for direct API calls. ### Changed - repository standards and documentation polish (#97) Repo standards documentation was tightened after the standards audit: validator and RBAC guidance now more clearly describe ownership boundaries, environment handling, and contributor expectations. Integration coverage and local validation were kept aligned with the documented Node 24 workflow. ## [1.4.2] - 2026-06-02 A cosmetic patch release. No migration, no breaking changes, no config or permission changes. See [Upgrading -> 1.4.2](./docs/09-UPGRADING.md#upgrading-to-142-from-141). ### Fixed - favicon now matches the marketing site exactly The in-app browser-tab icon is now the same `{}` brace mark the marketing site ([powerdns-authadmin.org](https://powerdns-authadmin.org)) serves. It was being generated as a PNG via `next/og`, which re-drew the glyph with a bundled font and its own sizing, so it never matched the site's SVG favicon pixel-for-pixel. The PNG generator (`app/icon.tsx`) is replaced with a static `app/icon.svg` holding the identical markup the site embeds as its data-URI favicon; Next.js auto-injects `` and serves the file verbatim, so the icon renders identically across platforms. ## [1.4.1] - 2026-06-02 A small fix-and-polish release. No migration; no breaking changes. Adds an opt-in global read-only lock for the Settings page, fixes default-template auto-selection on the create-zone page for clustered / grouped primaries, and clarifies the backend picker's group label. See [Upgrading -> 1.4.1](./docs/09-UPGRADING.md#upgrading-to-141-from-140). ### Added - global Settings read-only lock (`SETTINGS_RO`) New opt-in `SETTINGS_RO` flag (default `false`). When `true`, the entire admin Settings page (site name, branding, login intro, support contact, lockout policy, password-reset toggle) is frozen for everyone: `PATCH /api/admin/settings` returns 403 even for holders of `settings.write`, and the form renders disabled with a notice. Intended for a public demo where visitors may hold a settings-capable role but must not reconfigure the install, without stripping the permission from the demo role. Enforced at the route via `assertSettingsMutable`; needs no companion variable. Mirrors `BOOTSTRAP_ADMIN_RO`. ### Fixed - default zone template no longer auto-selects for clustered / grouped primaries On the create-zone page, the default zone template stopped auto-selecting once its target primary joined a cluster or primary+secondary group. The form matched a template's `defaultForPrimaryIds` only against a standalone server's id; a grouped primary is surfaced as a single cluster backend that carried no id, so the match never happened. Backend options now expose the full set of writable primary ids they create zones through (the standalone primary's own id, or every cluster peer's id) and the matcher intersects that with the template's `defaultForPrimaryIds`. Also hardened first-boot provisioning: the `default_for_primary_slugs` -> id resolution now runs whenever the file defines zone templates, even when it omits the `pdns_servers` section, so a templates-only re-provision no longer leaves raw slugs that never match a backend id. ### Changed - clearer backend-picker label for primary+secondary groups The create-zone backend picker labelled every `pdns_clusters`-backed group as "N-peer cluster" using the writable-peer count, so a primary + secondary pair read as a misleading "1-peer cluster". It now reads by topology: "3-peer cluster" for a true multi-primary cluster, "primary + N secondaries" for a single primary with mirrors, and "primary" for a lone primary in a group. ### Docs Link to the project website and the live demo (with the demo login) from the README, and add the GitHub social image under `assets/`. ## [1.4.0] - 2026-06-01 Demo-hardening and dashboard polish: a lockable bootstrap admin for public demos, fixes to the per-backend PDNS statistics charts, a demo graph-data seed helper, and a repo-wide prose cleanup. No migration; no breaking changes. See [Upgrading -> 1.4.0](./docs/09-UPGRADING.md#upgrading-to-140-from-13x). ### Added - lockable bootstrap admin for public demos (BOOTSTRAP_ADMIN_RO) New opt-in `BOOTSTRAP_ADMIN_RO` flag (default `false`). When `true` it freezes the bootstrap admin account (the one matching `BOOTSTRAP_ADMIN_EMAIL`) against any change to its own identity or credentials: password, email, name, MFA / passkey enrolment, disable / delete, and role assignment all return 403. Intended for a publicly-hosted demo whose login is published, so a visitor signed in as the shared admin can neither hijack nor lock out the login. Everything else the account can do (zones, managing other users, ...) is unaffected - it is an identity lock, not a global read-only mode. Enforced at every relevant route via `assertBootstrapAdminMutable`; the profile and admin-user pages surface a read-only notice. When enabled, the boot seed creates the account with `must_change_password=false` (it can no longer change its own password, so the compliance gate must not trap it), and the flag requires `BOOTSTRAP_ADMIN_EMAIL` to be set (checked at boot). ### Added - demo graph-data seed helper (`npm run demo:seed:graphs`) `scripts/demo-seed-graphs.ts` backfills realistic time-series into `metric_samples`, `pdns_server_stats`, and `audit_log` so a freshly-booted demo stack shows full dashboard graphs instead of empty panels. It writes only rows the dashboard already reads (no app-code change), is re-runnable (clears its own prior rows; audit rows are tagged with a `demo-seed:` request-id), and is guarded behind `DEMO_SEED=1`. Demo / screenshots only - never production. ### Fixed - per-backend PDNS statistics charts The query-rate / latency / cache-hit cards on the dashboard's PowerDNS-stats tab no longer render a redundant series legend that overlapped the x-axis labels, the y-axis unit ("q/s" / "us" / "%") is no longer clipped at the top of the card, and the plot grid is tightened so the line uses the full card width. ### Changed - prose: em-dashes replaced with hyphens repo-wide Swept every em-dash (and the stray horizontal bar) out of comments, docs, and strings across the tree, replacing them with hyphens. Cosmetic only. ## [1.3.0] - 2026-05-28 A major feature pile: WebAuthn primary + 2FA, SAML 2.0 SP, LDAP direct-bind, teams zone grants, session-scoped IdP-derived permissions with live token recompute, the unified `/admin/authentication` admin surface, zone Import / Export, and a super-admin-gated app-DB Backup & Restore wizard. See [Upgrading → 1.3.0](./docs/09-UPGRADING.md#upgrading-to-130-from-12x) for operator actions. **Migration**: one new SQL file per dialect (`drizzle/0004_*.sql` and `drizzle-sqlite/0004_*.sql`). Runs at boot. ### Added - teams: per-zone grants (#75) `zone_grants` now supports a team principal alongside the existing user principal. A grant attached to a team flows through to every member via `team_members`; revoking the grant or removing a member from the team revokes access without surgery on per-user rows. Same admin surface as user grants (`/admin/teams/[id]` gets a Zone-grants section). Cross-type duplicate prevention via partial unique indexes; exactly-one principal enforced by a CHECK constraint. ### Added - session-scoped IdP-derived permissions + live token recompute (#85) IdP groups stop materialising into persistent `role_assignments` rows. At sign-in, the user's group claim is resolved to an `AbilitySource[]` snapshot via the new `computeGroupSync` and stored on `sessions.derived_permissions` (new JSONB column). Sessions naturally expire; stale grants for inactive users disappear with the session. Tokens follow current real permissions. At token use time, an OIDC or LDAP user's groups are re-fetched live (LDAP service-account search, OIDC refresh-token → userinfo) and materialised through the same `computeGroupSync`, cached per `IDP_PERMS_CACHE_TTL_SECONDS` (default 60s). Fallback path: when the live recompute fails (IdP unreachable, refresh rejected, SAML - which has no back-channel), the token uses the latest session's snapshot bounded by `TOKEN_IDP_FALLBACK_TTL_SECONDS` (default 24h). New audit action `auth.token.idp_perms_refreshed` - one row per cache window. ### Added - zone detail "Access" tab (#76) New "Access" tab on `/zones/[id]` (gated on `user.read`) listing every principal with access to the zone: roles that grant any zone-scope permission (dynamically derived from each role's permission list - system roles surface naturally, custom roles too if the operator gave them zone perms), teams with explicit `zone_grants` on this zone, and users with direct grants. ### Added - tabbed admin user-edit (#79) `/admin/users/[id]` matches `/profile`'s tab vocabulary (Account / Roles / Zone grants / Sessions / Two-factor / API tokens / Audit) instead of a long scroll. Tabs gated on the actor's capabilities. Self-edit server-redirects to `/profile` (the admin user-detail URL never enters history - Back returns to the users list cleanly). ### Added - app-DB backup export (#84) Super-admin-gated **Backup & Restore** wizard under `/admin/settings/backup` (no modal - every step renders inline with a back button). `GET /api/admin/backup/export` streams a JSON dump of the app DB; `POST /api/admin/backup/restore` does a merge-mode restore (`INSERT … ON CONFLICT DO NOTHING` in forward-FK order, one transaction, typed `RESTORE` confirmation phrase). Excludes PDNS zone data and symmetric secrets (`APP_SECRET_KEY` / `APP_ENCRYPTION_KEY`); encrypted columns ride as ciphertext - useless without the encryption key on the restore target. New `system.backup` permission, default-granted only to the seeded Super Admin role. Audit: `system.backup.exported` / `system.backup.restored` with per-table row counts. ### Added - zone Import / Export (#9) New **Import / Export** hub under **PowerDNS → Zones** (`/admin/import-export`, `zone.read` to view, `zone.create` to import): - **Import** - paste one or many zones in BIND format (or load from a file). A new RFC 1035 parser (`lib/dns/zonefile-parser.ts`) splits multi-zone input at `$ORIGIN` boundaries, handles `$TTL`, `@`, comments, and parenthesised multi-line SOA, refuses `$INCLUDE` (file-traversal vector), and skips DNSSEC types (PDNS manages those). Each zone becomes one `createZone` call with its rrsets pre-populated; the result is reported per-zone (created / failed + parse diagnostics). Audit: one `zone.create` row per imported zone (`source: zonefile-import`). - **Export** - pick a backend, multi-select zones, download a single BIND-format text bundle. The serializer (`lib/dns/zonefile.ts`, from the per-zone export route) emits idiomatic `$TTL` + `$ORIGIN`, owner names relativised against the origin (apex → `@`), and a parenthesised multi-line SOA; output round-trips through BIND / NSD / `pdnsutil load-zone`. Audit: one `zone.export` row per zone read. ### Changed - PowerDNS sidebar grouped into sub-sections The PowerDNS nav section is now chunked into **Backends** (Servers, Clusters, Autoprimaries), **Zones** (Zone templates, Import / Export), **Security** (TSIG keys), and **Activity** (Request log) so the growing list stays scannable. ### Changed - admin URL restructure + `oidc.*` → `auth.*` rename (#74) `/admin/oidc-providers` → `/admin/authentication/oidc`. Same shape for SAML and LDAP. Old URLs keep redirect stubs so external links survive. The CASL "Oidc" subject type became "Auth" and the `oidc.read` / `oidc.manage` permission strings became `auth.read` / `auth.manage` since the gates now cover three protocols at the unified surface. Existing role permission lists are auto-rewritten by the migration. ### Changed - profile tabs actually switch panels (#78) `/profile` tabs were rendering every panel and just scrolling. Tab identification swapped from a fragile component-function equality check to a `data-section-tab` marker attribute; visibility uses inline `style.display` instead of the `hidden` attribute (highest cascade specificity, no CSS-conflict surface). The component moved to `components/ui/section-tabs.tsx` and is reused by the new tabbed admin user-edit page. ### Changed - audit-vocabulary consolidation Three IdP-prefixed actions unified into protocol-neutral ones: | Old | New | | --------------------------------------------- | ------------------------------------ | | `auth.oidc.group_sync.assignment_added` | _removed_ | | `auth.oidc.group_sync.assignment_removed` | _removed_ | | `auth.oidc.group_sync.mapping_unresolved` | `auth.group_sync.mapping_unresolved` | | `auth.{oidc,saml}.linked` | `auth.idp.linked` | | `auth.{oidc,saml,ldap}.rejected_provisioning` | `auth.idp.rejected_provisioning` | Protocol context is preserved via `method` + `provider` fields in the audit row's `after` snapshot. ### Fixed - SSE badge no longer stuck on OFFLINE for permissionless users (#80) `/api/realtime` previously hard-403'd a user who couldn't read any zone. Their EventSource never opened; the chip reported "OFFLINE" forever. The stream is now opened unconditionally for any authenticated user - the per-event filters already gate what reaches them, so the connection is honest about its state. ### Fixed - zones-list scroll-in-scroll at high page sizes (#80) `
` was `flex-1 overflow-y-auto` without `min-h-0`. Under flexbox, a flex-1 child without `min-h-0` can grow past its parent's height when its content is taller, defeating `overflow-y-auto` and leaking a second outer scroll region. One-class fix. ### Added - DataTable pagination at top AND bottom (#80) Long lists no longer force operators to scroll all the way down just to flip a page or change the page size. Same controls render at the top and the bottom of every paginated table. ### Added - SAML 2.0 single sign-on - **`saml_providers` table** stores SAML SP configurations (ADR-0021). One row per IdP relationship: AD FS, Authentik SAML, Keycloak SAML, etc. Encrypted SP signing key + optional encryption key + the IdP's public signing cert. - **Admin UI**: `/admin/authentication/new` now offers SAML as an active card. Provider edit page at `/admin/saml-providers/` mirrors the OIDC equivalent - same pickers, same audit panel, same danger zone. - **Sign-in routes**: - `GET /api/auth/saml//login` - signed AuthnRequest + redirect to IdP. - `POST /api/auth/saml//acs` - Assertion Consumer Service; verifies signature, decrypts EncryptedAssertion if configured, applies group → role mappings, mints session. - `GET /api/auth/saml//metadata` - SP metadata XML (paste into IdP). - `GET /api/auth/saml//slo` - SP-initiated single logout. - **Secure defaults**: `wantAssertionsSigned: true`, `wantAuthnResponseSigned: true`, `signatureAlgorithm: "sha256"`, `validateInResponseTo: always`. Operators can relax per-provider via the form. - **Group → role mapping** reuses the OIDC materialiser - same shape, same `provider_id`-tagged `role_assignments` rows. - **Provisioning**: new `saml:` block in `provisioning.yaml`. See `provisioning.example.yaml` for a worked example. Slug is reserved in `auth_provider_slugs(provider_type='saml')` atomically with the row insert. - **Login dispatcher**: `auth_default_provider = "saml:"` now auto- redirects to the SAML initiate URL on a fresh visit. - Library: `@node-saml/node-saml@^5.1.0` (MIT, CVE-2025-54369 fixed). - Docs: new [`docs/13-SAML.md`](./docs/13-SAML.md) with worked AD FS, Authentik, and Keycloak setup. ADR-0021 captures the architecture. ### Added - LDAP authentication (ADR-0020) - Direct-bind sign-in against **Active Directory** and **OpenLDAP**. Operators configure providers under **Admin → Authentication** (the LDAP card on the "Add provider" picker is now live alongside OIDC and SAML). - Bind-then-search-then-rebind flow via the maintained TypeScript-first [`ldapts`](https://www.npmjs.com/package/ldapts) library. Strict TLS by default - plain `ldap://` is refused unless either StartTLS is enabled on the provider row OR `LDAP_ALLOW_INSECURE_PORT_389=true` is set. A new `LDAP_TLS_INSECURE_SKIP_VERIFY=true` env knob exists for lab use only; production deploys should pin the internal CA on the provider row instead. - Group → role mappings (global / team / zone / server scope) feed the shared `applyGroupSync` materialiser. AD's `memberOf` is read first; an optional second search (`group_search_base` + `group_search_filter` with a `{{userDn}}` placeholder) handles OpenLDAP installs without the `memberof` overlay. - New `POST /api/auth/ldap//login` route - same captcha + per-IP rate-limit pipeline as the local + OIDC paths. - New `ldap_providers` table (PG + SQLite); migrations `drizzle/0008_ldap_providers.sql` and `drizzle-sqlite/0008_ldap_providers.sql`. - Provisioning gains an `ldap:` block (worked AD + OpenLDAP examples in `provisioning.example.yaml`). A bare-slug `auth_default_provider` resolves to an LDAP provider through the existing `auth_provider_slugs` table. - New audit actions: `ldap.provider.created` / `.updated` / `.deleted` and `auth.ldap.rejected_provisioning`. `auth.login.success` after-state now carries `method: "ldap"` and `provider: ""` for sign-ins through this path. - Operator guide: [`docs/12-LDAP.md`](./docs/12-LDAP.md) (worked AD example with KB4520412 channel-binding note, OpenLDAP 2.6 example with `olcTLSCipherSuite` + memberof-overlay setup). ### Changed - admin sidebar restructure + URL alignment - **Sidebar "Infrastructure" section renamed to "PowerDNS"**, with shorter nav labels now that the section name carries the protocol context: - "PowerDNS servers" → "Servers" - "Groups" → "Clusters" (the underlying concept is a cluster of peers or a primary with its secondaries; "Groups" was a UI carry-over). - "Request log" moves up from the "System" section into "PowerDNS" - it's PDNS HTTP traffic, not platform audit. - **URL alignment**: two admin paths renamed to match the rest of the section (no `pdns-` prefix; the section already says PowerDNS): - `/admin/pdns-clusters` → `/admin/clusters` - `/admin/pdns-requests` → `/admin/requests` - The old paths redirect to the new ones so bookmarks and audit-log links keep working. - "System" now contains only Settings + Audit log. ### Added - globally-unique provider slugs - New `auth_provider_slugs` table acts as a cross-type reservation: every provider create transaction reserves its slug here first, and the table's PK enforces uniqueness across **every** authentication provider type (OIDC today; SAML + LDAP when PRs 2 + 3 of `feat/auth-providers-...` land). A SAML provider can't claim the same slug as an existing OIDC provider. Existing OIDC rows are backfilled by the migration in both dialects. - **Provisioning shorthand**: `auth_default_provider` in the YAML now accepts a bare provider slug (e.g. `auth_default_provider: "company-sso"`) alongside the existing `local` / `:` forms. The applier resolves a bare slug against `auth_provider_slugs` (including providers declared in the SAME file) and persists the canonical typed-prefix form. Unknown slugs log a warning and leave the previous value intact. ### Changed - unified authentication admin - **New `Admin → Authentication` page** consolidates the view of every sign-in method into one list. Local Auth appears as a synthetic row alongside every configured OIDC provider (and, when PR 2 + PR 3 of `feat/auth-providers-ldap-saml-webauthn` land, SAML and LDAP). The old `/admin/oidc-providers` index redirects here; per-provider edit pages (`/admin/oidc-providers/`, `/admin/oidc-providers/new`) keep their URLs. Sidebar nav renames from "OIDC providers" to "Authentication". - **Default sign-in method is now a single global setting** edited from the new page via a themed dropdown - replaces the per-OIDC-provider `force_default` checkbox. Stored as `settings.auth_default_provider` in the `local` / `oidc:` / `saml:` / `ldap:` format. Existing deployments are migrated automatically by the Drizzle migration in both dialects (most recently created enabled `force_default=true` wins). The `force_default` column is dropped. - **Provisioning compat**: `force_default: true` in YAML still parses; the applier translates it into `auth_default_provider` and logs a deprecation warning. Will be removed in a future minor. ### Changed - bounded retention on dashboard time-series tables (1:1 with display windows) - **The two time-series tables the zone-poller writes now prune to exactly the windows the dashboard reads.** `lib/metrics/dashboard-windows.ts` is the single source of truth - the dashboard graphs and the retention sweep both read from there, so changing a window in one place updates both. We keep nothing we don't display. - `metric_samples` - 7 days (`backendSeries()` + `sessionsSeries()`). - `pdns_server_stats` - 2 hours (per-backend metric widget). - `readRecentMetrics()` is now time-bounded (takes a `since: Date`) instead of the previous count-bounded shape - the count was an implicit 2h window at the 60s sampling cadence, and turning it explicit lets retention link to the same window cleanly. - Throttled to one pair of DELETEs per 5 minutes so the sampler's 60-second cadence doesn't churn the WAL. Best-effort: a failed prune logs and the write path continues. See `lib/metrics/retention.ts`. - Before this, both tables grew without bound. The dashboard's queries scanned ever-larger result sets even though every row past the window was discarded. On stacks with long uptime + many backends, this was the largest contributor to the SQLite/Postgres data volume. ### Added - auth providers (Phase 1 of `feat/auth-providers-ldap-saml-webauthn`) - **WebAuthn / passkeys** - sign in with Touch ID, Windows Hello, Android screen-lock, hardware security keys (YubiKey etc.) or cross-device passkeys (1Password, Bitwarden, iCloud Keychain). Two flows: - **Primary credential** - "Sign in with passkey" button on `/login` skips the password entirely (discoverable-credential flow). - **Second factor** - alongside TOTP. The MFA-required gate (per-role `requires_mfa`, per-user override) is now satisfied by EITHER a TOTP enrollment OR any WebAuthn credential. - Per-credential enrolment + remove + rename under `Profile → Two-factor → Passkeys & security keys`. - Selective admin reset by credential id (target-privilege ceiling enforced like the TOTP reset). - RP ID derived from `APP_URL` hostname; override via `WEBAUTHN_RP_ID` for apex/sub-domain credential sharing. - Strict-by-default origins; LAN-dev opt-out via `WEBAUTHN_ALLOW_INSECURE_ORIGINS=true`. - New docs page: [`docs/11-PASSKEYS.md`](./docs/11-PASSKEYS.md). - **OIDC logout hardening** - fixes the "sign out lands me back signed in" bug operators hit with IdPs that don't advertise `end_session_endpoint`: - `/api/auth/logout` sets a 60-second `pda_just_logged_out` cookie that suppresses `force_default` OIDC auto-redirect on the next `/login` render, so the IdP's still-valid session can't silently re-auth. - The OIDC discovery probe now reports whether the IdP advertised an end-session endpoint; the admin OIDC providers list shows a yellow "no end-session" warning chip when it's missing, with IdP-specific fix guidance in `docs/05-OIDC.md`. - **Architecture decision records** for the road map: - ADR-0018: provider abstraction - keep OIDC where it is, layer SAML + LDAP as siblings. - ADR-0019: WebAuthn - both primary credential and second factor. - ADR-0020 (proposed): LDAP architecture - TLS-strict default, `ldapts@^8`, bind-then-search, AD + OpenLDAP doc examples (lands in PR 2 of the feature branch). - ADR-0021 (proposed): SAML 2.0 SP - signed assertions required by default, `@node-saml/node-saml@^5.1.0` (CVE-2025-54369 fixed), AD FS / Authentik / Keycloak doc examples (lands in PR 3). ### Added - **Login: inline APP_URL mismatch banner.** Detects when the request host doesn't match `env.APP_URL` (the classic "copy-pasted `http://localhost:3000` but I'm browsing the LAN host" foot-gun) and surfaces it on the sign-in page with the actual + expected origin, so operators don't have to crack open DevTools to find out why their session cookie was silently rejected. Helper is unit-tested (`lib/auth/app-url-check.ts`). - **Compliance guard now covers MFA enforcement, not just must-change-password.** The shake-banner-on-blocked-nav behaviour is reused for forced TOTP enrolment; `requireUserForPage` re-checks both gates on every soft navigation (mirrors what was already there for password change). - **Roles list: description column** + the description renders on both system and custom-role detail pages. Wraps cleanly, never truncates. - **METRICS_TOKEN auto-generation.** When `/metrics` is enabled but no token is pinned in env, the app generates a random 32-char bearer on boot and logs it once - keeps the endpoint from being accidentally open on a shared LAN. ### Changed - **Force-MFA + OIDC users.** SSO-only accounts (no local password) are now always exempt from MFA enforcement: the IdP is the second-factor authority and the in-app TOTP enroll flow is read-only for them. The admin user-detail page hides the per-user MFA override for SSO accounts, and the PATCH endpoint rejects setting `mfaRequired=true` on them. `checkMfaCompliance` defends against legacy rows that already carried that flag. - **Installation docs** split into two paths (Docker / from-source) with a systemd unit + tested nginx and HAProxy reverse-proxy examples that get the `X-Forwarded-*` headers right for the APP_URL mismatch detector. - **APP_URL guidance** elevated to its own install step + `.env.example` comment, with the cookie-domain reasoning spelled out (operators copy-pasting `http://localhost:3000` from the example hit a silent cookie rejection). ### Fixed - **Login page "preload was not used" warnings.** The wordmark rendered both light + dark PNGs with Next.js `priority`, emitting two `` tags while CSS hid one - browsers warned every page load. Dropped `priority`; the visible image still loads eagerly above the fold. ## [1.2.1] - 2026-05-27 A **build-pipeline patch** - significant image-size reduction with no operator-facing behaviour change. The published image goes from **~1.18 GB local / ~225 MB compressed pull** to **~290 MB local / ~80 MB compressed pull** (about a 75% local / 65% compressed cut). Drop-in upgrade - no schema, API, or operator-config changes. ### Changed - build pipeline - **Boot scripts pre-bundled at image-build time.** `scripts/migrate.ts`, `scripts/seed.ts`, and `scripts/provision.ts` are now built into self-contained ESM files under `boot/` via `npm run build:boot` (esbuild). The runner runs them directly with `node`, so the runtime image no longer needs: - `tsx` (the on-the-fly TS transpiler the previous entrypoint shelled out to), - the full `lib/` and `scripts/` source trees, - `tsconfig.json`, or - the separate prod-deps `node_modules` overlay (the previous `deps` stage - ~700 MB on disk used solely to make boot succeed). - **Dockerfile: `deps` stage removed; runner switched to distroless.** The build now goes builder → fs-prep → runner, where the final runner is `gcr.io/distroless/nodejs24-debian12:nonroot`. Boot externals (`better-sqlite3`, `@node-rs/argon2`, `pg`, `pino` + transports) resolve at runtime from the standalone bundle's already-traced `node_modules` - Next's image-tracer has been ensuring those are present all along; the dedicated `deps` overlay was redundant. - **Next.js trace exclusion for `@img/sharp*`** combined with `images: { unoptimized: true }` in `next.config.ts`. Sharp was a ~16 MB optionalDep used only by Next's built-in image optimizer; with the optimizer off (the wordmark PNGs in `/public` are tiny pre-sized assets, and brand-logo uploads render via a plain ``) it never runs and can be left out of the runner entirely. - **Native-binary debug symbols stripped** during the build. Sub-MB but free; the binaries `dlopen` identically. ### Operator-facing trade-offs - **No shell in the runtime image.** `docker exec sh` is not available anymore - the distroless base ships only the `node` binary, glibc, openssl, and ca-certificates. For incident triage that needs a shell, build a `:debug` tag against bookworm-slim using the same builder stage. Day-to-day operations (logs, healthz/readyz probes, env reload, image upgrades) are unaffected. - **Container user is now `nonroot` (uid 65532)** instead of `node` (uid 1000). If you'd hand-set ownership on a host-mounted `/data` volume to uid 1000 prior, re-chown it to 65532 before the first upgraded boot. The compose files in this repo's docker-compose-\*.yml examples don't pin a uid and need no change. - **Next.js built-in image optimizer is disabled.** `` tags still render - they just serve the file at its intrinsic size, no resize or format conversion at the edge. No PowerDNS-AuthAdmin page relied on the optimizer (our images are static brand assets); this is only a difference for downstream customisation that adds dynamic image processing via `next/image`. ### Changed - image tags `:latest` now follows releases, not `main`. | Tag | Points to | | ---------------- | --------------------------------------- | | `:latest` | most recent release (`vX.Y.Z` tag push) | | `:X.Y.Z`, `:X.Y` | that release + its minor channel | | `:edge` | tip of `main` (every push) | | `:sha-xxxxxxx` | exact commit, immutable | Operators following `:latest` will jump to `1.2.1` on next pull and then stay there until the next release tag. Use `:edge` to track `main`. ### Unchanged - Same Next.js standalone server, same migration SQL files, same entrypoint flow (migrate → seed → provision → server). - `/healthz` + `/readyz` semantics unchanged. - All API, auth, RBAC, OIDC, signup, audit, and PDNS-backend behaviour identical. - Cosign signing + SBOM attachment unchanged. ### Upgrading Pull the new image and recreate the container - that's the whole upgrade. See [Upgrading → 1.2.1](./docs/09-UPGRADING.md#upgrading-to-121-from-12x) for the no-shell / non-root caveats. ## [1.2.0] - 2026-05-26 A **minor release** that combines two closely-related changes: 1. **Standalone-PDNS write-capability fix (#57).** A daemon with the default `primary=no, secondary=no` in `pdns.conf` was incorrectly hidden from `/zones/new`'s backend picker. 2. **`PDNS_BACKGROUND_POLLING` opt-in flag.** AuthAdmin no longer maintains a background ticker against PDNS unless the operator explicitly opts in. The supplementary "replication-awareness" surfaces (sync chip, zone Sync + Statistics tabs, servers Sync column, dashboard PDNS metrics, drift advisories) are gated on this flag. > **NOTE - behaviour change on upgrade.** `PDNS_BACKGROUND_POLLING` > defaults to `false`. Existing 1.1.x deployments that rely on the > sync chip, dashboard PDNS metrics, per-zone Sync tab, or drift > advisories **MUST NOW ENABLE** `PDNS_BACKGROUND_POLLING=true` in > their environment and restart the app to keep those features. See > [Upgrading → 1.2.0](./docs/09-UPGRADING.md#upgrading-to-120-from-11x) > for the use-case guidance. Closes #57; reported by @insxa in [discussion #27](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/discussions/27). ### Added - **`PDNS_BACKGROUND_POLLING` env var (default `false`).** Single opt-in switch for the replication-awareness layer. When off, every PDNS interaction is a direct consequence of an operator action - no background traffic. When on, the unified poller runs on its 30 s / 60 s / 5 min cadences and powers the SYNCED/DESYNCED chip, per-zone Sync + Statistics tabs, servers-list Sync column, zones-list mirror column, dashboard PowerDNS-metrics tab, and the drift-derived advisories in the bell. (#57) - **`(i)` polling-mode hint** on every polling-gated page heading (`/dashboard`, `/admin/servers`, `/zones`, `/zones/`) when polling is off - hover tooltip explains the current state and links to the live CONFIGURATION doc. One small icon, consistent across the app, so an operator can flip the flag deliberately from wherever they are when they notice a sync-aware feature is missing. - **`flash=polling-required` error toast** when an operator follows a direct URL to a gated feature (`/dashboard?tab=pdns`, `/zones/?tab=sync`, `/zones/?tab=statistics`) on a polling-off install - the page redirects to the default view and surfaces a red error toast naming the env var. - **Boot-time log line** at the first `/healthz` hit summarising the effective polling mode plus a sharp warning when the configured fleet has replication topology but the flag is off (mirrors / multiple primaries / clusters). Hard 3 s budget - never blocks startup. ### Changed - **`isWriteCapable` is now `caps ? !isReadOnlyMirror(caps) : true`.** The predicate flipped from gating on the AXFR-primary flag to gating on the explicit observation of a read-only mirror - so standalone (`primary=no, secondary=no`), explicit primary, and dual-role primary+secondary all correctly count as writable. Only a pure secondary mirror is excluded from `/zones/new`'s picker. (#57) - **Header sync chip gates on actual replication topology AND the poller flag.** `hasReplicationTopology()` was added in this cycle; the chip only enters SYNCED/DESYNCED mode when both a ≥2-peer cluster (derived primary+secondaries OR configured multi-primary) AND `PDNS_BACKGROUND_POLLING=true` are present. Standalone / single-primary / polling-off fleets see plain "Live". (#57) - **Capability badge `none` → `standalone`.** The neutral badge for a daemon with no replication flags now reads `standalone`, matching the semantic ("hosts zones over the API; no DNS-protocol replication") and removing the alarming "none" label. Same neutral tone. `summarizeCapabilities()`'s fallback follows suit (`api` → `standalone`); `api: no` → `unreachable`. - **Dashboard tab strip hides** when polling is off - the "Admin" view becomes the default (and only) tab. The PDNS-metrics tab body redirects with the flash toast on direct URL. - **Zone-detail Sync + Statistics tabs hide** when polling is off. Direct ?tab=sync / ?tab=statistics URLs redirect to the records tab with the flash toast. - **Servers-list Sync column hides** when polling is off (the page's realtime sync subscriber stays unmounted; row reachability still updates on every operator-initiated probe). - **Zones-list mirror column hides** when polling is off; default sort collapses to Name asc instead of Sync-desc then Name. ### Fixed - Standalone-PDNS daemons no longer rendered as `none` / not-a-write-target on `/admin/servers` or hidden from `/zones/new`. (#57) - `scheduleImmediatePoll` and the in-flight `scheduleFollowupPoll` are no-ops when polling is off; mutations still publish their own SSE refresh and call `invalidateBackendObservation`, so the next page render warms what it needs via `ensureBackendsObserved`. ### Tests - Four-way table test for `isWriteCapable` × `isReadOnlyMirror` across the standalone / primary / secondary / dual-role flag matrix. - `unprobed (null)` defaults to write-capable so a freshly-added backend stays usable until its first probe. - Polling-flag tests pin `ensurePollerRunning` to no `setInterval`, and `scheduleImmediatePoll` to no `setTimeout`, when the flag is off. - **`decideHeaderChipMode` pure helper** (extracted from `app/(app)/layout.tsx`) is unit-tested across all five gating inputs (polling enabled, realtime available, can-read-backends, has-topology, lagging) - every false gate falls back to plain "Live"; only the full happy path enters sync mode. - **`describeFlash` for `polling-required`** is unit-tested to produce a red error toast naming the env var verbatim (so operators can grep for it), with and without the `need=` parameter. - **`logPollingModeOnce` startup log** is unit-tested across three branches (flag on info; flag off + standalone info; flag off + topology warn) plus the 3 s probe budget timing out gracefully and the one-shot guard against re-firing. - Integration suite pinned to `PDNS_BACKGROUND_POLLING=true` so the replication-aware code paths stay exercised end-to-end. ## [1.1.5] - 2026-05-26 A **security-hygiene patch**. No app-code changes; ships only a defensive dependency pin to neutralise the **Mini Shai-Hulud** npm supply-chain campaign (MAL-2026-4153) at the resolver level. See [GHSA-…-…-…](https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/security/advisories) for the full advisory. ### Security - **Defensive pin: `size-sensor` → `1.0.3` via package.json `overrides`.** The npm package `size-sensor` (an indirect dependency through `echarts-for-react`) was hijacked on 2026-05-19 - versions `1.0.4`, `1.1.4`, and `1.2.4` were published by an attacker who took over the `atool` npm account and contain a `preinstall` hook that runs an obfuscated Bun script exfiltrating secrets to GitHub via `harkonnen-melange-*` repositories (Mini Shai-Hulud / TeamPCP). Tracked as [MAL-2026-4153](https://osv.dev/vulnerability/MAL-2026-4153) / [GHSA-gx6x-v325-85g4](https://github.com/advisories/GHSA-gx6x-v325-85g4). **PowerDNS-AuthAdmin was never affected** - every `1.1.x` release shipped `size-sensor@1.0.3`, the last clean version (published before the takeover). `npm audit` was clean throughout. This release adds an explicit `"size-sensor": "1.0.3"` to `package.json` `overrides` so that no future `npm install --save <…>` can let the resolver pick up `1.0.4`+ from a freshly-resolved subtree. OpenSSF Scorecard's `Vulnerabilities` check, which flags any version inside the OSV advisory's overly-broad SEMVER range, should clear on its next refresh. ## [1.1.4] - 2026-05-26 A **major operator-UX release**: top-to-bottom responsive overhaul, every table unified onto one mobile-friendly recipe, live fleet-wide sync state in the header chrome, a new animated sync indicator, full screenshots gallery regen, plus the security and supply-chain hardening previously parked in `[Unreleased]`. No schema or API breaking changes - drop-in upgrade. ### Added - operator UX - **Mobile-first responsive shell.** Off-canvas hamburger drawer under `md`, full sidebar from `md+`. The drawer closes on backdrop tap, Esc, and route changes (no in-drawer close button needed). The top bar reflows so every control (hamburger, status chip, bell, theme, avatar) is reachable on a 320 px viewport. (#51, #52) - **Live header status chip with fleet-wide sync verdict.** Single pill in the top bar shows `CONNECTING / CONNECTED / OFFLINE / PAUSED` (SSE connection state) plus a trailing `· SYNCED / · DESYNCED` whenever the page has a notion of sync state. The off-the-shelf default is the fleet-wide `globalAnyLagging()` verdict computed from the in-process zone-state cache (no extra PDNS hit). Per-page `` overrides on zone detail, the zones list, and the servers page. A 5 s grace prevents "OFFLINE" flashes on Ctrl-R. (#52) - **Animated SyncIndicator.** New concentric-ring SVG used everywhere a sync verdict is displayed (header chip, zones list per-row chip, servers table). **Synced** = solid centre + two outward-pulsing rings via staggered CSS keyframes (radar/sonar ping). **Desynced** = hollow centre + two dashed concentric rings that counter-rotate via `stroke-dashoffset` so the icon reads as _actively trying to sync_, not a frozen error. Honours `prefers-reduced-motion`. (#52) - **One DataTable everywhere.** The audit log, profile sessions, autoprimaries, OIDC providers, TSIG keys (read-only + manage), dashboard backends + recent activity, role-assignments panel, team-members panel, zone change-history, and the zone-templates / servers / users / roles / teams lists now all use the same `` recipe - `bg-bg-muted` thead, `even:bg-bg-subtle` body stripes, accent-tinted hover wash, mobile auto-reflow to labelled cards. No more bespoke `` markup outside dialog-internal review panels. (#52) - **Diff-before-apply for record edits.** The per-RRset editor now insists on a **Review changes** modal between Save and the PATCH; the diff is BIND-style before/after, validation errors are gated behind an explicit _Save anyway_ checkbox so overrides are intentional and audited verbatim. (#52) - **One-button theme toggle.** Was three buttons (sun / monitor / moon). Now one button whose icon mirrors the active preference and cycles `light → dark → system → light` on click. Pre-hydration `.dark` class unchanged - no flash of wrong theme. (#52) - **Capability badges + clickable rows.** Per-backend badges (`CLUSTER`, `DEFAULT`, `PRIMARY`, `READ-ONLY MIRROR`) standardised across every list. Rows + mobile cards are now click-to-detail; embedded links/buttons (Edit, Delete, Test) intercept so per-row actions still work. (#52) - **Compliance hard-stops on every navigation.** Operators with `must_change_password = true` or unmet MFA-per-role requirements are pinned to `/profile` (or an allow-list of self-service routes) on every page nav, not just the initial render. The header status chip is suppressed in that state because the SSE endpoint would 403 anyway. (#52) - **Mobile zone-tabs no longer truncate, change history reflows.** Tabs `flex-wrap` on `< sm` so "Change history" (the widest label) never falls off-screen. New `` auto-scrolls to the tab strip when `?tab=` is set in the URL. Zone change-log gets a mobile-card layout alongside the desktop table - same expand-on-click pattern, but no clipped Resource/Actor columns on a 360 px viewport. (#52) - **CTAs no longer wrap their labels.** `whitespace-nowrap` on the shared green "+ Add" button class - fixes the "+ Add role" rendering as "+ Add" / "role" on narrow flex rows. (#52) - **PDNS request log documented.** The per-call HTTP audit surface at `/admin/pdns-requests` was undocumented despite shipping since 1.1.0. Filters by server / op / status / `requestId` / time range; every row expands inline to the request + response detail; cross-pivots to / from the audit log via shared `requestId`. ([FEATURES § 3.7](./docs/FEATURES.md#37-pdns-request-log)) - **Backend health bell documented.** The alert bell + popover that surfaces active advisories (unreachable hosts, API-key rejections, replication drift, missing TSIG keys, mirror zones without `masters`, daemon-config drift between peers) is now an explicit feature in the catalog. ([FEATURES § 3.8](./docs/FEATURES.md#38-backend-health-advisories) · [ADR-0015](./docs/adr/0015-backend-health-advisories.md)) ### Added - documentation - **Visual gallery regen.** Every page is captured at four parities - desktop+light, desktop+dark, mobile+light, mobile+dark - and rendered with `` so they auto-switch to match the reader's theme. Mobile shots are wrapped in a CSS-rendered iPhone 16 Pro bezel (status bar with Dynamic Island sits above the page; Action Button left, Camera Control right). See [`screenshots/`](./screenshots/README.md). (#53) - **`scripts/screenshots.mjs`.** Playwright-driven regen tool. Per-page `prepare(page)` hooks for surfaces that need a click or two (zone-edit, zone-edit-diff, zone-change-history, backend-health). Comma-separated CLI page filter or `PAGES_FILTER` env. Optional `pngquant + oxipng` post-pass shrinks the gallery by ~70 % when both binaries are on PATH. Documented in [`docs/dev-setup.md`](./docs/dev-setup.md#regenerating-screenshots). (#53) - **`docs/FEATURES.md` § 19 - Operator UX & responsive design.** Eight sub-sections covering everything new in this release with module pointers. Cross-linked from the screenshots gallery. (#53) - **Root README mobile-first showcase.** Three iPhone-framed mobile shots (dashboard, zone detail, audit log) below the desktop grid + a link to the full gallery. (#53) - **Inline screenshot embeds** at every feature section in `docs/FEATURES.md` and hero shots in `docs/01-QUICKSTART.md`, `docs/04-BACKENDS.md`, `docs/05-OIDC.md`, `docs/07-RBAC.md`. (#53) ### Security - **Patched dev/build dependency CVEs** via `overrides` - the deprecated `@esbuild-kit` chain is forced onto `esbuild@0.25.12` (dev-server CORS) and `next`'s pinned `postcss` up to `8.5.15` (`` XSS). `npm audit` is clean. (OpenSSF Scorecard: Vulnerabilities.) (#47) ### Added - supply-chain - **Property-based fuzz tests** (`fast-check`) for the DNS parsers - TXT presentation, DynDNS request/auth, and every RR-type content validator - running in the unit suite as `*.fuzz.test.ts`. Hardens the hand-rolled parsers (which have shipped real bugs) and satisfies the OpenSSF Scorecard Fuzzing check. (#48) - **Signed releases + image provenance.** A `release-sign` workflow (on release publish) cosign-signs the published multi-arch image (keyless / Sigstore) and attaches an SPDX SBOM plus a signed checksums bundle (`*.sigstore.json`) to the GitHub release. Verify the image with `cosign verify ghcr.io/powerdns-authadmin/powerdns-authadmin:1.1.4` (see [Hardening → verifying the image](./docs/08-HARDENING.md)). (OpenSSF Scorecard: Signed-Releases.) (#49) ### Fixed - **Stale CODEOWNERS path** (`middleware.ts` → `proxy.ts`). (#50) ## [1.1.3] - 2026-05-26 ### Fixed - **Per-zone grants now work on multi-primary clusters.** A `zone_grant` is keyed to one backend, but a cluster zone's reads/writes resolve a rotating peer (`choosePeer`), so a grant issued on one peer intermittently returned 403 when another peer was chosen. Grants are now expanded across cluster peers on the authorization path, so a grant on any peer authorizes the zone on every peer of that cluster. (#40) ### Changed - **`middleware.ts` → `proxy.ts`.** Adopted the Next 16 `proxy` file convention (the `middleware` convention is deprecated); the per-request CSP nonce + security headers are unchanged. (#41) - **CI GitHub Actions re-pinned to Node 24-compatible releases** (still pinned by commit SHA), ahead of GitHub's deprecation of the Node 20 action runtime. (#44) ### Documentation - **Installation guide rewritten** to four bulletproof, copy-paste steps (pick a database → create `.env` → write `docker-compose.yml` → start), plus a docs-wide accuracy sweep (bootstrap-admin semantics, lockout default, metrics route/default, provisioning order, dev-setup flow) verified against the code. - **`act` documented as the pre-push local-CI standard** (a committed `.actrc` pins the runner image); it runs the JS-action jobs locally, while CodeQL / Docker / Scorecard remain on GitHub CI. - README: added a GHCR pulls badge and a "PowerDNS Auth tested versions" header over the compatibility badges. ## [1.1.2] - 2026-05-25 ### Security Findings from an internal security audit. Distinct advisories are tracked privately as GHSA records; the fixes are summarized here. - **MFA-enrollment and forced-password-change gates now enforced on API routes, not just page loads.** `requireUser` (the shared route guard) now refuses a **session** whose role requires MFA but hasn't enrolled, or that is flagged `mustChangePassword`, with the self-remediation endpoints (TOTP enrollment, change password, logout) explicitly exempt. Previously these gates lived only in the page layout, so a non-compliant user - or anyone holding their session - could call the JSON write APIs directly and bypass them. - **Privilege-escalation ceilings closed on three admin paths.** Creating a user with an initial role now applies the same "can't grant permissions you don't hold globally" ceiling the role-assignment route already enforced (it previously didn't, allowing a non-Super-Admin to mint a global Super Admin). Resetting another user's password and removing another user's MFA now refuse to target a user who holds global permissions the actor lacks (previously a `user.reset-password` holder could take over a Super Admin account). - **OIDC outbound requests are now IP-pinned against DNS rebinding.** Discovery, JWKS, and the token-exchange POST (which carries the client secret) now connect only to the address the SSRF guard validated - closing the TOCTOU window the PDNS client already guarded. The background discovery sampler also runs the SSRF guard before probing. The pinning logic is shared via a new `lib/net/pinned-fetch` module. - **Defense-in-depth hardening:** the audit-log redaction backstop now also catches `*Encrypted` / `oidcIdToken` columns; the `serverId` PDNS path segment is URL-encoded; client-IP parsing uses strict `isIP`; `APP_ENCRYPTION_KEY` byte-length is validated at boot (not at first use); the SSE per-user connection counter no longer leaks a slot on pre-start abort; and PDNS error bodies are redacted before being surfaced. ### Fixed - **Self-service-signup email verification is now redeemable.** The verification link worked only for an already-signed-in user, but a freshly-signed-up local account is blocked from signing in until verified - a deadlock. Verification is now an unauthenticated, token-only flow (the signed token proves ownership), and the verify page renders for logged-out users. - **Audit writes made atomic with their mutation** on the PowerDNS-server create/update routes (the audit row was written outside the mutation's transaction), and on OIDC group-sync role changes. - **SQLite dashboard "events per hour"** buckets are now computed in UTC, matching the Postgres path (they previously skewed by the server's local timezone offset). - Minor: per-team member counts filter in SQL rather than in memory; a failed audit insert after an already-applied zone edit no longer returns a 500. ## [1.1.1] - 2026-05-25 ### Security Coordinated batch resolving six advisories (GHSA-gjg4-58c5-2qg3, GHSA-wf29-rmhc-rqc9, GHSA-24hf-rxww-95cf, GHSA-phv2-wjmm-pqqq, GHSA-frpq-xgm7-574x, GHSA-86v6-w5p9-29r8). - **Zone-grant route now enforces the permission ceiling (GHSA-gjg4-58c5-2qg3, high).** The per-user zone-grant route assigned a role's permissions without checking them against the granting admin's own authority, so an admin could grant - through a zone scope - permissions they didn't hold globally (privilege escalation). It now applies the same `permissionsExceedingGrant` ceiling as role assignment. - **OIDC group→role mappings now enforce the permission ceiling (GHSA-wf29-rmhc-rqc9, high).** An `oidc.manage` holder could map an IdP group to a role granting permissions they lacked, then escalate by signing in through that group. Mappings are rejected at save time unless every mapped role is within the actor's global permission ceiling. - **OIDC `requireEmailVerified` default changed to `true` (GHSA-24hf-rxww-95cf, high).** The `createOidcProviderSchema` previously defaulted `requireEmailVerified` to `false`, shipping new DB-configured OIDC providers with the account-takeover guard disabled. The default is now `true`, matching the documented intent and the env-provider behaviour. **Existing DB rows keep their stored value** - operators should audit any provider where `requireEmailVerified` is `false` and confirm the IdP does not emit the `email_verified` claim before retaining that setting. - **AES-GCM authentication-tag length enforced on decrypt (GHSA-phv2-wjmm-pqqq, medium).** `decrypt()` accepted a truncated GCM auth tag (Node permits tags ≥ 4 bytes by default), silently downgrading integrity strength. It now requires the standard 12-byte IV and 16-byte tag and passes `authTagLength` as defence-in-depth. - **Failed-login counter increment made atomic (GHSA-frpq-xgm7-574x, medium).** The lockout counter used a read-modify-write, so concurrent failed logins could lose increments and exceed the lockout threshold. The increment is now a single atomic `failed_login_count = failed_login_count + 1 … RETURNING` statement. - **Last-Super-Admin guard hardened (GHSA-86v6-w5p9-29r8, medium).** The guard counted raw assignment rows (including disabled users and duplicate rows), so the last _usable_ global Super Admin could be disabled or deleted - locking the install out of its own administration. It now counts distinct **enabled** users and also covers the user disable + delete routes (previously only assignment removal was guarded). - **Content-Security-Policy `script-src` tightened.** Removed `'self'` and the Cloudflare Turnstile host from `script-src`; the directive is now the per-request nonce plus `strict-dynamic` (with `'unsafe-eval'` only in dev), so an injected inline or remote script can no longer execute by virtue of same-origin or a hard-coded allow-listed host. - **DNS-rebinding hardening on outbound PowerDNS requests.** The reachability guard validated the backend host, but the follow-up HTTP request re-resolved DNS - a TOCTOU window a rebinding record could exploit to reach a blocked address. The guard-validated IP is now pinned into the request dispatcher, so the connection targets the address that actually passed the guard. - **Supply-chain & scanning hardening.** Every third-party GitHub Action is pinned to a full commit SHA, and the container base image to a `sha256` digest; CodeQL runs the `security-and-quality` query suite; and CodeQL, dependency-review, and OpenSSF Scorecard now gate pushes/PRs. Also resolved three `js/incomplete-sanitization` findings - a literal backslash is now escaped before the following metacharacter when building SVCB/HTTPS and SOA-mailbox rdata. ### Added - **Self-service signup.** Optional `SIGNUP_ENABLED` exposes a `/signup` page and API (both 404 when off, the default). New accounts receive the low-privilege `SIGNUP_DEFAULT_ROLE` - a boot-time guard refuses an admin-equivalent role - with an optional `SIGNUP_ALLOWED_EMAIL_DOMAINS` allow-list and SMTP-backed email verification. See [Configuration](./docs/03-CONFIGURATION.md). - **Inline SVG brand logo.** The settings brand logo now accepts an inline `data:` SVG URI in addition to an `https://` URL; inline SVG is sanitized server-side (DOMPurify) before it is stored or rendered. - **Build provenance in the version chip.** Non-release / local builds show the short commit SHA in the sidebar version chip, so a running build is unambiguously identifiable. ### Fixed - **Dashboard active-session count.** The "active sessions" KPI was sampled in a way that always reported 0; the sampler now counts live sessions correctly. - **DNS record validators.** Corrected the SRV port-range bound, accept the all-zeroes IPv6 group (`::`) in AAAA, reject an unbalanced quote in CAA, and fix the TXT bare-text escape order (RFC 1035 § 5.1). - **SQLite write path.** Writes and their audit row now commit in a single real transaction (atomic), and the `backend_advisories` `first_seen_at` / `last_seen_at` defaults match the Postgres schema. - **Cluster routing.** Probe/failure latencies no longer skew peer selection, and the round-robin index is shared across the process so rotation stays even. - **Redis event-bus handler.** The cross-replica SSE message handler is registered exactly once, so an event is no longer delivered multiple times when `REDIS_URL` is configured. - **SelectMenu drop-up.** The themed select menu flips upward when it would otherwise open past the bottom of the viewport. ### Documentation - **Installation: persist secrets in `.env`.** The setup used shell `export`s for `APP_SECRET_KEY` / `APP_ENCRYPTION_KEY`, which silently change on the next shell and guarantee a lockout. It now writes them once into a Compose-loaded `.env`, with explicit `down` vs `down -v` guidance. ## [1.1.0] - 2026-05-24 ### Security - **OIDC issuer SSRF guard.** The operator-supplied OIDC issuer/discovery URL is fetched server-side (provider test + live discovery), so it now runs through the same outbound-URL guard as PowerDNS backends. By default in production it refuses an issuer that resolves to a private-network address or uses `http://`; link-local / cloud-metadata (`169.254.0.0/16`) is always blocked. Two new opt-in flags relax it for an internal IdP: `APP_OIDC_ALLOW_PRIVATE_NETWORKS` and `APP_OIDC_ALLOW_INSECURE_HTTP`. - **Role-assignment permission ceiling.** Granting a role now refuses to assign permissions the acting admin doesn't themselves hold globally - you can no longer mint a role assignment that exceeds your own authority. A last-Super-Admin guard also blocks removing the final global Super-Admin assignment, so an install can't be locked out of its own administration. ### Added - **Optional Redis for horizontal scale (replicas > 1).** Setting `REDIS_URL` makes auth rate limiting, one-time reveal tokens, and the realtime SSE event-bus coordinate across replicas (sessions were already shared via Postgres). Each falls back to its in-process path when Redis is unset or a command fails, so single-node deployments need no Redis and a Redis blip degrades coordination rather than causing an outage. Ships a `docker-compose.ha.yml` example and a README High-availability section. See [ADR-0016](./docs/adr/0016-redis-horizontal-scale.md). - **Return-to-intended-page after sign-in.** Hitting a deep link (e.g. `/zones`) while signed out now sends you back to that page after login - including through the OIDC round-trip - instead of always dumping you on the dashboard. The redirect target is validated to be a same-origin relative path. - **Add servers while creating a group.** The "new group" form now has a themed, multi-select list of ungrouped backends so you can add members at creation time (assigned atomically, audited per server); the Groups page is a list view. - **Hidden-zone warning on the zones list.** When the same zone name is served by a backend that isn't shown, a banner above the ALL / FORWARD / REVERSE filter surfaces the count and the distinct hidden backends. It fires only for cases an operator should notice - standalone secondaries mirroring an unmanaged primary, or the same name on a second primary - and stays silent for a primary's secondaries whether they're **grouped or auto-derived** (matched to their managed primary by `masters[]`, exactly as the servers page nests them), since that's normal replication. - **Read-only secondary backends** - secondaries can now be added **without** an app-managed primary (unpinned mirrors of an external/upstream primary), and their otherwise-invisible zones appear in the amalgamated zone list (deduped: only zones no primary already serves), badged "read-only". The zone detail renders records + DNSSEC read-only for a secondary while leaving the legitimately-writable replication config (the zone's `masters`, transfer metadata, and removing the mirror) editable. A server-side guard backstops this for the API/token surface: zone-content writes (records, DNSSEC, zone create/clone) to a secondary are rejected (409). - **DNSSEC + DNS-resolution integration tests** - DNSSEC is now enabled on the test backends (`g*-dnssec=yes`), and the suite verifies, against a live stack, that records resolve over real DNS after the app writes them, that securing a zone serves DNSKEY + RRSIG on the primary, and that the signed zone transfers presigned to a secondary via AXFR and resolves there. - **PowerDNS compatibility matrix + badges.** One workflow per supported PowerDNS Authoritative version (**4.6 → 5.0**, sharing a reusable core) runs the full end-to-end suite on each minor/major release tag (plus monthly and on demand) - not on every push. Each exposes a live GitHub Actions status badge in the README (no committed state to maintain). ### Changed - **Sidebar navigation** regrouped into **Infrastructure / Access / System** sections (was a single flat "Admin" list), with clearer section headers and indented children, and the previously-orphaned **TSIG keys** and **Autoprimaries** admin pages are now linked. - The zones list's **DNSSEC** column now shows a green closed padlock when a zone is signed and a muted open padlock when it isn't (was "on"/"off" text). - **Server `/config` view** dropped the `# slug - /config (read-only)` caption header, and the daemon `api-key` now shows redacted (``) rather than being omitted entirely - so the operator can see the setting is present without exposing the secret. - **Unified the collapsible "summary" disclosure** used by the audit log and the PowerDNS HTTP request logs into one shared component, and fixed the cramped top spacing in the expanded request view. - **Migrations squashed** to a single migration per dialect for 1.1.0 (capabilities + advertised-addresses columns, the dropped per-server role enum + `primary_id`, and the `backend_advisories` table). See [ADR-0017](./docs/adr/0017-migration-squash-1.1.0.md). - **CI split for speed.** Every push/PR now runs the integration suite against one pinned PowerDNS image; the full 4.6 → 5.0 matrix moved to the release-time compatibility workflow above. The integration stack still selects the image via `PDNS_AUTH_IMAGE`. ### Fixed - The **TSIG keys** admin page (`/admin/tsig-keys`) was unreachable - `tsig.read` was granted by no role, so even Super Admin got bounced to the dashboard. It's now granted alongside `tsig.manage` (Team Owner and above); the boot seed rewrites system-role permissions, so a redeploy fixes existing installs. - **TSIG cascade-delete on a renamed/dotted key.** The pre-check that detaches a TSIG key from zones still referencing it compared a dot-less key name against PowerDNS's trailing-dot zone key-id fields, so the detach could be skipped. Key-name handling is now normalized through a single `stripTrailingDot` helper. - **Self-contention against a backend store.** The background poll (reads) and the request path (writes) could hit the same backend simultaneously; on a single-file gsqlite3 store a reader can stall a writer into a transient HTTP 500. The app now coordinates per backend - the poll's reads and the request path's writes take turns (keyed per backend; interactive reads stay fully concurrent, and separate backends never block each other), so the app no longer contends with itself. No PowerDNS configuration change required. - **An unreachable backend no longer wedges the UI.** The background poll and the explicit Test/Refresh now use a fast-fail probe (one attempt, 5s timeout) instead of the write-path's 3 attempts × 10s, so a newly-added or down backend resolves to "unreachable" in seconds rather than stalling the zones/servers pages (which await the poll) or the Test toast for ~30s. User-initiated reads and writes keep the full retry resilience. ## [1.0.2] - 2026-05-23 ### Changed - **Project moved to the [`PowerDNS-AuthAdmin`](https://github.com/PowerDNS-AuthAdmin) GitHub organization, and container images are now published to the GitHub Container Registry (GHCR) instead of Docker Hub.** Pull `ghcr.io/powerdns-authadmin/powerdns-authadmin:latest` (or a `:X.Y.Z` tag). The previous Docker Hub repository is no longer updated. ## [1.0.1] - 2026-05-23 ### Fixed - The dashboard "PDNS backends needing attention" widget and the PowerDNS-servers Status column no longer flag healthy backends as stale/unreachable. Reachability now tracks a `last_seen_at` timestamp, bumped on every successful background poll (and on a manual Test / Refresh all), instead of the version-probe timestamp - which only moved on a manual probe and so went "stale" within 24h even while the backend was being polled successfully every 30s. ### Changed - An OIDC provider configured via `OIDC_*` environment variables now appears as a **read-only** provider badged "Configured by ENV" - shown on the login page and in **Admin → OIDC providers** alongside DB-backed providers, instead of being a hidden fallback that only surfaced when no DB providers existed. A DB provider with the same slug still shadows it. ### Added - A documentation set under [`docs/`](./docs/): Quickstart, Installation, Configuration, Backends, OIDC, Provisioning, RBAC, Hardening, Upgrading, and Troubleshooting guides. - Sidebar footer showing the running version (linked to its GitHub release) and a Docs link pinned to the matching version's `docs/`. ## [1.0.0] - 2026-05-22 First production release. ### Added - **Multi-backend management** - standalone primaries, primary + secondaries groups, and multi-primary clusters from one app, with per-cluster peer-selection strategies (round-robin / random / lowest-latency / least-load). - **RBAC** (CASL) - five system roles plus custom roles; ~60 permissions scoped global / team / zone / server. - **Authentication** - local accounts (Argon2id), generic OIDC SSO with PKCE + group→role mapping and RP-initiated logout, TOTP MFA with a per-user override, and scoped `pda_pat_` API tokens. - **Zones & records** - per-RRset editor with diff-before-apply, per-type validators, zone cloning, zone templates, and optimistic concurrency. - **DNSSEC, TSIG, autoprimaries** management. - **Sync probes** - serial + record-for-record comparison for primary/secondary groups and clusters. - **Append-only audit log** with redacted before/after snapshots and per-zone history. - **Transactional email** - email verification, password reset, and email-change confirmation (SMTP). - **First-boot provisioning** - YAML-driven setup of settings, roles, teams, templates, servers, clusters, demo zones, and OIDC providers. - **Storage** - SQLite or Postgres; migrations run automatically on boot. - **Observability** - Pino structured logs, Prometheus `/metrics`, `/healthz` + `/readyz` probes. - **Distribution** - multi-arch (`linux/amd64` + `linux/arm64`) image published to Docker Hub as `jseifeddine/powerdns-authadmin`, plus a one-command minimal-demo stack. [Unreleased]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.12.1...HEAD [1.12.1]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.12.0...v1.12.1 [1.12.0]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.11.0...v1.12.0 [1.11.0]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.10.1...v1.11.0 [1.10.1]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.10.0...v1.10.1 [1.10.0]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.9.1...v1.10.0 [1.5.6]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.5.5...v1.5.6 [1.5.5]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.5.4...v1.5.5 [1.5.4]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.5.3...v1.5.4 [1.5.3]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.5.2...v1.5.3 [1.5.2]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.5.1...v1.5.2 [1.5.1]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.5.0...v1.5.1 [1.5.0]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.4.3...v1.5.0 [1.4.3]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.4.2...v1.4.3 [1.4.2]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.4.1...v1.4.2 [1.4.1]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.4.0...v1.4.1 [1.1.5]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.1.4...v1.1.5 [1.1.4]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.1.3...v1.1.4 [1.1.3]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.1.2...v1.1.3 [1.1.2]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.1.1...v1.1.2 [1.1.1]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.0.2...v1.1.0 [1.0.2]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/PowerDNS-AuthAdmin/powerdns-authadmin/releases/tag/v1.0.0