# Updates

How a clinic gets a new release, and what happens when one goes wrong.

## The contract

**A clinic is either on the old version or the new one, never between them.**

Every step past the point of no return is paired with the means to reverse it,
and the reversal runs automatically. An operator who has just watched an update
fail is not the right person to be typing recovery commands.

## Clinic is its own product

The licence server hosts several products behind one update API and treats a
client that names no product as the **TaxPilot CMS** — a different ~195 MB
Laravel application with its own release history. Clinic sends
`product=clinic` on every call, and `config/updates.php` sets it literally with
no `env()` behind it: a clinic that could change it could pull down another
product's release and extract it over itself.

This required three additive changes on the licence server:
`UpdateVersion::PRODUCT_CLINIC`, widening the `in:cms,ai` validation, and a
`clinic-update-v1` entry in `SignUpdatePackage::MANIFEST_PREFIXES`.

## The signature is the whole security model

Everything else — TLS, the HMAC on the API, the signed download URL — protects
the *channel*. Only the signature protects against the channel itself being
wrong: a compromised licence server, hijacked DNS, a proxy rewriting responses.
The private key lives on a machine that is **not** the licence server, so none
of those yields the ability to produce a package that verifies.

The canonical manifest, rebuilt locally and compared byte for byte:

```
clinic-update-v1|{version}|{sha256}|{file_size}
```

Every field earns its place:

- **prefix** — one offline key signs Clinic and TaxPilot alike, so without it a
  genuine, correctly signed TaxPilot package would verify here.
- **version** — stops a signature being lifted from one release and replayed
  onto another, which is how a known-vulnerable older package gets served as the
  newest.
- **digest and size** — bind the signature to the exact bytes on disk. The
  digest is computed from the *downloaded file*, so the signature attests to
  what we hold, not to what the server claimed it would send.

A build with no embedded public key installs **nothing**. Treating a missing key
as "skip verification" is how a packaging mistake turns into every clinic
installing whatever it is offered.

## Generating the release key

**`config/updates.php` currently ships no public key, deliberately.** Until one
is generated, `PackageSignature` refuses every package with `no_public_key` and
the updater installs nothing. That is the correct state for a build with no
trustworthy key.

The first Clinic keypair was generated on the machine that also serves this
application and the licence server — precisely the arrangement this design
exists to prevent, since one compromise would have yielded both the key and the
distribution channel. It was destroyed rather than shipped, and its fingerprint
is blacklisted in `ReleaseKeyProvenanceTest` so it cannot be pasted back.

To create the real one, copy **`tools/generate-release-key.php`** to a machine
that serves nothing — not the licence server, not a clinic's host, not the box
running Apache — and run it there:

```bash
php generate-release-key.php ./clinic-signing
```

It is standalone: no Laravel, no Composer, no network. It generates a 4096-bit
RSA pair, passphrase-protects the private half, proves the pair signs and
verifies before writing anything, prints the passphrase once, and prints the
exact block to paste into `config/updates.php`.

Only the public half travels back. The private key and its passphrase stay on
that machine, kept apart from each other — anyone holding both can produce a
package every clinic will install without question.

Rotation, once a customer release exists, means shipping the new public key in a
build **before** the first release signed with it: clinics verify against the
key baked into the version they are currently running, not the newest one.

## Order of operations

Expensive-but-safe work first, dangerous work last.

| Stage | Reversible by |
|---|---|
| Preflight | nothing to reverse |
| Download + verify | deleting a file |
| Back up files and database | deleting a file |
| Extract to staging | deleting a directory |
| **Swap** | rollback |
| **Migrate** | rollback |
| Finalise | rollback |

`InstallStage::hasTouchedApplication()` is the dividing line. Before the swap a
failure is a no-op and the installer simply stops; from the swap onward it rolls
back automatically. Rollback restores **files first, then the database** —
the other order leaves restored data being read by new code.

Stages are persisted to `storage/app/private/updates/state.json` because a
clinic's shared host will not run a download, a full backup, an extraction and a
migration inside one PHP timeout. State lives in a file rather than a table
because the installer runs migrations, and state in the database is unreadable
at exactly the moment a failed migration makes it most needed.

## Backups

Both are taken before the swap, and both are what rollback uses.

The **database dump** is written in PHP, not by shelling out to `mysqldump`.
Clinics run on cPanel where `exec` is routinely disabled; a safety net that only
works on hosts allowing shell access is no safety net on the hosts that matter.
Rows stream through an unbuffered cursor in batched inserts, because a clinic
with years of encounters will not fit its database in a shared host's memory.

The **file archive** covers everything the swap replaces and nothing it does not.
`storage/` is excluded: it holds the clinic's uploaded documents, the swap never
touches it, and including it would inflate every backup by their entire upload
history — and make the archive contain itself.

## What is never replaced

