# Changelog — Tresipunt Manager

All notable changes to this application are documented here. Newest version first.

This file follows the same rule as the `CHANGES.md` of the Tresipunt plugins: **each entry
says what changes for whoever deploys or operates the application**, not which file was
touched. Internal traceability lives elsewhere.

> It is named `CHANGELOG.md` and not `CHANGES.md` on purpose: the plugins use `CHANGES.md`
> because that is what the Moodle Marketplace expects, and the Manager is not published
> there. For an application, `CHANGELOG.md` is the convention every tool looks for.

## 2.2.0 — 2026-09-17

MINOR, not MAJOR: the API contract does not change — same endpoint, same codes, same payload
shape — and no correctly registered site notices anything. What stops being served is what
was never covered by a licence in the first place.

### ⚠️ Breaking changes

- **`features`, `tutorials` and `resources` no longer serve anything to a host that is not
  registered under the calling token.** Until now a valid token was enough: any domain,
  registered or not, got the content. It was found by looking at a real request — a site of
  one customer whose domain was not registered got `2002` on `licence` and, twenty-two
  seconds later, `200` and 19.970 bytes on `features`.

  **It was not a decision.** The seven content branches of `ProductToken` were the same
  forty-line block copied, and three of the copies had lost the first check of all. The
  comment that justified it — «no requiere entorno, similar a `setup`» — was false: `setup`
  did require it, twenty lines above. `api/error-codes.md` had written the hole down as if
  it were intended.

  **Who is affected**: any site currently receiving content whose domain is not registered
  in its licence. They stop receiving *features*, *tutorials* and *resources*; they were
  already getting nothing from `licence`, `setup`, `scss`, `scss-cdn` and `js`, which did
  check. The fix is to register the domain, which is what should have happened at
  onboarding. Find them with:

  ```sql
  SELECT host, client_id, action, COUNT(*) AS peticiones, MAX(started_at) AS ultima
  FROM api_request_logs
  WHERE action IN ('features','tutorials','resources')
    AND environment_id IS NULL AND http_status = 200
  GROUP BY host, client_id, action ORDER BY peticiones DESC;
  ```

  New rejection codes, following each action's existing family: `5000` for `features` and
  `resources`, `4000` for `tutorials`. **No other code changes.**

### Changed

- **The API's security rules are now declared in one table instead of repeated per action.**
  `ProductToken` went from 974 lines with seven copies of the same block to 599 with one.
  Each action declares what it requires — the host registered under that token, and the
  product contracted and in force — and **an action that declares nothing gets the strictest
  treatment**, so the thirteenth action cannot be written open by omission the way three of
  the twelve were.

  Four actions deliberately do **not** check the product, and that is not an oversight:
  `products` asks for the whole list, and `sync`, `data` and `plugins` do not ask for
  anything — they write the site's state. The `plugin` they carry is who calls, not what is
  requested.

  The rejection codes stay exactly as they were, one by one. That the same cause returns
  `2002`, `3000`, `4000`, `404` or `403` depending on which action says it is a real defect,
  but unifying them changes the contract with sites in production: that is a major and goes
  on its own.

- **A rejection for an unregistered host now always reaches `api.log`.** Only six of the
  twelve actions wrote it, so a `sync` or a `data` from an unknown host left neither the
  domain searched nor whether that domain exists under another licence — which is exactly
  what is needed to diagnose it.

## 2.1.0 — 2026-09-16

Everything done after 2.0.0 went to production on 2026-09-09, deployed on 2026-09-16.
MINOR, not PATCH: it brings screens that were not there. Nothing that an already running
Moodle was doing stops working — the API contract is untouched.

### ⚠️ Breaking changes

- **A new permission, `admin.environments.review`**, granted to Admin, Manager and Support by
  a migration. It gates the environment observations described in *Added*. It is deliberately
  **not** `admin.environments.edit`: Support does not hold that one — they have `index`,
  `refresh` and `supports.index` — and Support is precisely who spots that something is wrong.
  Hanging the feature off `edit` would have left it without the people who need it, and
  granting them `edit` would have opened the domain, the licence and the type of every site.
- **The permissions of five of the six seeder roles are now editable from the panel.**
  Manager, Developer, Support, System and WebService can be changed in *Configuration ›
  Access*; only **Admin** stays blocked, and for one concrete reason — it is the role that
  grants access to that very screen, so removing one permission too many would leave the panel
  with no way to edit any role again. The **internal name** of all six stays fixed: the seeder
  and the permission migrations look roles up by name, so renaming «Manager» would silently
  make the next migration create an empty new one while the users stay on the renamed role.
  Use `fullname` for the visible label.
