Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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>`. 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:<name>`, 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.<zone>.<code>` 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 `<div>`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.<zone>` /
  `in.<zone>` 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 `<SelectMenu>` (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=<zone>` 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 (`<query-type> "<Lua
snippet>"`, 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 `<text font-family="monospace">`, 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 `<img src>` 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 `<img>` 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 `<img src>`, 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
`<link rel="icon" type="image/svg+xml" sizes="any">` 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)

`<main>` 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/<id>` mirrors the
  OIDC equivalent - same pickers, same audit panel, same danger zone.
- **Sign-in routes**:
  - `GET /api/auth/saml/<slug>/login` - signed AuthnRequest + redirect to IdP.
  - `POST /api/auth/saml/<slug>/acs` - Assertion Consumer Service; verifies
    signature, decrypts EncryptedAssertion if configured, applies group →
    role mappings, mints session.
  - `GET /api/auth/saml/<slug>/metadata` - SP metadata XML (paste into IdP).
  - `GET /api/auth/saml/<slug>/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:<slug>"` 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/<slug>/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: "<slug>"` 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` / `<type>:<slug>` 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/<id>`, `/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:<slug>` / `saml:<slug>` / `ldap:<slug>` 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 `<link rel="preload">`
  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 `<img>`)
  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 <container> 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.** `<Image>` 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/<id>`) 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/<id>?tab=sync`, `/zones/<id>?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 `<HeaderStatusMode/>` 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 `<DataTable>` recipe - `bg-bg-muted` thead, `even:bg-bg-subtle`
  body stripes, accent-tinted hover wash, mobile auto-reflow to labelled
  cards. No more bespoke `<table>` 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 `<ScrollToTab/>` 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
  `<picture>` 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` (`</style>` 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 (`<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