`config/updates.php` → `preserve`. Losing `storage/` loses every uploaded patient
document. Losing `.env` loses the database credentials and `APP_KEY` — which also
makes the licence file and every encrypted column unreadable.

## Safety rails

- **Clinic hours.** Installing is refused during the clinic's own opening hours,
  07:00 to 20:00 unless the installation says otherwise. The system goes offline
  and migrations run against patient data; doing that mid-morning is a clinical
  risk, not an inconvenience. `--force` overrides this and *only* this — it
  never relaxes a licence, signature or integrity check.

  The window is set per installation, because an evening practice given the
  shipped one would be blocked exactly when it is empty:

  ```
  UPDATE_WINDOW_START=20
  UPDATE_WINDOW_END=7
  ```

  A window that runs past midnight, as that one does, is understood as such.
  Setting both to the same hour switches the guard off altogether — correct for
  a demonstration site with no patients, wrong for a clinic, and deliberately
  not offered anywhere in the interface. An hour outside 0–23 is ignored and the
  shipped window used instead.
- **Licence state.** Updates require an active licence. Unlike the read-only
  ladder in [LICENSING.md](LICENSING.md), refusing an update costs a clinic
  nothing clinically: they keep the working version they already have.
- **One at a time.** A second install cannot start over a running one.
- **Disk space.** Checked against several times the package size, because the
  download, the extraction and the backup all coexist.
- **Path traversal.** Archive entries are checked for `..` and absolute paths
  before extraction. Packages are signed, so a hostile one means our key has
  leaked — but a control that only holds while the key is safe is not a control.
- **Release notes are escaped, not rendered.** They arrive over the network, and
  an update server able to inject markup into an admin's browser would have a
  clean path to the whole system.

## Checking versus installing

Checking is safe, cheap and scheduled daily. Installing is **never** automatic —
not even for a release the server marks `force_update`, which is surfaced to the
operator as emphasis and never acted on. A human chooses when a clinic goes
offline.

## Commands

```bash
php artisan update:status
```

```bash
php artisan update:check
```

```bash
php artisan update:install
```

```bash
php artisan update:rollback
```

Rollback restores the database as well as the files, so anything recorded since
the update is lost. That is stated at the prompt rather than buried here, where
nobody reads it at the moment they need it.

The browser equivalents exist because clinics run on cPanel where SSH is
frequently unavailable, and an updater only reachable from a shell is one most
customers cannot use. Prefer the console where there is one — a browser install
is a very long request.

## Why `config:cache` is not run afterwards — but `config:clear` is

`finalise()` clears the config, view, route, application and module caches, and
deliberately does not *build* a config cache. Baking the configuration writes
`DB_DATABASE` into `bootstrap/cache/config.php`, after which Laravel ignores
`.env` entirely — which is what makes the test suite unable to redirect itself
away from the live database. The same trap is documented in
[DEPLOYMENT.md](DEPLOYMENT.md).

`config:clear` was missing until 2026-09-09, and that was a real gap rather than
a tidy-up. The argument above is about not *creating* a cache; it says nothing
about one that is already there, and on cPanel there always is — caching the
config is the documented step for that host. So a release that changed anything
under `config/` swapped the files in and then carried on reading the stale
cache. Nothing failed: the update reported success, the files really were new,
and the clinic kept running the old settings — including the old version
string, so it would be offered the same release every day for ever.

## Releasing (for us, not for clinics)

1. Build the package:

   ```bash
   npm run build              # public/build is gitignored and must be current
   php tools/build-release.php 1.1.0
   ```

   The script exports the committed tree, adds `public/build`, drops the test
   directories, stamps `clinic.product.version` inside the package only, writes
   the ZIP with forward slashes, and reads it back to check. It refuses to run
   on a dirty working tree, because a package that does not match the commit it
   claims to be cannot be worked out afterwards.

   Tests are excluded deliberately: a clinic has no use for them, and a malware
   scanner on a customer's host quarantined one by removing its read
   permission, which broke the pre-update backup and stopped the update dead.

2. Upload it to the licence server as **product `clinic`**.
3. Sign it on the offline key machine. The key is passphrase-protected; omit
   `--passphrase` to be prompted without echo rather than leaving it in shell
   history:

```bash
php artisan updates:sign 1.1.0 --product=clinic --key=/path/to/clinic-release-private.pem
```

4. Publish it.

Set `min_supported_version` **only** when a release's migrations assume an
earlier upgrade already ran, and leave it blank otherwise. An install below the
floor is not offered the release at all — so a floor of `1.0.0` hides the
release from a `1.0.0-dev` install, which is how the first Clinic release was
briefly invisible to the one clinic it was built for. `version_compare` puts
any pre-release below the version it precedes.

**The private key must never be stored on the licence server.** If it were, one
compromise would yield both the key and the distribution channel, and signing
would prove nothing.