- **The environments list has a new URL filter, `?sin_frescos=1`** («sin datos frescos»):
  environments that have never synchronised *or* that last did so three days ago or more. It
  is the filter behind the «desincronizados» KPI. The two existing chips — «nunca
  sincronizado» and «sin señal» — still split those same sites in two, because an unfinished
  registration and a breakdown need different work; the three are mutually exclusive.

- **`Environment::ENVIRONMENTS` and `Environment::ENTORNOS` are gone.** The four `env`
  values — `pro`, `pre`, `develop`, `local` — are now a catalogue table (`envs`) managed from
  **Configuration › Catalogs**, so they can be created, edited, deleted and reordered without
  a deploy. Read them with `Environment::envs()` (`[shortname => name]`) or
  `Environment::entornos()` (the list); the colour of each one is `Environment::colorDeEnv()`.
  `environments.env` still stores the string and there is no foreign key: that value arrives
  in each Moodle's payload.
- **The public API validates `environment.env` and `site.env` against that catalogue**,
  instead of a hardcoded `in:pro,pre,develop,local`. A new use is accepted as soon as it is
  created in the panel; a deleted one starts returning 422 to whichever site still sends it.
- **The environments list `env` filter changed shape in the URL.** It was `?env=pro`, one
  exclusive value; it is now `?env=pro,pre` — several at once, in the catalogue's order — and
  **an absent or empty `env` means «whatever the catalogue marks as visible»**. Old links with
  a single value keep working.

- **The request viewer opens on errors that nobody has reviewed yet**, not on everything.
  `?severity=` absent now means `error` and `?onlyUnreviewed=` absent means «only the ones not
  marked as seen»; the URL only carries them when they differ from that. A saved link with no
  parameters therefore shows a different list than before — the one the screen exists for.
  «Everything» is one click away and the header counts errors regardless of both filters, so
  marking one as seen no longer lowers a number that is supposed to say how many there are.
- **`api_request_logs` has a new `reason` column** (indexed alongside `started_at`),
  classifying each rejection into one of twenty motives — bad token, licence expired, product
  not contracted, product does not exist, environment switched off, host not linked, rate
  limit, and so on. Existing rows are backfilled in 50k-id blocks by the migration itself. The
  same classification is available in SQL, so a query on the table and the viewer agree.
- **`plugins.type`, `plugins.versiondisk` and `plugins.versiondb` no longer accept a missing
  value as `null`** through the `plugins` action: they are `NOT NULL` and are now written as
  `''` / `'0'`, which is what `sync` already did. See *Fixed* — the previous behaviour aborted
  the whole request.
- **Content versions now have a publication state, and a new version is born as a draft — so
  creating a version no longer serves it.**
  Six of the seven versioned content types had no state at all: a version was served **the
  moment it was created**, to every environment whose plugin version matched it. There was no
  draft, no «ready but unpublished» and no deprecation — the documentation of a product was
  edited in production, and the only way not to publish half of it was to not create the
  version, or to create it with a number higher than any installation had yet. A trick, not a
  mechanism.
  - Everything already loaded stays **published**: any other choice would stop serving
    content that is being served today, and the client would notice before we did.
  - **A new version — and a duplicate — is created as a draft.** Forgetting to publish is
    visible on screen and one click away; publishing half-written content is not visible from
    here at all.
  - `setups`, which was the only type with state, now shares the same rule as the other six
    instead of carrying its own copy.

### Added

- **A *Version and changes* screen, in Spanish, under Configuration.** It answers «is X live
  yet?», which until now was answered by asking whoever had deployed. The instance does not
  ask anything to know what it runs: **it is that code**, so `App\Support\Historial::ACTUAL`
  is exactly what is up there — no `git describe` (the server may have no `.git` and `exec()`
  is usually disabled) and no file written by the deploy (it disappears on the first deploy
  that does not write it). The header also shows `APP_ENV`, because two tabs on the same
  screen are otherwise indistinguishable, which is precisely when someone acts on the wrong
  instance. **No permission is required**, unlike every other screen in the section: the
  people who most need to say whether something is live are the ones with the fewest
  permissions, and asking for `admin.configs.index` would have left it to Admin, the only
  role that already knew the answer. Nothing about any customer is shown.

  Two things it deliberately does **not** have. There is no «pending to deploy» list — it
  would only be right as long as nobody forgot to add a line, and what is done and not
  released is already written down in this file's `Unreleased`. And the entries carry **no
  date**: the useful one is the day it reached production, which happens after the commit
  that would state it, so it would be filled in by hand and read as a fact while being a
  guess. The git tag and the deploy log do know.

  **Bumping the version on every deploy is now a project rule**, written in
  `.tresipunt/…/manager/operations/versionado.md`: `MAJOR.MINOR.PATCH`, decided by what
  happens to whoever is already running, not by the size of the change. A test enforces the
  part a rule cannot — that the version being run has an entry saying what it brought.

