# Windows installer

Builds a single self-contained `.exe` that installs ShopDB-Flask on an
**air-gapped** Windows Server. Nothing here ever touches the network at install
time: Python, the wheels, the SPA and (optionally) MySQL all ship inside it.

Lives with the application on purpose. The installer depends on app internals -
`flask plugin` verbs, `site-profile.json`, `MOUNT_PATH`, the plugin registry -
so a separate repo would drift out of step with the thing it installs.

## Files

| File | What it is |
|---|---|
| `shopdb-preflight.ps1` | Stage 1. Read-only. Changes nothing, reports what this server is missing. |
| `shopdb-install.ps1` | Stages 0 and 2-5 plus `uninstall`. All the actual work. |
| `shopdb-admin.ps1` | Operator console installed alongside the app: status, restart, logs, backup, plugins. |
| `ShopDBFlask.iss` | Inno Setup wizard. A thin wrapper - it collects input and runs the stages. |
| `build-installer.sh` | Stages the bundle from a site profile. |
| `make-branding.py` | Generates wizard artwork and the icon from `frontend/public/*.svg`. |
| `*.bmp`, `shopdb.ico` | Generated artwork, committed so a Windows build box needs no Python. |
| `build-installer.ps1` | The same build, natively on Windows. No Bash needed. |
| `bundle-lock.json` | The exact third-party payload this installer ships. Reviewed by commit. |
| `bundle-lock.ps1` | Creates and checks that lock. Also runs on the target server. |
| `refresh-bundle-lock.ps1` | Regenerates the lock, after showing what changed. |
| `verify_bundle_lock.py` | The same check for the Bash builder, so Linux needs no pwsh. |

## Building

Two builders, same result. Use whichever machine you are on - `build-installer.ps1`
does the whole job natively so a Windows work PC needs no Bash.

```bash
# Linux / WSL
./build-installer.sh ../../site-profile.example.json
```

```powershell
# Windows
.\build-installer.ps1 -Profile ..\..\site-profile.example.json
```

Both stage the app tree, build the SPA twice, write `plugins.iss`, copy the
installer scripts **from this directory**, and then verify the third-party
payload against `bundle-lock.json`. They exit non-zero if it does not match.

The payload itself is added by hand, and no longer needs Windows to produce:

```
bundle/wheels/               pip download -r requirements.txt --only-binary=:all: \
                               --platform win_amd64 --python-version 314 \
                               --implementation cp --abi cp314 -d wheels
bundle/python/               python-3.14.x-amd64.exe
bundle/httpplatformhandler/  httpPlatformHandler_amd64.msi
bundle/urlrewrite/           rewrite_amd64.msi        (client-IP rule; see below)
bundle/mysql/                mysql-8.0.x-winx64.msi   (bundled-database option only)
```

Then compile on Windows:

```
iscc ShopDBFlask.iss
```

**Inno Setup 6.6.0 or newer.** The wizard uses the built-in `windows11` custom
style, which earlier versions reject. The script fails at compile time with that
sentence rather than with a bare "WizardStyle is invalid".

The wheelhouse is **cp314-locked**. A different Python minor version means a
different wheelhouse; the installer will not use a Python it did not install.

## What is pinned, and where

Three layers, because no one of them covers the whole problem.

| Layer | Covers | Enforced |
|---|---|---|
| `requirements.txt` sha256 per package | every wheel is genuinely what upstream published | `pip --require-hashes` at install; aborts on mismatch |
| `bundle-lock.json` | the EXACT payload: wheels, Python installer, MSIs | both builders, and again on the server before anything runs |
| git | the application tree | code review |

`--require-hashes` alone is not enough. pip lists every artifact of a pinned
version - `cffi 2.1.0` has 100 hashes - so it proves the wheel is genuine, not
that it is the wheel this bundle was built and tested with. It also ignores
extra files in the wheelhouse, and says nothing about the Python installer or
the MSIs, all of which run as SYSTEM on the target server.

