From ed3cb4aaac97a2aba18d5324b1794145d0e44a44 Mon Sep 17 00:00:00 2001 From: Mix Date: Sat, 10 Oct 2026 13:01:18 +0300 Subject: [PATCH] docs: the panel as it is in 2.0 Stage 3, step 3. Written by Sonnet from the code and the plan, checked by Opus. - guide.md: a section on the panel - menu, paths, the roles global and domain with outbound and inbound assigned separately -, Overview and Health, Queue and System log, Account and Server > Settings, the domain page / Domain settings / application form, Help as a page of topics. The DMARC report address is one of three choices set in Domain settings. The Spam filter box, the two INBOUND_ANTISPAM_* variables the panel reads, and the filter's socket on Health. Sessions; backup and export encrypted unless the box is unticked; 1.x data is not restored into 2.0. - architecture.md: Panel HTTP surface rewritten from web.go (routes, the /server/ guard, per-object checks, /fragment, one layout, typed page data, the kit and its guards, CSP and htmx); Persistence gains the database - one baseline, a 1.x file refused, the tables as they are. - security.md: roles and what each reaches, as built; sessions by user id and what ends them; state-changing requests; errors shown to users; the 2.0 review; two accepted risks from it - a delegated inbound upstream may be any host and port, and Account's profile is saved without the password. - CHANGELOG: Removed opens with "2.0 starts from an empty data directory"; entries that describe the user-default report address say they were replaced before release; the filter's socket. - roadmap.md: two review follow-up rows struck as retired by the redesign (.pair / .split, the two back links); password-reset notes that changing the account e-mail must then ask for the password. Progress 25/27. - development.md: the component kit and its guards; README and product.md: wording. Left as they are - The third follow-up row the plan expected to strike - initShowWhen is not re-run after an htmx swap - still holds: no polled fragment carries such a control today, so nothing breaks, but the redesign did not retire it. - guide.md embeds no screenshots, so none needed retaking. - README and guide still name image 1.9.5 - the version cut. - plans/preflight.md and plans/inbound-antispam-panel.md use pre-2.0 paths; roadmap's template-data-typing is largely done by the redesign. go test -count=1 ./cmd/panel (env-doc tables): ok Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 46 +++- README.md | 4 +- docs/architecture.md | 331 ++++++++++++++++++---- docs/development.md | 28 +- docs/guide.md | 512 ++++++++++++++++++++++------------- docs/plans/panel-redesign.md | 2 +- docs/product.md | 8 +- docs/roadmap.md | 10 +- docs/security.md | 144 ++++++++-- 9 files changed, 800 insertions(+), 285 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f53bddb..3b5188e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version (`dmarc_default_mode` / `dmarc_default_address`) and a domain either has an address of its own or follows a named user's default (`domains.dmarc_rua_user_id`) — a new domain follows whoever created it, - and deleting that user leaves it with no report address. + and deleting that user leaves it with no report address (replaced before + release — see the entry of 2026-10-10 below). `user_inbound_domains` and the per-user `all_domains` / `all_inbound_domains` flags are in the schema for the inbound delegation that follows. Details: `docs/schema-migrations.md`. @@ -52,8 +53,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version **Server › Settings** (`/server/settings`, global role) replace the one Settings page. Account has three forms, each its own POST: profile (username, e-mail), password, and the default DMARC report address - (SelfPost hosted, my account e-mail, another address, none). Saving the - profile no longer asks for the password; changing the password still does. + (SelfPost hosted, my account e-mail, another address, none) (replaced before + release — see the entry of 2026-10-10 below). Saving the profile no longer + asks for the password; changing the password still does. - panel-redesign (stage 1): the application form is one POST. Who the application may send as, its client-IP allow-list and its rate limit ("use the domain limit", manual or auto) are validated together and saved @@ -125,6 +127,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version are gone, and with them `users.dmarc_default_mode`, `users.dmarc_default_address` and `domains.dmarc_rua_user_id` (owner, 2026-10-10; replaces the design of 2026-09-21 described above). +- panel-redesign (stage 3): with the inbound spam filter on, its socket is one + of the milter sockets on **Server › Health** and counts in Overview's card. + The panel connects to the configured address (`inet:host:port` or + `unix:/path`, one second at most, nothing sent). A filter that does not + answer is an error with `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` - inbound + mail is deferred - and a warning otherwise - mail goes through unfiltered. - panel-redesign (design, stage 3): **New password** and **Delete** are in each row of a domain's Applications table again, as POST forms that look like the links beside them. @@ -151,20 +159,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version export file. - The panel role `domain_admin` is stored as `domain`. - On the old domain page, "Same as Settings" makes the domain follow the - default report address of the user who chose it. + default report address of the user who chose it (replaced before release — + see the entry of 2026-10-10 below). - The old panel is untouched on screen but steps aside in the tree: its stylesheet is `legacy.css` and its layout `layout_legacy.html` until the last old page is restyled. The 2.0 menu paths in the new shell do not - exist before stage 1; only the kit page uses that shell so far. + exist before stage 1; only the kit page uses that shell so far (a stage-0 + state: both files are deleted and every page is on the shell — see + *Removed*). - panel-redesign is `agreed` (owner, 2026-10-09); work starts with stage 0, the component kit. +- panel-redesign (stage 3): the operator guide, `architecture.md` (Panel HTTP + surface, Persistence) and `security.md` (roles, sessions, errors shown to + users) describe the 2.0 panel. `security.md` records two accepted risks from + the 2.0 review: a delegated inbound upstream may be any host and port, and + Account's profile is saved without the password. ### Security - A panel session belongs to a user by id and ends with them. It used to be keyed by user name: the cookie of a deleted or renamed user stayed valid and became a session of whoever was next created under that name. Signing - in again is required after upgrading. + in again is required after upgrading (there is no upgrade from 1.x; this + concerns development builds of 2.0). - Changing one's password ends one's own other sessions only; it used to sign every other user out. A password set by an administrator on the user form ends that user's sessions. @@ -180,17 +197,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version - Saving a domain's report-address form untouched no longer moves a domain that follows another user's default to whoever saved it: that user's - default is its own choice, and "My default instead" is the change. + default is its own choice, and "My default instead" is the change + (replaced before release — see the entry of 2026-10-10 below). - The auto rate-limit fields follow the limit mode on the domain and application forms (the script applied only the first matching rule). - The DMARC page of a domain no longer answers 500 once the domain has a reported source. - The confirmation before deleting a user names the domains that follow that user's default report address and will be left without one; it used - to say the domains were not affected. + to say the domains were not affected (replaced before release — see the + entry of 2026-10-10 below). ### Removed +- **2.0 starts from an empty data directory.** A 1.x `/data` is not upgraded: + its database is refused at start and left untouched, and a 1.x backup is not + restored; a domain is carried over with its export file. Gone with it, as + built: the 1.x migration chain, every pre-2.0 panel path (listed next), the + help drawer (Help is a page of topics), the default DMARC report address — + the panel-wide one of 1.x (`dmarc_report_email`, "Same as Settings") and the + per-user one with a domain following a user that earlier entries of this + release describe (`users.dmarc_default_*`, `domains.dmarc_rua_user_id`) — + together with the DMARC box on Account, the old stylesheet `legacy.css`, the + old layout `layout_legacy.html`, and the unused `rate_limits.allowed_ips` + column. - Old panel paths, GET and POST alike, without redirects: `/status`, `/status/fragment`, `/status/recheck`, `/reload`, `/domains` and everything under it (`/domains/import`, `/domains/{id}`, `…/dns-recheck`, `…/delete`, diff --git a/README.md b/README.md index 295f0aa..45f4c6b 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ send log and DNS checks in the panel, encrypted backups. - Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing - Optional inbound relay on port 25 (backup-MX / forwarder; off by default) - Optional DMARC aggregate report ingest on port 25 (off by default) -- In-panel Help drawer and `/help` page for Status and domain controls +- In-panel Help: a page of topics, opened from the `?` in the head of a box - Per-application client IP allow-list for SMTP AUTH (optional) -- Web panel — domains, applications, deliveries, mail queue, system log, backup +- Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings) - Multi-domain relay — each SASL application is bound to one sending domain - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides diff --git a/docs/architecture.md b/docs/architecture.md index 3a99cca..ba8e1ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -114,10 +114,10 @@ One process, four roles: parameters (`queue_run_delay`, `minimal_backoff_time`, `maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`, `delay_warning_time`) and caches the snapshot on the handlers config. The - Mail queue card and a delivery's `deferred` / `bounced` history print those + Outbound queue box and a delivery's `deferred` / `bounced` history print those numbers; they never call `postconf` per request. If `postconf` is missing, the panel logs a warning and uses Postfix 3.x compiled-in defaults - (`300s` / `4000s` / `5d` / `0`) with a muted note on the card. + (`300s` / `4000s` / `5d` / `0`) with a muted note on the box. 2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/ Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** (`default_action=accept`) so milter failure does not stop mail. Domain @@ -133,8 +133,8 @@ One process, four roles: 3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by queue-id. Send-log `queued → sent` transitions depend on this goroutine alone (`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)). -4. **rate-limit recalc** — every six hours (and on demand from the domain - page), recomputes level-2 **Auto** rate limits from send-log statistics. +4. **rate-limit recalc** — every six hours (and on demand from Domain settings and + the application form), recomputes level-2 **Auto** rate limits from send-log statistics. Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure). @@ -159,7 +159,7 @@ panel user cannot read). `follow()` drains the old inode once more before switching descriptors; the panel treats a missing log file as an empty tail, not an error. -**Read offset is persisted** (`logtail_state` table, migration `0003`): the +**Read offset is persisted** (`logtail_state` table, in the baseline schema): the tailer stores its position plus a fingerprint of the log's first 512 bytes, and on start resumes from it, parsing the tail written while the panel was down. If the fingerprint no longer matches (rotated or recreated in the meantime) it reads @@ -185,8 +185,8 @@ rotated past fourteen files while the panel was down, or deleted. **Two one-shot reads** sit beside the follow loop and are unrelated to it, both serving panel pages on request: `TailLines` (the last *n* lines, for -`/system-log`) and `QueueLines` (the lines carrying one queue-id, for -`/deliveries/{id}`). `QueueLines` scans a bounded tail of the current file — +`/server/log`) and `QueueLines` (the lines carrying one queue-id, for +`/outbound/log/{id}`). `QueueLines` scans a bounded tail of the current file — finding a message's lines means reading rather than seeking — and matches the id anchored on the character before it, since queue ids are hexadecimal runs and a shorter one is regularly the tail of a longer one. Send-log rows outlive the log @@ -197,61 +197,227 @@ state for an older message and the page reports it as such, not as a failure. ## Panel HTTP surface -Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated -unless noted. Routes marked **global** return **404** for domain administrators -(`requireGlobal()` in -[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table -below is a summary — HTMX fragment endpoints -(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`, -`/system-log/body`, …) and every POST variant live in `web.go`. +Canonical routes: [internal/web/web.go](../internal/web/web.go). The router is +two flat `http.ServeMux`es built by `muxes()` — one pattern per route, method +included (`GET /outbound/log/{id}`), nothing mounted as a sub-router, and +building them only takes method values, so the route guard test can run it on +a bare `Server` and ask which pattern answers a path: -| Route | Purpose | +- **public** — `/healthz` (liveness), `/license` (the embedded `LICENSE`), + `/static/`, `/setup/`, `/login`, `/logout`; +- **authenticated** — everything else, behind `RequireAuth`: a request without + a live session is redirected to `/login`. + +Both sit inside `secure()`, which sets the security headers and runs the origin +check on every state-changing request (see below). + +### Routes + +A path reads like the menu: `//`, an entity under its list, an +action under the thing it changes. Every state change is a `POST`; a `GET` +only reads (the delete confirmation at `GET …/delete` renders a form). + +| Group | Routes | |---|---| -| `/healthz` | Liveness (no auth) | -| `/license` | Embedded `LICENSE` text (no auth) | -| `/setup/*` | One-time admin bootstrap | -| `/login`, `/logout` | Session auth | -| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) | -| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network | -| `/domains` | Domain list; `POST /domains` (add domain) is **global** | -| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** | -| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) | -| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins | -| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) | -| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail | -| `/reload` | **Global.** `POST` — reload OpenDKIM tables, Postfix sender map, and inbound relay maps when enabled (not DMARC maps — see § Persistence restore) | -| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) | -| `/help` | In-panel operator help (any authenticated user) | -| `/settings` | Username/password for any user; DMARC report default is **global** only | -| `/users`, `/users/*` | **Global.** Panel user CRUD | -| `/inbound`, `/inbound/{id}`, `/inbound/{id}/*` | **Global.** Inbound relay domains. Registered only when `INBOUND_RELAY_ENABLE=true`; otherwise 404. | -| `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}` | **Global** list and report detail; domain roll-up scoped like deliveries. Registered only when `DMARC_REPORTS_ENABLE=true`; otherwise 404. | +| Home | `GET /` → `/overview`, or for a user with the *domain* role to the first list they reach (`/outbound/domains`, or `/inbound/domains` when that is all) | +| Overview | `GET /overview`, `GET /overview/fragment` | +| Outbound › Domains | `GET, POST /outbound/domains` · `/outbound/domains/{id}` · `POST …/dns-recheck` · `GET, POST …/delete` · `GET …/settings` · `POST …/settings/reports`, `…/ratelimit`, `…/ratelimit/recalc`, `…/export` | +| Outbound › Applications | `GET, POST /outbound/domains/{id}/applications/new` · `GET, POST …/applications/{aid}` · `POST …/applications/{aid}/password`, `…/{aid}/ratelimit/recalc`, `…/{aid}/delete` | +| Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` | +| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` | +| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` | +| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `GET /server/components` | +| Account | `GET /account` · `POST /account/profile`, `/account/password` | +| User menu | `GET /help` · `POST /logout` (public mux) | -HTMX polling refreshes monitoring fragments (5 s while the operator is active on -the page, 30 s when the tab is visible but idle, none when hidden — scheduled in -`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend -session idle timeout (only non-`HX-Request` GET and mutating requests count as -activity). The Mail queue retry-policy card is outside that fragment: it is the -start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)), -not a live re-read. +An application is addressed under its domain, and an application id that does +not belong to the domain in the path is a 404. The application form is one +`POST`: sender, client IPs and rate limit are validated and saved together or +not at all, also at creation. The page that shows a new password is the +response to the `POST` that created or regenerated it; it has no `GET` path and +carries `Cache-Control: no-store`. + +The pre-2.0 paths are not served and not redirected (`/status`, `/domains/…`, +`/inbound/{id}/…`, `/deliveries`, `/mail-queue`, `/system-log`, `/backup`, +`/users/…`, `/settings`, `/reload`, `/dmarc/…`, and `/account` as a redirect): +each answers 404 once signed in. `TestRoutesFollowNavigation` +([internal/web/guard_routes_test.go](../internal/web/guard_routes_test.go)) +holds the table: every path is registered with its methods, every menu entry +points at a registered path, every old path answers 404, and no template +contains an old path. + +### Who may open what + +Authorization is by tree where the tree is the boundary, and by object where it +is not. + +- **`/server/` is guarded once.** Routes under it are registered through + `server()` in `web.go`, which wraps the handler in `globalOnly` (404 for + anyone but the global role — the answer a missing page gets, so the panel does + not confirm the page exists) and panics at start-up for a pattern outside + `/server/`. The handlers also call `requireGlobal()` themselves. +- **Overview and the Outbound queue** are global-only but not under `/server/`, + so their handlers call `requireGlobal()`. +- **Outbound is checked per domain.** `lookupDomain` resolves `{id}` and answers + 404 for a missing domain and for one the principal may not reach alike + ([handlers/handlers_domains.go](../internal/web/handlers/handlers_domains.go)); + application routes go on to check the application belongs to that domain. + Lists and the log are filtered by assignment in the query + (`ListDomainsForUser`), not in the template. Adding and deleting a domain is + `requireGlobal()`. +- **Inbound is checked per inbound domain**, separately from outbound + ([handlers/handlers_inbound.go](../internal/web/handlers/handlers_inbound.go)): + `requireInbound` (the list), `requireInboundDomain` (one domain — page, + upstream, recipients, DNS re-check) and `requireInboundGlobal` (add, delete). + All three answer 404 alike. +- **The DMARC hub** is open to anyone with outbound reach for the reports of + their own domains; the *Ingest* box (server-wide statistics) is rendered for + the global role only. +- **Account and Help** are open to every signed-in user. +- The principal ([web/auth/principal.go](../internal/web/auth/principal.go)) + carries the role, the assigned outbound and inbound domain ids and the two + *All* flags; `CanAccessDomain` and `CanAccessInboundDomain` are the only + predicates. The reach, not the role, decides which menu groups exist. + +Routes marked global are tested per path for a user with the *domain* role, +including another tenant's domain id +([handlers/handlers_delegation_test.go](../internal/web/handlers/handlers_delegation_test.go), +[authz_test.go](../internal/web/handlers/authz_test.go)). + +### Polled fragments + +A polled page answers its refresh at `/fragment` (`/overview/fragment`, +`/outbound/log/fragment`, `/server/health/fragment`, …). The fragment is the +same `{{define}}` block that renders the page's own box, so the first paint and +a refresh cannot differ. HTMX refreshes them every 5 s while the operator is +active on the page, every 30 s when the tab is visible but idle, and not at all +when it is hidden — scheduled in `panel.js` via `data-poll`, not +`hx-trigger="every …"`, because a trigger filter is evaluated with `new +Function`, which the CSP forbids. Polling does not extend the session idle +timeout (only non-`HX-Request` `GET`s and mutating requests count as +activity). The Outbound queue's retry-policy box is outside the polled region: +it is the start-up `postconf -h` snapshot (see [Panel +binary](#panel-binary-cmdpanel)), not a live re-read. + +### View engine and component kit + +Pages are rendered by [internal/web/view](../internal/web/view/view.go). There +is one layout and one way to render. + +- **One layout.** Every page is parsed with `layout.html` (the shell: brick + navbar with the wordmark, the perforated edge, the strip of sibling pages, + user menu, footer) and `components.html` (the partials), then its own file. + `layout_signed_out` is the same document without navigation, used for sign-in + and setup; `Engine.Render` picks it when the page data names no user. The + page's file defines `content`; `pageFiles` in `view.go` lists the files of + each page, and fragments are listed in `fragmentFiles` and rendered by + `RenderFragment` with no layout. +- **Typed page data.** A page's data is a struct that embeds `Meta` (title, + user, the viewer's reach, the menu group and page it sits in) and carries + the page's boxes and rows as fields, built by a constructor in the `view` + package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is + derived from `Meta` and the feature flags (`SetInboundEnabled`, + `SetDMARCEnabled`) in one place, `Engine.shell`, so which entries the menu + has is decided once. No template reads a `map[string]any`; the one map left + in the handlers is the health sampler's, which is turned into a typed + `Health` before rendering. +- **The component kit.** [components.html](../internal/web/view/templates/components.html) + holds one `{{define}}` per partial (page head, postmark, box and its head and + foot, flash, health card, DNS record, facts, side menu, timeline, log pane, + empty state, confirm list, help topic, form rows) and is the only file that + writes their `sp-` markup. [components.go](../internal/web/view/components.go) + is the typed input of each (`Head`, `Box`, `Record`, `Fact`, `SideMenu`, …), + so a page passes a struct, never a `dict`. Template functions: `status_tag` + (a status → Bulma's tag classes, once), `wbr_at` (a break after the `@` in a + table cell). A page that needs a component the kit lacks is a design change + made in the mockups first + ([plans/panel-redesign.md](plans/panel-redesign.md) § The contract). +- **Help.** [help.go](../internal/web/view/help.go) is the Help page's data: + topics grouped by section of the panel. A box's head links to its topic with + `Box.Help` (`/help#`), and the page lists only the + sections the viewer's menu shows. +- **Assets.** [static/](../internal/web/view/static) is embedded + (`//go:embed`): vendored `bulma.min.css` and a Tabler icon subset (checksummed), + `panel.css` (the mockups' stylesheet, rule for rule), `htmx.min.js`, `panel.js`, + the IBM Plex subset and the logo files. Served with a content-derived `ETag`, + and `/static/` does not list its files. The kit page + `GET /server/components` renders every partial in every state from fixtures + (global role only). + +**Guard tests** hold the design in place; they run in `go test ./...` and CI. +They read their rules from the accepted mockups +([docs/assets/panel-redesign/panel/](assets/panel-redesign/index.html)) rather +than keep a copy: `TestPanelClassVocabulary` (only the Bulma subset and the +`sp-` classes of `panel.css`; every `sp-` class defined, used and shown on the +components page), `TestPanelCSSIsTheMockupStylesheet`, `TestPanelCSSContract` +(no `!important`, no id or page selector, type floor, widths), +`TestTemplatesUseComponents` (no hand-written component markup in a page), +`TestPanelOutlinesMatchMockups` and `TestPanelPageStructure` (each rendered page +has the component skeleton of its mockup), `TestRedesignedPagesLoadOnlyTheKit`, +`TestVendoredAssetsArePinned`, `TestStaticHoldsOnlyTheKnownAssets`, +`TestRoutesFollowNavigation`, `TestNoTemplateUsesInlineScriptOrStyle`. The +ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink; +the CI step +[design-first](../.github/scripts/design-first.sh) rejects a commit that +changes a guard together with the code it judges, and a template that is a +byte-for-byte return of one from history. The design contract and the process +are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement. + +### Security headers, CSP and scripts + +`secure()` ([internal/web/security.go](../internal/web/security.go)) puts these +on every response: `Content-Security-Policy: default-src 'self'; object-src +'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`, +`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, +`Referrer-Policy: no-referrer`, and `Strict-Transport-Security` when the cookie +is `Secure`. Because the policy has no exemptions, no template carries an +inline `