- **Environments can carry an observation: what is pending on that site, with a level and who
  wrote it.** It is the only thing the panel knows about a site that a human typed — everything
  else each Moodle declares when it synchronises — and until now it lived in somebody's head or
  in a chat. One observation per environment, three levels (*Aviso*, *Importante*, *Urgente*)
  each with the criterion for using it written next to it, and the author and date recorded.
  Editing the text does **not** refresh that date: how long something has been waiting is what
  decides what gets attended, and it would stop meaning anything if a comma reset it. Resolving
  clears the note from the site — otherwise the list would keep saying «pending» about
  something that is not — and leaves the full text, the level and who closed it in *Panel
  actions* (codes 16043 and 16044). The environments list gets a button in the actions column
  and a triage chip with the count and how many are urgent; the environment page shows the
  whole note under the verdict.
- **A login link per environment**, for sites behind SSO. A Moodle with SAML, CAS or OAuth2
  sends everyone to the identity provider the moment «Log in» is pressed, and we have no
  account there; the URL to bypass it and use the manual support account is different on every
  site (`?saml=off`, `?authCAS=NOCAS`, sometimes another domain). Optional: with nothing set,
  the panel builds the usual `{domain}/login/index.php`, so it only needs filling in for the
  exceptions. Validated as `url:http,https` — the field is rendered as a link on three screens
  and Laravel's plain `url` rule accepts any scheme.
- **The plugins section is now five screens with a summary front page** —
  `/plugins`, `/plugins/nuestros`, `/plugins/sin-instalar`, `/plugins/inventario` and
  `/plugins/moodle` — instead of one screen with three tabs and a fourth block hidden inside
  the first. A tab cannot be linked from an email, does not appear in the menu, and **forces
  the other two to be calculated** just to paint its counters: opening the inventory also paid
  for the version detail of our own plugins and for the core check. «Licenciado y sin instalar»
  gets its own page because it is a commercial question, not a technical one.