So `bundle-lock.json` records an exact file set with a sha256 and a byte size
each, and verification is **set equality**: a missing file, an unexpected extra
file, or changed content all fail. There is no install-time override.

The lockfile is `--universal`, so one file serves Linux (dev, Docker, CI) and the
Windows wheelhouse. A Linux-only resolve had silently omitted `colorama`, a
win32-only dependency of `click` - which in hash-checking mode is a hard error
rather than a quiet omission.

### Changing what ships

```powershell
.\refresh-bundle-lock.ps1        # show what changed, write nothing
.\refresh-bundle-lock.ps1 -Yes   # write it
```

Then **commit `bundle-lock.json`**. That commit is the review - it is the only
place a change to what runs as SYSTEM on a customer's server becomes visible to
a human. `refresh-bundle-lock.ps1` refuses to overwrite an existing lock until
you have seen the diff, for that reason.

To stage a bundle before its lock exists: `ALLOW_UNLOCKED=1` (Bash) or
`-AllowUnlocked` (PowerShell). Bundles built that way must not be shipped.

Verifying a live server, months later and offline:

```powershell
shopdb-admin.ps1 verify
```

## Client IP addresses

IIS does not set `X-Forwarded-For` on its own, and HttpPlatformHandler connects
from loopback. Without a rule, **every client reads as 127.0.0.1** - so the
GE-Enforce IP allowlist, the dashboard's visitor-location lookup and per-host
login rate limiting all stop working, silently.

The wizard asks, because the two answers are mutually exclusive:

- **Clients connect directly** (`-ClientIpSource direct`, the default) - installs
  URL Rewrite from the bundle and sets `X-Forwarded-For` from `REMOTE_ADDR`.
  Overwriting the header is what stops a client spoofing its own.
- **A proxy sits in front** (`-ClientIpSource proxy`) - leaves the rule off.
  Behind ARR or a load balancer `REMOTE_ADDR` is the *proxy*, so applying the
  rule would discard the real client IP.

An existing `web.config` is never overwritten - it is the one file on a server
that legitimately carries hand-edits. The installer reports what it found
instead.

## Deployment methods

Chosen in the wizard, and the bundle carries a SPA build for each because Vite
compiles the base path in - it cannot be switched at install time.

- **Its own site** on a port (default 8090).
- **Subpath** under an existing site, e.g. `http://<server-fqdn>/shopdb/`. Needs
  no new DNS record. Three things must agree - the IIS application alias,
  `MOUNT_PATH` in `.env`, and the SPA's build-time base - so the alias is fixed
  per bundle (`SUBPATH_ALIAS`, default `shopdb`) and the installer refuses if the
  bundle's build does not match what was asked for.

Switching between methods removes the other one's IIS artifact and reconciles
`MOUNT_PATH` and `CORS_ORIGINS`, so a server never ends up with both.

## Upgrades

Run a newer installer over an existing install. It:

- backs the database up first, **verifies** the dump is complete, and refuses to
  migrate if it cannot;
- restores from that backup if migrations fail, and reports honestly that DDL
  the failed migration committed cannot be undone;
- refuses to run a bundle older than what is installed;
- keeps `.env` unless new credentials are supplied, and copies it aside first;
- stops the app pool before replacing files, then starts it again.

Whether a run is an upgrade is decided by probing the **target database**, not by
whether the app directory exists - a rebuilt server pointed at an existing
database is an upgrade, and treating it as fresh would drop tables.

## Testing notes

Verified end to end on Windows Server 2025 against both a bundled MySQL 8.0 and
an existing MySQL 5.6: fresh install, upgrade, re-run idempotency, failure and
rollback, uninstall, and both deployment methods including switching between
them.

**Not yet verified:** a fully air-gapped run with the network disabled at the
hypervisor, and any load in a real browser (all HTTP checks so far used curl,
which sends no `Origin` header - so `CORS_ORIGINS` is untested in anger).