- **Environment list KPIs**: how the estate is made up, in three cards — by use, by platform
  (with each type's logo) and by service and signal. They answer *what we have*, which is a
  different question from the triage chips below them (*what needs attention*), and until now
  only the second had an answer. Every cell is a filter and toggles off when pressed again;
  the numbers count the whole estate and never move when filtering, because that is the point
  of them.
- **Dashboard: the five largest sites, by users, courses and enrolments.** The data arrived in
  every site's daily telemetry and could only be seen one environment at a time. Three lists
  and not one ranking: a campus with many users and few courses and a catalogue with many
  courses and few enrolments do not sort the same, and a single figure would need a weighting
  nobody has agreed on.
- **The platform of each environment now has its own logo in the list**, and «Estado técnico»
  has been split into *Versión* and *Señal*, with the contracted support moved next to the
  licence — it is a commercial agreement, not a technical fact. Three questions with different
  tone, urgency and owner were stacked in one cell.
- **`logs` and `notices` can be cleaned from their own screens**, by date, level, code and
  user for panel actions, and by date, type and whether the notice was ever sent for notices.
  Neither table was touched by anything — no scheduled task, no retention — and `logs` has soft
  deletes, so rows «deleted» long ago were still taking up space; the cleanup uses
  `forceDelete()`. **Deleting a notice re-arms it**: the table exists so alerts are not
  repeated, so the modal counts the recurring ones separately and says the daily task will send
  them again.

- **Environment-use catalogue** with listing, create, edit and delete, plus a per-row
  «shows by default in the filter» checkbox and a closed colour palette taken from the design
  system. Deleting a use that any environment declares is blocked — `environments.env` holds
  the string, so deleting it would raise no error and would instead leave those sites with a
  value that means nothing and a `sync` that starts failing. `local` can never be renamed or
  deleted: the generated column `environments.domain_unique` compares against that literal
  string, and the domain unique index hangs off it.
- **The environments list filters several uses at once** (`pro`+`pre` is the question people
  actually ask) and **hides local and inactive sites by default**, saying how many rows that
  hides and offering «see everything» right next to the count. The number is exact and counted
  over the other filters in force.
- **The other filters only offer what is present in what is being shown**, with counts:
  platform, version family and product. Before they came from the whole catalogue, so a
  narrowed list offered options no visible site had and choosing them led to an empty list.
- **The API request viewer ignores switched-off environments**, with an «include switched-off
  environments» toggle and the count of what it is hiding. Those sites keep calling for months
  after being decommissioned and every call takes an error, so they were showing up as
  failures to fix and crowding out the sites that are actually served. Asking for one site
  explicitly — by environment or by host — overrides the cut.
- **Rejections are classified by motive, not just by severity.** «It failed with a 403» was
  the same row whether the token was wrong, the licence had expired, the product was not
  contracted or the product did not exist — four different problems with four different owners
  and four different fixes. The viewer now filters by motive, breaks the errors down by motive
  in the header and says the motive in each row and in the detail. «Product not contracted»
  and «product does not exist» are deliberately kept apart: the first is a commercial matter
  and the second is a catalogue mistake of ours.
- **Products take an icon** (jpeg/jpg/png/webp — no SVG) with a preview and a «remove icon»
  action that never touches the static images shipped with the panel, and their **type and
  technology are free text** with suggestions taken from what is already in use. A product
  like `fresk_premium`, which is a bundle and not a plugin, no longer has to be forced into a
  technology it does not have.
- **Monitoring has a front page** that routes to its six screens with a card each saying what
  that screen answers, plus live counts of what is being kept. It used to open on the first
  tab, so «where do I look at X» meant opening all six.
- **«Traffic and cuts» rebuilt around what has to be done.** It used to be a flat list of
  `api_blocks` rows: one line per episode, the same shape whether it was cutting service or
  merely observing, and no way to tell them apart at a glance. Now it opens with a verdict and
  a numbered «what you have to do»; what is **actually cutting** is separated from what is
  only being watched; episodes are **grouped by motive, mode and subject type** with a
  per-group review and delete; «near the limit» shows the one-minute windows that crossed the
  warning margin in the last 24 hours; and the IP ranking answers the other question — who is
  giving us the traffic — with the last user-agent and the distinct domains of each IP, since
  cutting an IP that fronts a shared hosting cuts every site behind it.
- **`api_blocks` and `api_rate_events` can be cleaned by hand and on a retention.** Two new
  settings — episode retention and window retention — plus a cleanup modal that deletes
  reviewed episodes by date range, by IP or one at a time, telling you beforehand how many
  rows go and what is **not** deleted. **An active cut is never deleted**, whatever the range
  says.
- **The plugin inventory has a fourth state, «missing from disk».** A plugin registered in
  Moodle's database whose files are gone — Moodle's own «Missing from disk» — used to read as
  «no news», because a missing version was taken as «they match». It is now a critical row
  saying what to do, which is neither running the cron nor uploading a newer version: restore
  the files or uninstall it properly, because while it stays registered its tables and its
  data stay too.
- **A cut now leaves one row in the request log — one per episode, not one per request.**
  The rate limiter runs before the logger on purpose, because a row per rejected request is
  the amplification the limit exists to stop: an IP with 720 rejections would write 720 rows
  into the fastest-growing table in the system. The side effect was that a cut left **no
  trace at all** in the request viewer, which is where anyone investigating «what happened to
  this site» actually looks. Now the first rejection of each episode is logged, with its own
  motive — **«IP at the limit»** or **«manual block»**, kept apart from the per-licence limit
  because the cap, the owner and the fix are different — and the remaining 719 are not. In
  observation mode nothing extra is written: the request goes through and the logger records
  it normally.
- **The plugin park screen ignores switched-off environments too**, across its three tabs,
  with one toggle for all three and the count of what it is leaving out. A decommissioned
  site keeps its last inventory, so without the cut it kept contributing its 470 components
  to the coverage figure and its stale version to the «distinct versions» count — and «3
  versions of local_tresipunt» where one belongs to a site that no longer exists sends
  someone to investigate a divergence that is not there. The Moodle branch list and the type
  dropdown follow the same cut, so they only offer what the visible rows have.
- **The request log cleanup is a modal.** It was a collapsible strip at the foot of the
  viewer with four blocks inside: shut it said nothing and open it took more room than the
  listing, on a screen that exists for looking at requests. And each of its buttons went
  straight to the generic «are you sure?», so choosing the criterion and deciding the deletion
  happened in the same click. The modal now picks the criterion first and shows the exact
  count, the date span and how many unreviewed errors go with it, before offering the red
  button.

### Fixed

- **A site whose front-page description was longer than 255 characters got a 500 on its daily
  telemetry, and lost that day's snapshot.** `data.description` holds the Moodle's own
  description, in HTML, and was declared `varchar(255)`; two real customers had 382 and 664
  characters there — a welcome message with paragraphs and bold — and MariaDB under
  `STRICT_TRANS_TABLES` does not truncate, it raises «Data too long for column». What it cost
  was not the profile row but **the history row**: `DataAction` writes `data` first and
  `environment_stats` second, with no transaction, so the snapshot of that day was never
  written — and the snapshot of a day does not come back, while the profile is fixed by the
  next send. `description`, `imageurl` and `url` are now `TEXT`; the columns that hold Moodle
  codes of a known shape (`language`, `countrycode`, `privacy`, `regioncode`) stay as they
  were, because widening what has a fixed shape only removes a check the database does for
  free. **No test could have caught this**: the suite runs on SQLite, which records a
  `VARCHAR`'s length and then ignores it, so the regression test asserts the **declared column
  type**, which is the one thing both engines agree on.

- **Nothing in `api_blocks` could be deleted while the limits were in observation mode, and
  that is the mode they ship in.** Every deletion path asked for `state = cleared` — «only what
  has been reviewed» — but an episode in observation is born `blocked` and stays there: it cuts
  nobody, nobody was going to review 101 of them by hand, and neither the manual cleanup nor
  the retention task could touch them. In pre that meant 101 rows with no way out and a table
  that could only grow. What is protected is now **what is actually cutting** (`blocked` **and**
  `enforced`); everything else — reviewed or merely noted — is history and is deleted like any
  other history.
- **The «desincronizados» KPI counted 19 and filtering by it showed 2.** It counts sites with
  no fresh data — never synchronised *or* silent for three days — and pressing it enabled the
  «sin señal» filter, which is only the second group. Fixed with the filter that matches what
  the number says, not by lowering the number.
- **A 2002 rejection said «the registration of this environment is unfinished» even when the
  environment existed.** `ProductToken` already told three situations apart — the domain does
  not exist anywhere, it exists with no licence assigned, it exists under another licence of
  the same client — and the panel collapsed all three into the first. The request detail now
  says which one it is and, when the host **is** registered, shows the environment with a link
  to it. Two new reasons, `host_de_otra_licencia` and `host_sin_licencia`, make those cases
  filterable in the viewer instead of hiding inside «Sitio no reconocido».
- **`sitepolicynotagreed` and `usernotfullysetup` from a Moodle are now explained.** They fell
  through to «El Moodle ha rechazado la llamada (sitepolicynotagreed)», which sends support to
  check a token that is perfectly fine: the token is valid, it is the **account** that cannot
  act because it has not accepted the site policies. The message now says so and where it is
  fixed, which is in the client's Moodle.
- **`fresk_premium` no longer appears under «Licenciado y sin instalar».** It is a commercial
  ecosystem, not a Moodle component: its slug can never show up in any site's inventory, so it
  was listed as missing for every client that had it contracted, for ever. The same check keeps
  it out of «Plugins propios».
- **An `ORDER BY` inside the environments list filter builder broke the screen on MySQL.** That
  builder is reused by the facet queries (`select type_id, count(*) … group by type_id`), and
  ordering by a column that is not in the `GROUP BY` fails under `only_full_group_by`. It does
  not fail on SQLite, which is where the test suite runs, so the regression test inspects the
  SQL instead of executing it.
- **Two cached arrays brought the screen down for five minutes after a deploy.** Adding a key
  to something stored with `Cache::remember` leaves the old shape being served until the TTL
  expires, and the view fails with «Undefined array key». Both keys now carry a version
  (`dashboard:mas-grandes:v2`, `environments:composicion:v6`) and say so in a comment, because
  it will happen again on any deploy that changes what is stored.

- **Every rejection from the API middleware was logged with no client, no licence and no
  environment.** `ApiRequestLogger` takes those three from `_license_token` and
  `_environment`, which `ProductToken` injected into the request *after* all of its
  rejections — so the six `403`s (client deactivated, licence switched off, licence not yet
  valid, licence expired, environment switched off, host not linked to the licence) were
  stored with the three columns null while the middleware had them in hand. Those rows could
  not be filtered by client or by site in the viewer, they showed up in the host list as
  «not any environment» — i.e. labelled *a Moodle calling that isn't registered* when it is
  registered and merely switched off or out of contract — and the switched-off-environment
  cut could not reach them. The injections now happen as soon as each value is known. No
  response changed; the client and the licence are attributed in all six cases, the
  environment in the two that have already resolved the host.
- **The default date range in the request viewer ended at the start of the current minute**,
  so a request that had just arrived was invisible for up to 59 seconds and then appeared on
  its own. Both ends are stored with minute precision (`datetime-local`), so parsing the end
  gave `:00`; it is now taken as the end of that minute, which is also what someone typing
  «up to 14:32» by hand means.
- **The `env` colour code was six copies and they disagreed.** The environment page painted
  `pro` orange under a comment claiming it used «the same colour code as the list», and the
  filter buttons painted nothing at all. There is now one palette, in the catalogue, shared by
  the list rows, the environment page, the filter buttons and four more screens.
- **A Moodle with a plugin missing from disk could not send its inventory at all.** The
  `plugins` action wrote `null` into `plugins.type`, `plugins.versiondisk` and
  `plugins.versiondb`, which are `NOT NULL`: with MySQL in strict mode the insert threw
  `SQLSTATE[23000]` and took the whole request down, not just that plugin. It is exactly the
  payload of a plugin whose files are gone — there is no disk version to send — so the sites
  that most needed reporting it were the ones that could not. Those three columns are now
  normalised the way `sync` already normalised them.
- **The «all good» green dot on the monitoring tabs also lit up when there was no data at
  all.** It was painted on «zero pending», and zero pending is indistinguishable from
  «nothing whatsoever» — which is the dangerous case: if the scheduler is not running no
  notice is ever generated, so the tab claimed everything was fine precisely when nothing
  was. Green now requires evidence that the thing works (notices delivered recently, requests
  reaching the API); without it the dot is grey and says so.
- **Deleting rows from the request log left the header counting rows that no longer
  existed.** The indicators are cached per filter key and the purge did not clear that key,
  and the header is the first place anyone looks after deleting.
- The platform count in Configuration › Catalogs links to exactly those environments now:
  the link carried no filter because of a note saying the environments filters did not travel
  in the URL, which stopped being true.

## 2.0.0 — 2026-09-07

First documented release of this repository. It rebuilds the panel around **monitoring and
per-environment diagnosis**, closes 87 catalogued issues and takes the test suite from
nothing usable to **1008 passing tests**.

There is no earlier history here: the previous Manager is a different application in a
different repository (`tip-manager`) and its data is not migrated by this release.

### ⚠️ Breaking changes

- **Response codes are now classified by severity, and `*002` codes are not errors.** A
  client that treated every non-`200` as a failure will now report failures that are correct
  answers: `sin_contenido` means nothing applies to that site and `negocio` means the licence
  does not cover it. The plugin was updated accordingly; any other consumer must be.
- **`environments.version` no longer holds two different things.** It is the Moodle release;
  the database version arrives as `versiondb` and is stored separately. Anything reading that
  column expecting a build number must be revised.
- **The `sites` vocabulary is now `environments`** across the panel, the routes and the API
  payloads.
- **Seeding no longer creates any user unless the environment says who.** The seeder used to
  fall back to three hardcoded addresses, so any installation seeded anywhere created
  accounts in the name of specific colleagues. Set `SEED_ADMIN_EMAIL` (and optionally its
  password) in `.env` **before seeding**; with none set, roles and permissions are still
  seeded and the console says nobody was created. Existing installations are unaffected —
  `firstOrCreate` never touched existing accounts.

### Added

- **SCSS-CDN parameters are now settings**, under Configuration → Manager settings, with the
  values that were hardcoded as defaults: ZIP size cap (50 MB), lifetime of the signed URL
  handed to the plugin (3600 s, minimum 30) and of the one shown in the panel (365 days).
  The ZIP cap was written **twice** with the same number — in the form validation and in the
  service that opens the file; both now read the setting.
- **Delete the whole request history of one site**, from the log cleanup panel, picking the
  site by name or domain. Works for a registered environment **and for a host nobody owns**,
  which had no other way in. A dedicated warning gives the row count by severity, the date
  span, how many errors nobody has reviewed yet, and what is *not* touched.

- **Searchable pickers in the request-log filters.** Client and site are now typed, not
  scrolled: they query the server and return twelve matches at a time, saying how many are
  left out. Sites match on name **or domain** — the domain is what you have in hand when
  reading a log — and are scoped to the selected client.
- **The host filter now has a field, and it lists hosts nobody owns.** Previously `host`
  could only be set by clicking "same host" on a row already on screen, so a host that
  resolves to no environment — an unfinished onboarding, a changed domain — could not be
  searched for at all. Suggestions come from the log itself within the active date range,
  and each one says how many of its requests have no recognised environment.
- **Delete the API errors of one site, known or not**, from the "sites with errors" list
  where they are counted. Severity `error` only, within the visible date range, with the
  count and the dates in the confirmation; batched and audited like every other log purge.

- **Environment page** — a single verdict per site (off, erroring, not syncing, no signal, no
  licence, normal), what it last sent, what it last asked for, its contact details and its
  settings. Plus turning a site on and off with an impact preview, a reason and an audit
  trail.
- **Environment usage** — users, courses and enrolments over time, from the daily snapshot,
  with a comparison range of 7 days to a year.
- **Plugin inventory per environment**, and a park-wide plugin view: which sites have a given
  plugin and in which version.
- **Request log viewer rebuilt on severity**, with per-request detail that answers what
  happened and whether it always happens, the saved response body for failed calls, a
  "mark as seen" flag, and a button that copies a ready-made prompt for debugging.
- **Rate limits and IP blocks** for the API, per IP and per licence, with an observe-only mode
  and a daily digest by email.
- **Manager-to-Moodle calls** — ask a site to sync its plugin inventory now, or to send its
  usage snapshot now, through the plugin's web service. Failures are translated into a
  sentence that says what to do.
- **Support contracts per environment**, with expiry alerts.
- **Deleting a version of versioned content**, in all five types, allowed only when no
  environment is receiving it and it is empty — the button carries a padlock and the modal
  explains which condition is missing.
- **Deleting a product, an environment type and a feature**, none of which had a way in.
- **Last access per user**, recorded on login and distinguishing "never signed in" from "not
  recorded yet".
- **Upload limits shown in the panel**, including the web server layer that PHP cannot read.

### Fixed

- **Both creation wizards had three "Next" buttons meaning two different things** — the
  header, the client list pagination and the card footer. The header now only cancels, each
  step's button names the step it leads to, and the pagination says "previous/next page".
  The environment wizard's header also kept offering "Next" on step 2, where it validated
  and did nothing: it looked like the primary action and was dead.
- **The licence wizard could dead-end on a client with no environments**: step 2 defaulted to
  "existing environment" and its validation then demanded one from an empty list. It now
  starts on "new environment" and disables the other option, saying why.
- **Adding a contact and leaving it blank stopped a client from being saved.** The rule was
  `required_with:contacts.*`, which resolves to the row itself — always present — so the name
  was always required. The blank-row filter existed but ran *after* validation. A partly
  filled row is still rejected, which is the distinction that matters. Client form messages
  are now in Spanish, like the rest of the panel.

- **Creating an environment type works.** The screen opened fine and threw as soon as you
  typed in the first field: the component had neither the form properties nor `store()`. It
  had never worked, which is why the four existing types come from the seeder. The
  `shortname` field now explains what it is — the key `sync` uses to resolve the type a
  customer plugin reports — and the uniqueness error says the name stays taken even when the
  type that held it was deleted (the schema index does not exclude soft-deleted rows).
- **Filter and form pickers no longer read whole tables on every render.** The client and
  site pickers in the request log — and the client picker in the environment form — ran
  their queries inside the Blade template, so every keystroke in any debounced field
  re-read and re-serialised the table. The environment form was the last live screen
  picking a client that way; the licence screens already searched server-side. How clients
  are searched now lives in one place, and a test fails if any template queries a model
  again.

- **Deactivating a client or an environment did not cut the service**: the API only checked
  the token. It now checks the whole chain.
- **No environment could be switched off from the panel**: the "active" checkbox was
  validated in a way that made unchecking it an error.
- **The eight copy-to-clipboard buttons did nothing over HTTP**, because the modern clipboard
  API only exists in a secure context and the error went to the console.
- **A `sync` was rejected with "host not linked to this token"** because of a domain
  normalisation mismatch.
- **The whole daily payload of every site was being discarded**: a date sent as `2026-09-4`
  failed validation, so no usage data was ever stored. Any date shape is accepted now.
- **An absent or empty field no longer keeps the previous value**: a contact email from May
  was being shown as current.
- **`versiondb` arrived on every sync and was thrown away**, and the dashboard invented five
  Moodle branches that do not exist.
- **The dashboard counted expiring licences twice**, showed an empty notices block, and
  reported sites "with active support" by a flag rather than by contract.
- **Blocking an IP by hand crashed on MariaDB** and could only be done for IPs that had
  already called.
- **Purging the request log said nothing at all**: the panel rendered no flash messages.
- Plus corrections to searching, filtering, ordering and permissions across the panel — the
  full list is catalogued internally.

### Changed

- **Entry-point detection on ZIP upload marks a file only when there is exactly one
  candidate.** It first marked every file nobody imports; a theme points at one entry point
  in its configuration, so two candidates almost always mean a stray file in the ZIP — and
  marking it silently hides that. With two or more, nothing is marked and they are named.

- **Environment list filters live in the URL**: sixteen filters that now survive a reload and
  can be shared as a link.
- **Content editing warns about what Moodle will strip.** Pasting an `<iframe>` used to be
  accepted and silently removed when the site rendered the page; it is now refused at save
  time, pointing to Tutorials for video.
- **API errors also reach the server log** (`storage/logs/api.log`), so an integration can be
  diagnosed with `tail -f` instead of opening the panel. Only real errors are written.
- Licences can be searched by environment name and domain, and an environment links to its
  licence.

### Security

- **A crafted bundle ZIP could write outside its own folder.** Entry names were normalised
  for separators and a leading slash but **not for `..`**, so an entry such as
  `a/../../../another-product/2026010100/main.scss` landed in **another product's bundle** —
  and that tampered CSS is what the API then serves to that other customer's Moodle. Fixed
  in two layers: the storage path builder refuses any `..` segment (so every door is
  covered, not just the upload), and a ZIP containing such an entry is rejected whole.
  Flysystem did not catch it: its traversal guard protects the **disk root**, not the folder
  we consider the boundary.
- **Signed CDN URLs can no longer be persisted into stored SCSS.** Three parser functions
  substituted the `@import`s and **saved the result**, signing with the 365-day default: a
  bundle processed that way would carry URLs that die after a year, breaking the customer's
  theme on that day with nothing failing visibly. They were unreachable and also referenced
  non-existent columns, so it never happened; they are gone, and `saveFile()` now refuses
  content carrying a signed CDN URL so it cannot come back.

- **Customer Moodle web service tokens are encrypted at rest** and never displayed again.
- **Route permissions are now enforced on Livewire calls too.** Previously the only check on
  a mutation was being authenticated.
- **Seeder passwords are no longer in the code**; they come from the environment. Neither are
  the email addresses and names: with no `SEED_*_EMAIL` set, that user is not created at all.
  They are read from `config/seed.php` rather than `env()` inside the seeder, so they survive
  `config:cache` — after which `env()` returns null outside config files.
- **Password reset and email verification are throttled per IP.** The limit used to be per
  email address only, which made trying a thousand addresses as cheap as trying one.
- **SVG uploads are rejected in the content editor**: it can carry executable code and was
  served from the panel's own origin.
- Five dead settings were removed from the database, one of which held a username and
  password in clear text — and the support alert recipient was stored in one of them, so
  alerts were going to the wrong team.

### Removed

- **Twenty permissions that gated nothing.** Duplicates of another permission under a
  different name (`admin.types.edit` vs `admin.configurations.environments-types.edit`),
  leftovers of a screen that was removed, and the three `api.*.store` the API never checked —
  it authenticates by licence token. **Nobody loses access**: no screen was checking them.
  Seven more that gate nothing on purpose stay, now declared with the reason in
  `RoleSeeder::PERMISOS_DE_ROADMAP`; the role editor marks them "no screen yet", and a test
  fails if a new permission gates nothing and is not declared.

- **Three unrouted Livewire components** (`Environments\Create`, `LicenseTokens\Create` and
  `LicenseTokens\Components\LicenseForm`) with their views and their imports. The live
  screens are the wizards. The token-format test named the four screens one by one and went
  stale as soon as two were gone; it now sweeps the code instead, asserting the `3IP-` prefix
  is built nowhere but the model.

- The "environment types" screen as the section entry point, twelve dead Livewire files and
  a duplicated data table component.

### Notes

- **PHP 8.4 is required.** The declared minimum used to be 8.2 and was wrong: the installed
  dependencies abort on 8.3.
- **The database must be MySQL 8 or MariaDB 10.6+.** The schema uses a generated `STORED`
  column and a partial unique index.
- **Back up the database before the first `migrate`.** Five migrations modify or delete data,
  and two of them do not restore anything on rollback, by design.
- **Never run `key:generate` on an existing deployment**: it destroys every stored customer
  credential.
- **The front end build is not optional.** The panel uses Tailwind; unbuilt CSS renders a
  broken layout.
- **This release starts measuring, not enforcing.** API limits do not refuse anything
  (`limits:enforce = 0`) and log retention is `0`, so nothing is deleted automatically. Both
  are tuned from the panel without deploying.
- **Alert recipients start empty** and must be set from the panel; the screen says so in red
  until they are.
- **One cron line is enough** — `schedule:run`. The four scheduled tasks are declared in the
  application with their own times.
- **"Last access" will read "not recorded" for everyone** until each person signs in once.
  That is correct: the data was not stored before this release.
- Known limitation: **versioned content has no draft state**. Any version that is created is
  served immediately. Scheduled for 2.1.
