Commit Graph

422 Commits

Author SHA1 Message Date
cproudlock
cb18d170cf Say whose type it is
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Two fields on the same page were both labelled "Type": the asset's own, and the
catalog model's. Only one of them was vague. "Model type" already says exactly
what it is; the bare "Type" did not say whose.

So the unqualified one is the one that changes. No new vocabulary, and "Model
type" reads correctly against it:

  Type  ->  Machine Type      (machines)
  Type  ->  PC Type           (computers)
  Type  ->  Printer Type      (printers)
  Type  ->  Device Type       (network devices)

Left alone everywhere the word is not ambiguous - measuring tools, subnets,
VLANs, notifications, supply types and the manifest editor have no model type on
screen to be confused with.

This is a labelling change only. It does not address the blank type column on
machines imported from the classic ASP database, which is a data gap the
backfill script fills; renaming a column heading was never going to put values
in it.
2026-08-05 10:33:47 -04:00
cproudlock
24266146d8 Show the model's type only when it differs from the asset's own
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Adding a "Model type" row next to "Type" put two rows in the Hardware section
that read identically. They come from different tables - modeltypes is the
catalog-wide list spanning every kind of asset, machinetypes is machine-only -
but the names line up in practice: all 262 machines in the development database
match exactly, which is the same fact that makes the type backfill safe.

So the row now appears only when the two disagree, which is the case worth
seeing: a model catalogued as one thing fitted to an asset recorded as another.
When they agree it says nothing and is hidden. Applied to machines, PCs,
printers and network devices, each compared against its own type table.
2026-08-05 10:16:21 -04:00
cproudlock
58b460fe3d Backfill an asset's type from its model, by exact name only
Correcting an earlier judgement. I said the model's type could not be used to
fill an asset's type, because modeltypes is the catalog-wide list covering every
kind of asset - it holds "Access Point", "Camera" and "Desktop PC" - and only
about two thirds of its names exist as machine types.

That is true across the whole catalog and misleading in practice. Restricted to
the models an asset class actually uses, the picture is different: all 262
machines in the development database map exactly, because the non-machine
entries are never used by machines. The blanks on the machines list are rows
whose type the database could already have supplied.

So the backfill now fills the type as well, under a rule that cannot mistype
anything: exact name match or nothing. A model type with no identically named
entry in the asset's own type table is reported with a count and left untouched,
so somebody can decide rather than have a guess written into their data. The
same shape covers computers, printers and network devices, each against its own
type table.

Verified against the development database by nulling one machine's type inside a
transaction: it was detected as fillable, the proposal read "LocationOnly" ->
"LocationOnly", the update restored exactly the original id, and the rollback
left the row unchanged.

Still a dry run unless given --commit, and a table missing the model column is
skipped, so it runs against a server whose network migration is not yet applied.
2026-08-05 10:11:50 -04:00
cproudlock
3f320fcc8b Derive an asset's vendor from its catalog model, and show the model's own type
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
An asset that carries a model but no vendor was showing a blank the database
could already answer: the model records its vendor, and both sides reference the
same vendors table. Machines, PCs, printers and network devices now fall back to
it.

The fallback is FLAGGED, not merged silently. to_dict sets vendorfrommodel and
the detail pages render "(from model)" beside the value, because the record
itself is still empty: the edit form shows an empty vendor box, and a page
implying the vendor is stored would be lying about where it came from.

The model's type is exposed under its own name, modeltypename, and shown as a
separate "Model type" row. It is deliberately NOT used to fill in the asset's
own type. modeltypes is the catalog-wide list covering every kind of asset - it
holds "Access Point", "Camera" and "Desktop PC" alongside the machine entries -
so it is a different taxonomy from machinetypes. Only about two thirds of the
names overlap, and mapping one onto the other would mistype the remainder, with
the failure mode being a machine labelled "Desktop PC".

scripts/backfill_vendor_from_model.py writes the derived vendor down for real,
since the display fallback leaves reports that read vendorid still seeing
nothing. It is a dry run unless given --commit, fills only rows where the
asset's vendor is NULL and the model names one, and never overwrites a vendor
somebody chose. It skips a table lacking either column, so it runs against a
server whose network migration has not been applied yet.

Verified against the development database by nulling one machine's vendor inside
a transaction: it was detected as fillable, restored to exactly its original
value, and the rollback left the row untouched.

FLASK_ENV is not forced by the script. The app already reads it from .env, and
overriding it demanded a SECRET_KEY the environment had no reason to supply.
2026-08-05 09:59:39 -04:00
cproudlock
f8c4246483 Fix model photo upload, and give network devices the model link the page assumed
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
Three faults around vendor-model photos, found while looking at why an uploaded
image did not appear.

Saving a model was blocked after uploading a photo. The Image URL field was
type="url", and an upload sets it to an application path such as
/api/models/image/model-120.png. Native url validation demands an absolute URL
with a scheme, so the browser refused to submit the form with "Please enter a
URL" for a value the page had just written itself. The field is now type="text",
which is what it always needed to be: it holds either a full web address or a
path on this server. documentationurl stays type="url".

The upload button did not appear when adding a model, only when editing one.
That was deliberate - the photo is stored as model-<id>.<ext>, so it cannot be
sent before the record has an id - but it reads as a missing feature, and the
hint explaining it was easy to miss. A photo chosen while creating is now held
and uploaded as soon as the model is saved, and it is dropped if the dialog is
cancelled, so it cannot land on the next model created in the same session.

Network devices could never show a photo. NetworkDeviceDetail.vue binds its hero
image to networkdevice.imageurl, but networkdevices carried only vendorid, with
no link to a catalog model, so nothing could populate it - a feature that looked
present and could not work. Machines, PCs and printers have carried
modelnumberid since July. This adds the same column and relationship, the
to_dict branch that exposes modelname and imageurl, the field on the API, and a
Model selector on the form so the link can actually be set.

The migration is guarded the same way employees0002photo is: on a fresh database
the tables come from the SQLAlchemy models, which already declare the column, so
an unconditional add fails with "duplicate column name". The foreign key is
created only on databases that can add one by ALTER; routing it through
batch_alter_table made Alembic's column sort raise "Circular dependency
detected" on the fresh-database test.

Deploying this needs `flask db upgrade` and `flask plugin upgrade-all` on the
server, not just a file copy.
2026-08-05 09:08:40 -04:00
cproudlock
92a90fcec6 Document publishing the installer as a release asset
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
The exe is attached to a release, never committed: most forges reject files over
100 MB inside a repository while allowing release assets far larger, and a
committed binary would sit in every future clone forever.

Also records that tags must be pushed explicitly. A plain push of the branch
does not carry them, so a release had nothing to hang off.
2026-08-05 07:44:57 -04:00
cproudlock
89e880afc3 Release 0.8.0
Some checks failed
CI / backend (push) Failing after 6s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
The Windows installer has never shipped under a version: v0.7.0 was tagged
before any of it existed, so every build handed out so far stamped a server with
0.7.0. Two servers running different builds were indistinguishable, and the
installer logged each upgrade as "same version already installed" rather than
recording what changed. This cuts the release that fixes that.

0.8.0 rather than a patch: the air-gapped installer is a new capability, and
pre-1.0 semantic versioning puts that in the minor slot (ADR-007).

CHANGELOG gains a 0.8.0 section covering the twelve defects a real Windows
Server 2019 install surfaced, the move from inferring "is this a re-run of my
install?" to recording it, and the operator documentation.

deploy/site-profile-universal.json is now in the repository. Released builds
were being produced from a profile in a temporary directory, so the next release
could not have been reproduced once that file was cleaned up.
docs/RELEASING-WINDOWS.md points at the committed profile and says why.

scripts/gen_openapi.py reads __version__ out of shopdb/__init__.py instead of
restating it. Its hardcoded copy had already drifted a release behind, which is
the same mistake that once shipped an installer stamped with the wrong version.
v0.8.0
2026-08-05 07:34:05 -04:00
cproudlock
fb53161578 Answer "is this a re-run of my install?" from a record, not from the machine
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
The installer inferred that question from whatever the server happened to look
like: a MySQL service exists, the database has tables, the site exists, the venv
exists. None of those record who created them. A retry after a failed first
install was therefore taken for an upgrade of somebody else's working system,
which produced two dead ends on exactly the retry the wizard invites: stage 3
demanded a mandatory backup of a database its own failed attempt had written,
and then refused to prune tables it had created minutes earlier, because core
migration 7d05 seeds access protocols owned by the computers plugin and any
profile without that plugin hit the refusal every single time.

An install record at ProgramData\ShopDB-Flask\install-state.json answers it
instead. It is written when provisioning STARTS rather than when it finishes,
because the run that dies halfway is precisely the run whose retry needs it, and
it records what this installer created as it goes, so a crashed run no longer
leaves the next one guessing from the machine.

During unfinished first provisioning the pre-migration backup becomes advisory
and prune may force, since every row present was written by an earlier attempt
of the same install. On an established install both stay exactly as they were.
The classification is deliberately asymmetric: an install predating this record
carries a version stamp and probably real data, so it is treated as established
and keeps the mandatory backup. Guessing "first run" there would arm
prune --force against live tables.

Get-CreatedItems comma-protects its return. A zero-length array returned from a
PowerShell function unrolls to $null, and $null.Count is fatal under StrictMode
2.0 - the same fault that made bundle verification fail on every install
earlier. The harness caught it before it shipped.

Tests: deploy/windows/installer/tests/test-install-state.ps1 exercises new
servers, retries, completed installs, unrecorded-but-stamped installs, records
naming another directory, corrupt records, and persistence across a crash.
tests/test_installer_state.py runs it wherever pwsh exists and asserts the
invariants as text everywhere else. Both were confirmed to fail when the prune
gate or the comma protection is removed.

pytest.ini stops collection walking into deploy/windows/installer/bundle, which
is build output holding a complete second copy of the application. Importing
every plugin twice made SQLAlchemy refuse a redefined table and the whole suite
fail to collect, on a tree with nothing wrong in it, purely because an installer
had been built first. It surfaced only when the bundle grew from four plugins to
thirteen.
2026-08-04 21:42:49 -04:00
cproudlock
412c2dc877 Record the code-signing decision
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
Waiting for a certificate from the organisation's own certificate authority
rather than buying one from a public CA. Every server this installer runs on is
centrally managed and already trusts that root, so an internally issued
Authenticode certificate removes the unknown-publisher warning exactly where it
matters; a public certificate would buy trust on machines this software never
reaches.

Notes the interim measure that costs nothing: publish the SHA-256 through a
channel separate from the installer, since a hash beside the file is only as
trustworthy as write access to that location.

Wording avoids naming internal infrastructure, since docs/ is published.
2026-08-04 21:25:14 -04:00
cproudlock
1c04ff28b9 Close the remaining installer review findings
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Eleven findings, grouped by the root cause each belongs to.

Wizard input reaching a command line unchecked (ShopDBFlask.iss). Port fields
were spliced in bare and arrive as [int] parameters, so a blank or mistyped
port shifted every argument after it; both port fields are now validated as
1-65535 digits. A path ending in a backslash, which is what a drive root looks
like, ended its argument with \" and CommandLineToArgvW read that as an escaped
quote, so paths are now quoted through a helper that doubles the trailing
backslash. A drive root is refused outright as well: uninstall deletes the
application directory recursively, so installing to D:\ would have wiped the
drive on removal. The password handoff was written with SaveStringToFile, which
writes an AnsiString, and read back as UTF-8, so a correct non-ASCII password
was reported as wrong; it now goes out as UTF-8 without a BOM.

Launching without checking the result. Plugin deregistration invoked "flask
plugin uninstall" without --yes, and the command carries a click
confirmation_option that aborts with exit 1 when nothing can answer the prompt,
so it could never once have succeeded; the bare 2>&1 under EAP Stop then turned
that into a terminating error which the catch downgraded to a warning while the
plugin directory was deleted regardless. It now passes --yes, brackets the
error preference, restores the location in a finally, and keeps the code on
disk unless deregistration actually succeeded. MarkShortcutRunAs had four
quotes where it needed three, which kept the whole command inside one Pascal
literal so LnkPath was never interpolated and no shortcut ever got the
elevation flag; its exit code is now logged too.

Comparing IIS physical paths as raw strings. IIS stores the path as typed, so
it may carry environment variables or a trailing backslash. A Test-SamePath
helper now normalises both sides. That closes a real hazard in uninstall, which
matched applications on alias alone and would remove an unrelated application
of the same name under another site, unattended, since -OnFailure never
suppresses the confirmation.

Accepting existing IIS state without reconciling it. "Site already exists" took
the site however it was, so re-running with a different port left the old
binding while CORS_ORIGINS, the firewall rule and the smoke test all used the
new one, failing a working server. It now refuses with both ports named rather
than silently re-binding, and refuses a site of that name serving a different
directory.

Preflight rows drawn past the panel. The failures loop had no cap at all and
the warnings loop capped at 6, a number unrelated to the panel, which holds
about three rows. The cap is now measured from the panel height, applies to
both loops, and the footer counts what was actually left out instead of
inferring it.

Also: a failed upgrade now says the application pool is still stopped and how
to start it, rather than only "part-configured", since stage 2 stops a pool
that was serving. It is deliberately not restarted automatically, because after
a stage 3 failure the deployed code and the schema may disagree. shopdb-admin
Restart-App starts a stopped pool or site instead of recycling, which is a
no-op on a stopped pool and then reported the application as unresponsive. A
dead Write-Log line that parsed as three arguments is gone, and a preflight
warning no longer tells the operator to add a directory to a compiled exe.
2026-08-04 21:16:46 -04:00
cproudlock
ce521e84a5 Lock down backup directory ACLs, and let the uninstaller reach IIS
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
Two findings from the installer review, both of which fail silently.

Database dumps were readable by every authenticated user. A directory created
under ProgramData inherits BUILTIN\Users:(I)(OI)(CI)(RX), and a dump contains
every row including the users table and its password hashes. The installer
applied an owner-only ACL, but only in the branch that CREATED the directory,
so a directory created first by the console (shopdb-admin.ps1 backup) kept the
inherited permissions and the installer could never repair it. The ACL is now
re-applied on every run rather than only on creation, and the grants are made
inheritable with (OI)(CI) so dumps written into the directory later are covered
too. shopdb-admin.ps1 applies the same hardening for the default location, and
for an operator-named path says the dump holds password hashes rather than
silently rewriting the ACL of a directory that is theirs.

Verified on Windows: before, the directory carried BUILTIN\Users:(I)(OI)(CI)
(RX); after, only SYSTEM and Administrators, and a file created inside inherits
exactly those two. Without (OI)(CI) that file would not have been covered.

The uninstaller could not remove anything in IIS. [UninstallRun] launched a
bare "powershell.exe", and the Inno uninstaller is a 32-bit process, so WOW64
resolved it to the 32-bit PowerShell, which cannot see the IIS provider. The
site, application pool and application survived, pointing at a directory that
HAD been deleted, while Windows reported a clean uninstall. It now uses the
same Sysnative path as the [Run] entry, which was the last unshielded launch
site in the file.
2026-08-04 21:04:54 -04:00
cproudlock
1d73bd477e Document what future Windows releases look like, for operators and for builders
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Two audiences, two documents. Both were only in people's heads.

UPDATES-WINDOWS.md is for whoever runs a server: updates arrive as one
self-contained exe, an update takes two to four minutes, the site is down for
that time, .env and data and any hand-edited web.config are kept, unticking a
feature never removes it, the database is backed up and verified first, and a
downgrade is refused because migrations only go forwards. It covers both kinds
of security release, application and third-party, and explains that the
CycloneDX inventory staged on every server is what answers a published
vulnerability question. It also says plainly that the exe is not signed and the
checksum is the integrity check to rely on today.

It answers one question the existing docs did not address at all: the effect on
other sites sharing the same IIS server. The application pool is isolated and
the configuration is scoped to its own path, so other sites keep their own
handlers. What IS shared gets named rather than glossed: installing the IIS
modules and writing server-level configuration recycles application pools
across the server, which can drop requests in flight and clears in-memory
session state, though IIS is never stopped and no iisreset is issued. The two
IIS modules and the single permitted rewrite server variable are machine-wide
and stay behind on uninstall, deliberately, since another site may have come to
depend on them. The bundled database option collides on port 3306 with an
existing MySQL.

RELEASING-WINDOWS.md is for whoever builds releases: the three kinds of change
and the commands for each, why bundle-lock.json must be committed, the two
dependency traps that have each already cost a release, which generated files
must never be hand-edited, and the pre-release checks. It records the two known
gaps honestly - no code signing, and compiling still requires Windows and a
person.

UPGRADE.md and OPERATE-WINDOWS.md link to the operator document.
2026-08-04 20:54:05 -04:00
cproudlock
aeee210cf6 Repair a web.config that an earlier build made unusable
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Stage 4 deliberately leaves an existing web.config alone, because operators put
real changes in it: extra MIME maps, a /installers location, bindings, a
proxy-specific rule. Overwriting reverts those silently.

That rule had no exception, and earlier builds of this installer wrote an
<allowedServerVariables> block which is fatal on its own: the section is Deny
by default, so IIS rejects the entire file with 500.52 before
httpPlatformHandler runs. Any server already installed would therefore keep the
broken file forever, with re-running the fixed installer powerless to help,
since the first thing stage 4 does is decline to touch it.

Strip just that element, keeping every other edit, and only when it contains
nothing besides the variable this installer adds. A block holding anything else
is somebody's deliberate change and is left alone with a warning. The previous
file is copied to web.config.before-xff-fix first.

Exercised against four inputs: the file earlier builds wrote, which is repaired
and still parses as XML with the rewrite rule intact; a block with an
operator-added variable, which is left unchanged; an empty block, which is the
$null.Count trap under Set-StrictMode 2.0 and is why the filter is wrapped in
@(); and an already-correct file, which is a no-op.
2026-08-04 20:06:47 -04:00
cproudlock
95b0b77c13 Allow HTTP_X_FORWARDED_FOR at server level instead of declaring it per-application
The stage 5 smoke test failure was a locked config section, but not one of the
two the installer unlocks. A diagnostic collected from the server returned:

  HTTP 500.52 - URL Rewrite Module Error
  Module RewriteModule, Handler httpplatformhandler
  Error Code 0x80070021
  Config Error: This configuration section cannot be used at this path.
  Config File: \\?\C:\shopdb-flask\web.config

handlers and httpPlatform were both overrideMode Allow and locked false, so
the unlock had worked. The section at fault was a third one,
system.webServer/rewrite/allowedServerVariables, which ships
overrideModeDefault="Deny". web.config declared <allowedServerVariables>
locally for the X-Forwarded-For rule, and IIS rejects that declaration
outright, failing the entire configuration before httpPlatformHandler ran.
python was therefore never launched and C:\shopdb-flask\logs stayed empty,
which reads as a dead application or a permissions fault and is neither.

Unlocking the section would let every site on the machine declare arbitrary
server variables. The installer now adds the single variable to the
server-level allow list, checking first because a duplicate add is an error,
and web.config no longer declares it. The rewrite rule is unchanged.

Verified by applying the installer's own uncommenting to the template and
parsing the result: one rewrite element, no allowedServerVariables, the rule
still setting HTTP_X_FORWARDED_FOR from REMOTE_ADDR.

shopdb-diagnose.py checked only the two sections the installer unlocks, so it
could not have named this one; the IIS error page did. It now reports the
lock state of the rewrite sections as well.
2026-08-04 20:04:19 -04:00
cproudlock
10ee3a3c58 Add a stage 5 diagnostic collector
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
The stage 5 smoke test failing tells us only that IIS did not return 200. The
cause is in one of four places, and finding out which has taken a round trip
per guess. This gathers all four in one pass and writes a single report.

It records what IIS actually answers on localhost, 127.0.0.1, ::1 and the
machine name, including the status code and the parsed text of the IIS error
page; the site, application, pool and module state from appcmd, plus the
override state of the two config sections httpPlatformHandler needs; the
contents of web.config and the resolved httpPlatform processPath; whether the
venv can import shopdb and call create_app; the application logs, separating a
missing log from an empty one; the ACLs the pool identity depends on; and
recent HttpPlatform, WAS and W3SVC event log entries.

Secrets never reach the report. Values are read from .env first, then scrubbed
from every section before the file is written, which covers command output and
tracebacks that might quote them. A password embedded in any connection URL is
also masked whether or not it came from .env.

Standard library only, so it runs on the bundled runtime or any system Python.
Verified end to end on a Windows VM: it correctly reported a 404 with the IIS
error code for an absent application, and that localhost resolves to ::1 first.
2026-08-04 19:56:13 -04:00
cproudlock
97391cdee4 Unlock IIS config after the application exists, and report why the smoke test failed
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Two defects found in the stage 4 and stage 5 logs from a Windows Server 2019
install.

The scoped config unlock ran before the thing it unlocks existed. appcmd
resolves its location argument against applicationHost.config, but the unlock
was issued from the ACL block, ahead of New-WebApplication. On a first install
"Default Web Site/shopdb" is not there yet, so appcmd returned 80070003, "the
system cannot find the path specified", and the code fell through to unlocking
the section for the entire machine. That fallback exists for servers which
refuse the scoped form; it was instead the only path a first install could
take, so every install silently granted handler delegation server-wide. Moving
the block below site and application creation lets the scoped unlock work.

The smoke test discarded the diagnosis. Invoke-WebRequest raises on any
non-2xx, and the catch block kept nothing from the exception, so a fault IIS
had already identified by status code was reported as "site did not return
200 ... check the logs". It now records the status code and the text of the
IIS error page, and prints the tail of the HttpPlatform stdout log, which is
where a Python traceback lands. It also distinguishes a missing log from an
empty one: the first means the pool never launched python, the second that
python started and wrote nothing.

A non-200 that did not raise, such as a redirect, skipped the retry delay, so
the loop could spend all twelve attempts at once and report a timeout without
having waited.
2026-08-04 19:43:30 -04:00
cproudlock
a352a21a10 Declare packaging as a runtime dependency
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
shopdb/plugins/loader.py imports packaging.specifiers and packaging.version
at module scope, but packaging was never listed in requirements.in. It was
present in every development and CI environment as a transitive dependency of
pytest, so the full suite passed while a venv built from requirements.txt
alone could not import shopdb at all.

The Windows installer builds exactly such a venv, so stage 3 failed on a
customer server with ModuleNotFoundError: No module named 'packaging', after
the runtime and all wheels had installed successfully.

Add packaging to requirements.in, recompile the hashed lockfile, and add the
wheel to the offline wheelhouse with the matching bundle-lock entry. The
recompile also picked up newer uv formatting: inline environment markers on
cffi and greenlet and shorter "via" comments. The pinned distribution set and
every existing hash are unchanged.

tests/test_runtime_dependencies.py guards the general case by scanning
shopdb/, plugins/ and scripts/ for unconditional third-party imports and
asserting each maps to a distribution pinned in requirements.txt. Test
dependencies are the blind spot for this class of failure, since they are
present wherever the suite runs and absent wherever it does not.
2026-08-04 19:12:00 -04:00
cproudlock
f6b621d126 fix(installer): keep -Wait, and use the stage-0 handoff even when .env exists
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
Two defects from a Server 2019 run that got further than any before it - the
payload verified against the lock on a real server for the first time.

EMPTY EXIT CODE. "Python install failed (exit )" on an install that had actually
worked. Dropping -Wait to make -TimeoutSec enforceable left $p.ExitCode
unreadable: PowerShell only reliably populates it on a waited process. -Wait is
restored and the trade is now explicit - exit codes are load-bearing here, 1639
vs 1603 vs 3010 is the entire diagnosis, and a bounded wait is not worth losing
them for. -TimeoutSec is advisory: logged as an expected duration so a hang is
identifiable, not enforced. The code is also read defensively now, and an
unreadable one fails loudly rather than being taken for success.

That timeout has never worked - -Wait made the block dead code from the start -
so nothing is lost that was ever there. Trying to fix it broke something that
was working, which was the wrong trade to make silently.

STALE .env PREFERRED OVER A GOOD HANDOFF. The .dbpass fallback sat in the else
of "if .env exists", so it was consulted only when .env was absent. A
part-finished install HAS an .env, holding whatever password stage 2 last wrote;
if stage 0 has since regenerated the credential, .env is stale and .dbpass is
correct - and the installer preferred the stale one, giving "Access denied" with
the right password sitting unread on disk. The handoff is now applied before the
branch, so it covers both, and only when .env points at the local server so it
can never redirect a site whose database lives elsewhere.
2026-08-04 13:57:13 -04:00
cproudlock
21110b86eb fix(installer): clear the retry path, which is the path everyone is actually on
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
Five defects from the Windows-defect review, each confirmed against the code
before changing it. Four of the five only fire on a RE-RUN - and after eight
attempts a re-run is the normal case, not an edge case, which is exactly why
they survived.

Invoke-Native, three defects in one function:
  - Any non-zero exit was failure. 3010 and 1641 mean "done, reboot required",
    and the VC++ redistributable returns 3010 on a server with a pending file
    rename - an ordinary state on a freshly patched box. It is now an accepted
    outcome for the installers that can report it, logged as a warning so the
    operator knows a reboot is owed.
  - -Wait blocks inside Start-Process until the child exits, so the -TimeoutSec
    block below it could never run. Every timeout on every MSI was decorative.
    The wait is now bounded here, followed by a parameterless WaitForExit so the
    redirected output is flushed before it is read.
  - The Python bootstrapper ran /quiet with no /norestart, free to reboot the
    server mid-install.

Stage 0 refused to run when a MySQL service existed - including the MySQL84 it
had registered itself. Every bundled-database retry dead-ended while the wizard
promised that re-running was safe. A foreign MySQL still blocks; ours is started
if stopped, and the create-the-server block is skipped. It also no longer tries
to bootstrap through a root account whose password it set on the previous run:
with the handoff present there is nothing to do, and without it there is no safe
automatic recovery, so it says what to do instead of guessing.

Stage 4's appcmd unlock used '2>&1' under $ErrorActionPreference = 'Stop', which
turns any appcmd stderr into a terminating error - so the exit-code test and the
server-wide fallback, the whole reason the block exists, were unreachable, and
the stage aborted after Python, the venv, the schema and the ACLs had been
changed.

Stage 3 ran prune-schema and treated its refusal as a failure. Refusing is the
designed outcome when a table holds rows, signalled with SystemExit(1), so
Invoke-Native killed the stage and the reporting written to explain the refusal
was unreachable. Core migration 7d05 seeds access protocols owned by the
computers plugin, so any profile omitting computers hit this on every retry.

The preflight's MySQL 5.6 index-flag check is a warning, not a blocker. It
inspects the LOCAL MySQL, which may not be the database being installed against;
stage 3 checks the one actually chosen. Same class as the HttpPlatformHandler
blocker fixed earlier.
2026-08-04 13:45:37 -04:00
cproudlock
5f350179b1 fix(installer): undo a fix applied twice, and load the checker at script scope
A review of the installer for Windows-only defect classes found seven live
issues. These two would have stopped the next attempt on any server.

DOUBLE-APPLIED GUARD. Yesterday's $null.Count fix was applied at BOTH ends:
Test-BundleLock returns ,$problems, and the call site also wrapped it in @().
The comma already hands the array back intact, so the extra @() nests it and
.Count becomes 1 regardless of how many problems there are. Every install would
have failed with "the bundle does not match bundle-lock.json (1 problem(s))" on
a byte-perfect payload. Applying the same guard at both ends was worse than
applying it at neither. Verified in a Windows VM against a real bundle: clean 0,
tampered 1, restored 0.

DOT-SOURCE SCOPE. bundle-lock.ps1 was dot-sourced INSIDE
Assert-BundleIntegrity, which loads it into that function's scope - every helper
it defines disappears when the function returns. Assert-BundleIntegrity itself
worked; the next caller, Get-WheelhousePythonTag, died with "The term
'Get-JsonProperty' is not recognized". It only fires where a venv already
exists, so greenfield was fine and every retry after a part-completed install
was not. Now loaded once at script scope, guarded so the stages that run without
a bundle still work.

Both were confirmed by running them rather than by reading: the nesting with a
three-case pwsh test, the scoping with a minimal repro.
2026-08-04 13:35:30 -04:00
cproudlock
14fedcee4c fix(installer): a clean payload crashed stage 2
Reported from Server 2019: "The property 'Count' cannot be found on this object"
immediately into stage 2.

Test-BundleLock returns an array of problems, and an EMPTY array means the
payload is exactly right. PowerShell unrolls a zero-element return into $null,
and under Set-StrictMode 2.0 $null.Count throws - so the branch that runs when
everything is correct was the one that could not run. Every failing bundle got
past it fine, which is why nothing caught it until the 8.3 path fix made
verification succeed for the first time on a real server.

Fixed at both ends: the call site wraps in @(), and Test-BundleLock returns
,$problems so no caller can be handed $null or a bare string depending on how
many problems there happen to be.

The other .Count uses in this file were already @()-wrapped and are unaffected.
2026-08-04 13:04:05 -04:00
cproudlock
fe091e751a fix(installer): the bundled database rejected connections it should have accepted
Two defects in the stage 0 bootstrap, both surfacing as "Access denied" on a
server where the operator was holding the correct password.

CREATE USER IF NOT EXISTS is a no-op on an existing user - it does NOT change
the password. Stage 0 generates a fresh password every run and overwrites
.dbpass with it unconditionally, so any path that re-runs the bootstrap over an
existing account left the handoff holding a password the server had never been
told. ALTER USER now follows each CREATE, so the stored password and the handoff
always agree.

The user was also only created for 'localhost' and '127.0.0.1'. On current
Windows, 'localhost' resolves to the IPv6 loopback FIRST, so an operator who
types localhost rather than 127.0.0.1 arrives as '<user>'@'::1' - an account
that did not exist - and MySQL answers "Access denied" naming a host they never
typed. The ::1 account is now created and granted alongside the other two.

Note the datadir guard means the first defect could not fire on a straightforward
re-run - stage 0 refuses a non-empty data directory before reaching the
bootstrap. It was still wrong, and reachable once the directory has been cleared
by hand, which is what the failure message tells operators to do.
2026-08-04 12:59:45 -04:00
cproudlock
189a474082 fix(installer): do not demand a password the installer already holds
Asked why the password box does not pre-fill from .dbpass. It should not - but
it should not have been demanding a password either.

.dbpass is the ACL'd handoff stage 0 writes when it creates the database itself,
and stage 2 already reads it automatically when no password is supplied. The
wizard, though, required a password whenever .env was absent, without checking
for the handoff. On a server where stage 0 had completed but stage 2 had not -
which is exactly what a partly-failed install leaves - the operator was blocked
on a secret the installer already had, and sent hunting for a generated password
they were never meant to handle.

Blank is now accepted when either .env or .dbpass is present, and the sign-in
page says so when it sees a handoff.

Deliberately NOT pre-filled into the password box, for two reasons. It is the
only copy of a generated password, so round-tripping it through a UI control and
back out through a temporary password file adds exposure for no benefit - stage 2
reads the file directly. And .dbpass belongs to the BUNDLED database; on the
existing-database page the operator is pointing at someone else's server, where
a locally generated password is simply the wrong answer.
2026-08-04 12:57:00 -04:00
cproudlock
5f18ca27a1 fix(installer): split the database page so every field is reachable
Some checks failed
CI / backend (push) Failing after 6s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Trimming the description brought the Username box back and left Password off the
bottom. CreateInputQueryPage stacks its fields below the description and neither
scrolls nor shrinks, so a field that does not fit is drawn past the surface and
simply never appears - no error, no scrollbar. Sizing the description against a
pixel budget that varies with DPI and font scaling is guesswork, and it had now
failed twice.

Connection details (host, port, database) and sign-in (username, password) are
now two pages of three and two fields. Both fit under any reasonable
description, at any scaling, without anyone having to estimate.

The upgrade hint about leaving the password blank moves to the sign-in page,
where the password field actually is. ShouldSkipPage hides both pages for the
bundled-database option, and the stage arguments read the values from their new
homes.
2026-08-04 12:51:39 -04:00
cproudlock
e650eb0220 fix(installer): database page lost its Username and Password boxes
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Reported from the Server 2019 test: the Existing database page rendered a
truncated "Username:" label and no input boxes at all below it, so there was no
way to enter connection details.

CreateInputQueryPage lays its fields out BELOW the description text. The
description had grown to include a five-line CREATE DATABASE / CREATE USER /
GRANT block, added so a DBA could be handed the exact SQL. With five fields
underneath, the last two fell past the bottom of the page surface, where they
are simply not drawn - no error, no scrollbar, just missing controls.

The description is back to three lines. The SQL moves to
docs/INSTALL-WINDOWS.md, which is where someone would look for it anyway and
where it can be copied without being retyped from a wizard page.

Wizard page descriptions are a fixed budget: anything long enough to be worth
reading twice belongs in the guide, not on the page.
2026-08-04 12:44:37 -04:00
cproudlock
c5797bb339 fix(installer): payload verification broke on 8.3 short paths
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
A Server 2019 install reported all 96 payload files as simultaneously missing
and unexpected, with mangled names - wheels/heels/flask.whl,
python/ython/python-3.14.6-amd64.exe, mysqlclient/lient/mysql.exe. Exactly five
characters of each directory name survived, which is the difference between
ADMINI~1 and Administrator.

Inno extracts the bundle under C:\Users\ADMINI~1\AppData\Local\Temp\..., an 8.3
SHORT path. Resolve-Path kept that short form while Get-ChildItem returned the
long one, so the root was five characters shorter than the prefix being sliced
off every FullName, and every relative key came out wrong. The payload was
correct; the comparison was not - the verifier refused a perfectly good bundle.

The root now comes from Get-Item, which goes through the same provider as
Get-ChildItem so their path forms agree, and the prefix is checked with
StartsWith before being trimmed. If the two ever disagree again this throws
instead of inventing paths.

Verified against the real failure mode rather than assumed: running the check
through C:\SHOPDB~3\bundle in a Windows VM now passes.

Nothing on Linux or in a normally-pathed Windows directory could have caught
this - the short name only appears under a profile directory long enough to need
one, which is where Setup extracts.
2026-08-04 12:26:53 -04:00
cproudlock
263ae8e3b4 fix(installer): install the Visual C++ runtime before MySQL, and log the MSI
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
Second failure from the Server 2019 test. The previous fix worked - msiexec went
from exit 1639 (ERROR_INVALID_COMMAND_LINE, which is why it printed its usage
dialog) to exit 1603 (ERROR_INSTALL_FAILURE), so the command line parses now and
the MSI itself is failing.

It failed in 1.1 seconds. An MSI that dies that fast has not begun installing;
it has failed a launch condition. MySQL 8.4 requires the Visual C++
redistributable and a bare Windows Server does not ship it - the same runtime
mysql.exe and mysqldump.exe import, which was visible when their DLL
dependencies were trimmed and went unnoticed.

Stage 0 now installs VC_redist.x64.exe from the bundle before touching MySQL,
skipping it when vcruntime140.dll is already present, and fails with a sentence
naming the requirement if the redistributable is absent from the bundle
altogether. vcredist\ is an optional locked payload.

msiexec also gets /l*v now. A bare 1603 names neither the failing action nor the
reason, and it is the most common MySQL install failure - diagnosing this one
took a launch-condition inference rather than a log. The MSI log lands beside
the installer's own in ProgramData, so the next failure is readable instead of
guessed at.
2026-08-04 12:17:36 -04:00
cproudlock
8d0afc40d3 fix(installer): bundled MySQL install failed on a malformed msiexec command line
Reported from a Windows Server 2019 test: a "Windows Installer" dialog listing
every msiexec /Option appeared, then the wizard reported that the bundled MySQL
database could not be installed. That dialog is msiexec's usage help - it prints
it when the command line does not parse - so the install never started.

Cause: $MysqlRoot defaulted to 'C:\Program Files\MySQL\MySQL Server 8.4', which
contains spaces. Invoke-Native wraps any argument containing whitespace in
quotes, producing "INSTALLDIR=C:\Program Files\...". msiexec takes public
properties as PROPERTY=value and expects the VALUE quoted -
INSTALLDIR="C:\Program Files\..." - so it rejected the line, printed usage, and
exited non-zero.

This file already carried the rule, next to the Python target: "Never put a
space in a path this installer controls." I broke it setting the 8.4 path.

Two fixes, because one of them alone leaves the trap in place:

- $MysqlRoot is now C:\MySQL84, space-free like C:\Python314. The MySQL client
  search paths in the installer, the preflight and the operator console all look
  there first, keeping backups working against the bundled server.
- Invoke-Native now quotes PROPERTY=value correctly, so passing a spaced path
  explicitly no longer produces an unparseable command line.

tests/test_installer_defaults.py fails if an installer-controlled path default
ever contains a space again.
2026-08-04 12:06:36 -04:00
cproudlock
5321649e02 fix(installer): stop blocking the wizard on things the installer itself installs
The preflight page began refusing to continue while any check was failing, which
is right for something the operator must go and fix. HttpPlatformHandler was
marked FAIL when absent - so on a server without it the wizard stopped dead,
telling the operator the server was not ready, over a module the bundle carries
and stage 4 installs a few pages later. The only way forward was to go and
install by hand the exact thing the installer was about to install.

It is now INFO: reported, not blocking, matching how URL Rewrite is already
handled. Nothing the installer SUPPLIES may block the wizard, and
tests/test_installer_defaults.py now fails if that rule is broken again.

The site-port conflict check is downgraded from FAIL to WARN for the same class
of reason: it runs before the operator reaches the Address page, so it tests the
DEFAULT port rather than the one they intend to use, and blocking refuses an
install over a conflict the very next page lets them resolve.

Genuine blockers are unchanged - no IIS, no WebAdministration, wrong Windows
edition or architecture, no disk, and the MySQL 5.6 index flags. Those the
operator really does have to fix first.
2026-08-04 10:00:44 -04:00
cproudlock
4a8bd138a9 feat(import): load a site's data from spreadsheets
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
Adopting a site means getting its asset register in. The HTTP import API suits a
site with a source system and someone to script against it; a sister site with a
spreadsheet and no developer needs something else, and that is the common case.

FOREIGN KEYS TAKE NAMES. This is the whole design. A CSV row has to say where an
asset is, and the database stores locationid, an integer. Requiring the number
means importing locations, reading back the generated ids and pasting them into
the asset sheet - a workflow nobody finishes. Every foreign key here accepts
either a numeric id or the referenced row's name:

    assetnumber,assettypeid,statusid,locationid
    CMM-01,Measuring Tool,Active,Gage Lab

The column keeps its database name, per CONTRIBUTING.md; the value is whatever
the operator actually knows. Names resolve across files in one run, so
assets.csv can reference a location that only exists because locations.csv was
read moments earlier. A name that does not resolve is reported with its line,
column and value, not as a foreign key violation from three layers down.

Dry run is the default, and writes go into the transaction either way - the
rollback is what makes it a dry run. Skipping the writes instead made every
cross-file reference fail, which is the one thing a folder-wide check exists to
verify. Validation covers every row before anything is written, so a typo on
line 400 cannot leave 399 rows imported. Files are matched on a natural key, so
correcting a spreadsheet and re-running updates rather than duplicates.

TEMPLATES ARE GENERATED, NOT MAINTAINED. "flask csv templates" builds them from
the live schema, annotated with required/optional and which file each foreign
key refers to. The prompt for this was a hand-written template set that had
invented columns on seven of eleven tables and named a table that does not
exist, while looking entirely plausible - and described an import mechanism
(a Data Import page, a flask import-csv command) that had never existed. A test
fails the build if a generated template ever offers a column the schema lacks.

User accounts are deliberately not importable: passwords do not belong in a
spreadsheet in either direction.

Verified end to end against MySQL 5.6 - a folder dry run catching one bad
reference, the fix, the commit, and a re-run reporting updates rather than
inserts. 16 tests.
2026-08-04 09:13:03 -04:00
cproudlock
f72813ed9c feat(installer): bundle the database - MySQL 8.4 LTS, not 8.0
The bundled-database option could not actually be built. Stage 0 looks for
mysql\mysql-8.0.x-winx64.msi, and Oracle no longer publishes a standalone server
MSI for 8.0 - every 8.0.x returns 404. What remains for 8.0 is the MySQL
Installer bundle, which is an installer-manager: 'msiexec /i INSTALLDIR=' would
install THAT rather than a database, and stage 0 would then fail on a missing
mysqld.exe.

MySQL 8.0 also reached end of life in April 2026, so bundling it would have put
an unsupported database on every new site.

8.4 LTS still ships the standalone MSI (129MB, which is what the '125MB' note in
stage 0 was written against) and is supported into 2032. Defaults follow it:
install root MySQL Server 8.4, service MySQL84. The operator console still looks
for an 8.0 install path as a fallback, for sites already running one.

Also bundles mysqlclient\ - mysql.exe and mysqldump.exe with the two OpenSSL
DLLs they actually import, 20MB rather than the 51MB of debug and auth-plugin
libraries the archive ships. Stage 2 stages it onto the server, so a site whose
database is on ANOTHER host can still take the pre-upgrade backup that every
upgrade depends on. That was the gap the preflight had started warning about.

Bundle is now 221MB.
2026-08-04 07:56:39 -04:00
cproudlock
8d9d1d3439 test(docs): skip the publishability gate where there is no docs/ to check
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
The GitHub backend job failed on test_there_are_docs_to_check, correctly. docs/
is stripped from the published repository - it lives in the wiki on that side -
so on the mirror the glob matched nothing and the guard fired exactly as
designed.

An absent docs/ and a glob that silently matches nothing in a tree that HAS docs
are different conditions, and the test conflated them. The module now skips when
the directory is not there at all, and the guard still fails when it is there and
empty. Verified all three ways: 7 pass here, 7 skip in a docs-less checkout, and
the guard still fails against a docs/ containing no markdown.

The gate has to ship rather than be excluded from publication, because the
published tree is where the GitHub CI that would catch a regression runs.
2026-08-04 07:28:47 -04:00
cproudlock
2073d0dbe8 build(export): purge stale generated paths from the publication tree
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
rsync --exclude also PROTECTS a path from --delete, so anything that reached the
publication tree before its exclude existed stayed there permanently - invisible
to the sync and surfacing only as a scrub-gate failure. That cost two rounds of
'add the exclude, still fails' on the installer bundle and again on
.pytest_cache. The generated paths are now purged before the sync, so adding an
exclude is sufficient on its own.

tests/test_docs_publishable.py assembles its search terms from fragments: a file
containing the literal strings the scrub greps for tripped that scrub on itself.
Excluding the file from publication would have removed the check from the
repository it protects.
2026-08-03 15:17:09 -04:00
cproudlock
ee083ea80e docs: stop publishing internal references to a public wiki
docs/ is excluded from the code bundle and its scrub gate, because it goes to
the GitHub wiki instead - via a generator that has no gate at all. So the one
part of the repository written in prose, by people, about internal
infrastructure, was the one part nothing checked.

What was reaching a public wiki: the internal git server's URL and hostname,
.gitea workflow paths, developer home directories in the GE-Enforce cutover
reference, and a dev database root password inside a copy-pasteable command in
the import guide.

All replaced with neutral equivalents. tests/test_docs_publishable.py is now the
gate, at the source, in CI - a wiki page cannot be un-published, so catching this
after the fact is not good enough.

PROJECT-REVIEW.md also referred to internal tooling by name throughout; those
references are generalised. It remains an internal candid assessment of this
project that is nonetheless published, which is worth a separate decision.
2026-08-03 15:12:25 -04:00
cproudlock
bec138f5ac build(export): keep the installer's staged bundle out of publication
The sync walks the working tree rather than git, so deploy/windows/installer/
bundle came through despite being gitignored - about 100MB of build output
containing a copy of the whole application tree, the wheels and the vendor
installers. Its copies of config.py and requirements.txt then tripped the scrub
gate, which is the only reason it was noticed.

Excluded along with the installer's other generated files. Note that rsync
--exclude also protects a path from --delete, so a copy already in the
publication tree has to be removed by hand once.
2026-08-03 15:09:13 -04:00
cproudlock
e158cb21f9 deps: keep build-machine paths out of the lockfiles
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
The '# via' annotations recorded the absolute path of the temporary file the
lockfile was compiled from, which is meaningless to anyone else and does not
belong in a published artifact. They now read 'requirements.in', which is where
these requirements actually come from.

Pins and hashes are unchanged - verified by a hash-checked dry-run install.
2026-08-03 15:06:02 -04:00
cproudlock
2c415a1712 fix(installer): correct a false security claim, and clear the should-fix list
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
CLIENT IP / SPOOFABILITY. docs/geenforce-api-cutover.md claimed that removing the
IIS rewrite rule made the allowlist fail closed and that it does NOT become
spoofable. The opposite is true. IIS never sets X-Forwarded-For on its own; the
rule is the only thing that does. Remove it and IIS still forwards whatever
X-Forwarded-For the CALLER sent, waitress trusts it because it arrives from
127.0.0.1, and remote_addr becomes attacker-controlled - so a token-less caller
can fetch manifests from anywhere on the network. The document and the
_trusted_client_ip docstring now say so, waitress runs with
--trusted-proxy-count=1, and stage 5 checks the rule is actually live rather than
assuming it. The wizard question is rephrased to something an operator can verify
with their network team instead of guessing at.

NON-ASCII. The style gate only ever checked .py/.vue/.js/.ts, so documentation
accumulated em-dashes, arrows and box-drawing characters against this repo's own
convention - including in files added this week. Cleaned, and the gate now uses
INCLUDES_ALL so Markdown, JSON and YAML are covered.

PLUGIN DEFAULTS. The wizard pre-ticked measuringtools and printedparts, both of
which ship default_enabled=false, so every site taking the defaults installed and
enabled them against their manifests. Inno has no JSON parser so the list must be
hardcoded, but tests/test_installer_defaults.py now fails when it drifts.

UPGRADES. The payload copy merges, so a plugin dropped from a site's profile kept
its code forever - which defeats a lean build and leaves core's optional-import
guards succeeding for a plugin the site no longer has. Stale plugin directories
are now deregistered and removed before the copy.

add-plugin used 'plugin install', which for the five default_enabled=false
plugins left them installed but DISABLED - and printed a green success line
anyway. It now goes through apply-profile, and the success line is gated on the
exit code. Invoke-Flask records its own exit status, because $LASTEXITCODE keeps
a stale value when flask.exe is missing and no native command runs.

CHARSET. The utf8mb4 compiler hook lived inline in migrations/env.py, so it
covered the CORE chain only: plugin baselines inherited the server default, which
on a latin1 server means two charsets in one database. It is now
shopdb/utils/mysql_charset.py, imported by both, and preflight reports the
database's default charset.

BACKUP HONESTY. The dump was described as 'all of your asset data'. Uploaded
branding and floor-map images live in instance\ on disk, not in the database, so
a restore from the .sql alone comes back with no map. backup now archives
instance\ alongside it and says both are needed.

VERSIONING. AppVersion was hardcoded at 0.9.0 while the product, the frontend and
the newest tag said 0.7.0 - and 0.9.0 collides with a retired contract version.
Both builders now generate version.iss from shopdb/__init__.py.

Smaller: rollback overwrites .env before deleting it, as uninstall already did;
appcmd unlocks are scoped to this site's location rather than server-wide, with
the wide unlock as a fallback; DEVELOPMENT-SETUP says Python 3.14; the README
plugin list gains printedparts; prune-schema --force is documented as
first-provisioning-only; HTTPS is documented as not-the-default with the steps to
add it; the DBA SQL is on the wizard's database page; the features page says
unticking does not remove an installed feature; and the installer README states
that bundle-lock cannot vouch for the exe itself - that needs signing or an
out-of-band hash, neither of which is wired up.
2026-08-03 14:57:38 -04:00
cproudlock
aea2905de0 fix(installer): stop it lying, stop it leaking, and make it findable
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Nine fixes from a review of the installer against its actual audience: DT leads
at sister sites who are not Windows, IIS or Python specialists and who will lean
on an AI assistant to get through it.

TRUTHFULNESS. The preflight was advisory - an operator read 'IIS is not
installed', pressed Next, answered five more pages and the install died partway
through with Python already on the box. The results page now blocks while
anything is failing, repaints on every run instead of latching after the first,
and offers 'Check again' so a fixed problem does not mean starting over. On
failure the wizard said 'Nothing was left running', which is false in every path
because the stages run with -OnFailure never: it now says the server is
part-configured, that re-running is safe, and how to remove it. The final page no
longer reads 'ShopDB-Flask is ready' after a failed install.

SECRETS. The generated MySQL root password went to Write-Host in a process the
wizard runs hidden - so nobody saw it - and stdout is forwarded into the setup
log operators are told to send to support, so it was permanently recorded for
everyone who did not need it. It now goes to an ACL'd file. Database dumps, which
contain every user password hash, landed in a ProgramData directory readable by
every user on the box; the directory is now locked at creation.

UPGRADES ON REMOTE-DATABASE SITES. mysqldump was looked for only under local
MySQL install paths, so a site whose database is on another host silently skipped
every pre-upgrade backup - after stage 2 had already stopped the pool and
replaced the tree. Find-MysqlTool now prefers a client shipped in the bundle,
stage 2 stages it onto the server, preflight reports when it is missing, and
mysqlclient\ is an optional locked payload.

UNINSTALL. A subpath install is an IIS Application, not a site; removing only the
site left the application pointing at a deleted directory, so the parent site -
at West Jefferson, the live classic ASP - served 503 on that path forever while
Add/Remove Programs reported success. Uninstall now reads MOUNT_PATH and removes
the application. The firewall rule was created as "$SiteName $SitePort" and
removed as the literal 'ShopDB-Flask 8090', which matches nothing.

DAY-2 TOOLING. Every shortcut now passes -AppRoot and -SitePort, and the console
forwards them through its own elevation and 32-bit relaunches instead of
discarding them - a non-default directory or port made it report a healthy site
as broken, from a shortcut the installer wrote. 'Open ShopDB-Flask' resolved to a
hardcoded localhost:8090 that was wrong for every subpath install; it now asks
the console, which reads the address the installer recorded, and no longer
demands administrator to open a browser.

SMOKE TEST. The parent-site port lookup filtered for an http binding and
defaulted to 80, so an https-only parent site failed a working install with a red
dialog.

DOCS AND /api/docs. The installer was invisible: nothing in docs/, README.md or
CLAUDE.md mentioned it, so a DT lead or their assistant landed on the manual IIS
runbook and hand-built the very server the installer then refuses to upgrade.
docs/INSTALL-WINDOWS.md and docs/OPERATE-WINDOWS.md are now the canonical route,
the two manual runbooks are bannered as reference-only, README and CLAUDE.md
route by target, and llms.txt tells an assistant which document to follow and to
ask for 'check -Json' before diagnosing. Both ship on the server, along with
openapi.json and llms.txt - without those the self-hosted /api/docs was broken on
every installed box, which matters most to the sites least able to debug it.
Stage 5 now checks it actually serves.

shopdb-admin.ps1 gains 'check -Json': one structured, secret-free block covering
version, publishing method, IIS state, HTTP reachability, database, Python
version, plugins and errors. That is the cheapest useful answer to 'the operator
will ask an LLM' - it works with no infrastructure, which a install-time MCP
server could not.
2026-08-03 14:39:38 -04:00
cproudlock
e58f376643 fix(installer): the adopt-existing guard never fired
Stage 4 decided whether the IIS objects it was about to reconcile were its own by
testing for .installed-version. Stage 2 writes that file, and stage 2 always runs
first in a '-Stage all' install - so by the time the guard looked, the stamp it
had just written made every server look like one this installer built, including
the hand-built ones the guard exists to protect.

Stage 2 now records whether a stamp was present BEFORE it writes its own, and
stage 4 reads that observation. Running stage 4 alone still tests the file, which
is correct there: no stage 2 has run to disturb it.

Found by review, not by test - the guard has no coverage, because exercising it
needs a live IIS.
2026-08-03 13:47:07 -04:00
cproudlock
3606d8d696 feat(sbom): ship a CycloneDX bill of materials with every build
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
An air-gapped site cannot be scanned from anywhere else, so when a CVE lands the
only way to answer 'is that component here, and at what version' was to RDP in
and go looking. The frontend was the real blind spot: nothing recorded which
version of leaflet, dompurify, jspdf or html2canvas ends up inside the compiled
SPA.

scripts/generate_sbom.py emits CycloneDX 1.6 covering both ecosystems - every pin
in requirements.txt with the sha256 the installer enforces, and every package in
package-lock.json. Build-only npm packages are marked scope 'excluded' rather
than dropped, so 'not here' stays distinguishable from 'not looked for'.
Dependency edges are real: uv's '# via' comments give the Python graph and
package-lock gives the npm one.

Hand-rolled rather than cyclonedx-py plus cyclonedx-npm because both inputs are
already pinned and committed - this is a format translation, not a scan - and
because the build box may be a work PC with nothing but Python and Node. It is
deterministic by construction: same inputs, byte-identical output, so
regenerating does not churn.

Staged into the application tree by both builders, so it installs onto the
server with the app. shopdb-admin.ps1 verify reports it and searches it by
component name, which is the question actually being asked.

Packages appearing at several depths in package-lock (node_modules/vite and
node_modules/vitest/node_modules/vite) are merged, and a copy reachable outside
the dev tree makes the component count as shipped. Emitting both produced
duplicate bom-refs, which CycloneDX forbids and scanners reject; getting the dev
merge backwards would have hidden a shipped package from a CVE search.

Not covered by bundle-lock.json on purpose: its provenance is git, not the
third-party payload.
2026-08-03 13:15:27 -04:00
cproudlock
1bf3cb2e1c feat(installer): refuse to damage an installation it did not create
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Two guards for a server deployed by hand, which the West Jefferson production
box is.

An existing venv is reused, which is right for a repair or an upgrade of an
install this made, and wrong when the venv belongs to a different Python. The
wheelhouse is tagged for one minor version, so pip finds no candidate for the
compiled packages and dies partway through - after Python has been installed and
the application tree replaced. The two versions are now compared up front and
the run stops with both numbers and what to do about it.

Switching deployment method removes the other method's IIS artifact. That is
correct when this installer owns both and dangerous when it does not: a wrong
-MountAlias would call Remove-WebApplication on a live mount with no prompt and
no error, and the first sign would be the site returning 404. It now refuses
unless a version stamp shows this installer made the install, or -AdoptExisting
is passed, and the refusal lists exactly what it would have removed.
2026-08-03 11:46:48 -04:00
cproudlock
13d831eb90 feat(installer): require the wheelhouse to satisfy requirements.txt, and lock the real payload
The lock records what IS in the wheelhouse, not what the application NEEDS, so
an incomplete wheelhouse was locked, blessed and shipped - and only failed on an
air-gapped server.

That is not hypothetical. Assembling the wheelhouse anywhere other than Windows
silently omits colorama, a win32-only dependency of click, because pip evaluates
environment markers against the machine doing the downloading rather than the
machine being targeted. The bundle built here was short exactly that one wheel.

Both verifiers now cross-check wheels/ against the staged requirements.txt,
ignoring markers, since a requirement guarded by sys_platform == 'win32' is
precisely the one that must be present. Names are normalised to PEP 427 wheel
form, so mysql-connector-python matches mysql_connector_python.

bundle-lock.json is the first real lock: 42 files, cp314/win_amd64 - 39 wheels,
Python 3.14.6, HttpPlatformHandler 1.2 and URL Rewrite. MySQL is absent and
optional; a site choosing the bundled-database option adds it and re-locks.

The naming gate now skips the installer's build output. It contains a staged
copy of the application plus a second SPA build under dist-subpath, which
--exclude-dir=dist does not match, so a staged bundle failed the gate on
vendored minified JS nobody in this repository wrote.
2026-08-03 11:46:39 -04:00
cproudlock
44237b5cbd feat(installer): bundle URL Rewrite, ask where client IPs come from, verify installs
IIS does not set X-Forwarded-For on its own and HttpPlatformHandler connects
from loopback, so without a rewrite rule every client reads as 127.0.0.1. The
GE-Enforce IP allowlist, the dashboard visitor-location lookup and per-host
login rate limiting all stop working, silently. The rule needed URL Rewrite,
which the installer told operators to download - from an air-gapped server.

URL Rewrite now ships in the bundle, and the wizard asks which case applies,
because the two answers are mutually exclusive. Directly exposed: install it and
set X-Forwarded-For from REMOTE_ADDR, which is what stops a client spoofing its
own. Behind a proxy: leave the rule off, since REMOTE_ADDR is the proxy and
applying it would discard the real client IP.

The rule is enabled by deleting two explicit marker lines rather than by a regex
over the surrounding comment, so editing that prose cannot silently disable it.

An existing web.config is no longer overwritten. It is the one file on a server
that legitimately carries hand-edits, and replacing it reverted them without a
word - on a server where the X-Forwarded-For rule had been enabled by hand, that
alone would have turned the GE-Enforce IP allowlist off. The installer reports
what it found instead.

pip now runs with --require-hashes and --only-binary=:all:. Hash-checking is
requested explicitly rather than inferred from the lockfile, so shipping an
unhashed requirements.txt fails loudly instead of quietly dropping the check.

shopdb-admin.ps1 gains a verify command: which bundle this server was installed
from, and whether the installed packages still match what shipped.

The .iss states its compiler floor. WizardStyle uses the built-in windows11
custom style, which needs Inno Setup 6.6.0; older compilers now fail with that
sentence rather than 'WizardStyle is invalid'.
2026-08-03 11:17:58 -04:00
cproudlock
88af7fd9ce feat(installer): lock the third-party payload, and build on Windows without Bash
The bundle carries ~40 wheels, a Python installer and two MSIs. All of them run
as SYSTEM on the target server, and nothing verified any of them. A missing
wheelhouse printed MISSING and the script still exited 0, so an empty bundle
compiled into a shippable installer and the failure surfaced on an air-gapped
server with no way to fix it.

bundle-lock.json now records that payload exactly - sha256 and byte size per
file - and verification is set equality: a missing file, an unexpected extra
file, or changed content all fail. Both builders check it and refuse to produce
an unverified bundle; the lock ships inside the bundle and shopdb-install.ps1
re-checks it on the server before running any of it.

This is deliberately a layer above requirements.txt hashes. pip lists every
artifact of a pinned version (cffi 2.1.0 alone has 100 hashes), so it proves a
wheel is genuine, not that it is the wheel this bundle was built and tested
with; it ignores extra files in the wheelhouse; and it covers none of the
executables.

refresh-bundle-lock.ps1 regenerates the lock but refuses to overwrite one until
the operator has seen the diff, because the commit is the review - it is the
only place a change to what runs as SYSTEM becomes visible to a human.

build-installer.ps1 is the whole build natively on Windows, so a work PC needs
no Bash. It shares the plugin closure resolver with build-site.sh.

Both builders now copy the installer scripts from the repository. They were
copied from a downloads folder, so the logic that shipped was not the logic that
was committed and the build worked on exactly one machine.

Two verifiers exist because PowerShell is the only thing guaranteed present on
the target server, while the Linux builder should not need pwsh.
tests/test_bundle_lock.py runs both against the same fixtures and fails if they
disagree.
2026-08-03 11:17:45 -04:00
cproudlock
75f0a57821 build(site): share the plugin closure resolver, stage only web.config
Two fixes to the lean-site build.

The closure resolution moves out of an inline heredoc into
scripts/resolve_plugin_closure.py. The Windows builder needs the same answer,
and a PowerShell reimplementation would have been a second copy of the rules,
free to drift and produce a bundle whose plugin set did not match its profile.

The backend staging step copied all of deploy/ into the output tree. The Windows
installer stages its bundle at deploy/windows/installer/bundle, so that copy
recursed into its own destination and cp aborted with 'cannot copy a directory
into itself' - the documented build could not complete. Only
deploy/windows/web.config is read at install time, so only that is staged; the
rest of deploy/ is installer source and does not belong on an application
server.
2026-08-03 11:17:28 -04:00
cproudlock
9c2c21c2cc fix(employees): resolve User through the contract surface
test_plugins_only_import_contract_surface has been failing on main since
9a2d0cc: the employee name resolver imported shopdb.core.models directly.
shopdb.api already exports User (contract 0.13.0), so this is the same object
reached the way ADR-001 requires.
2026-08-03 11:17:28 -04:00
cproudlock
6ebc79a2de deps: hash-pin the lockfiles and stop dev and prod drifting apart
Both files are recompiled with --universal --generate-hashes, preserving every
pinned version. Three things change.

Hashes put pip into hash-checking mode, so a wheel whose sha256 is not listed is
refused rather than installed. The offline Windows install previously took
whatever file in the wheelhouse satisfied the version pin.

--universal means one lockfile serves Linux (dev, Docker, CI) and the Windows
wheelhouse. The Linux-only resolve had silently omitted colorama, a win32-only
dependency of click; in hash-checking mode a missing entry is a hard error, so
that omission would have broken every Windows install.

requirements-dev.txt is now compiled with -c requirements.txt, pinning shared
dependencies to the versions production runs. The two had been compiled at
different times and drifted: CI tested against alembic 1.18.5 while sites
installed 1.18.4.

Hashes pin the version and prove the artifact is one upstream published. They do
not pin WHICH artifact of that version is used, and they say nothing about extra
files in the wheelhouse - bundle-lock.json covers both.
2026-08-03 11:17:18 -04:00
cproudlock
5c4fdcb15e Merge branch 'installer-prereqs' into feat/installer-bundle-lock 2026-08-03 10:06:56 -04:00
4d5b6c3c55 build(site): also stage a subpath frontend build
Some checks failed
CI / backend (push) Failing after 2m1s
CI / naming (push) Failing after 1s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s
Vite compiles the mount path into the bundle, so it cannot be chosen at install
time from a single build - a page served under /shopdb would load and then
request its assets from /assets/, and render nothing.

build-site.sh now produces both:
  frontend-dist           base /        - the app on its own IIS site
  frontend-dist-subpath   base /<alias> - an IIS Application under an existing
                          site, e.g. http://<server-fqdn>/shopdb/

SUBPATH_ALIAS (default 'shopdb') is fixed per bundle and written into the staged
build as .alias, so the three places that must agree - the IIS application alias,
MOUNT_PATH in .env, and this compiled base - cannot drift apart. The installer
checks that marker and refuses rather than serving a page that cannot load.

The subpath build runs FIRST and is held in a temp dir: the root build has to be
last so frontend/dist is left in the state a developer expects, and the copy into
$OUT has to happen after the staging step that does rm -rf "$OUT".
2026-08-03 01:55:21 -04:00
0fa5f1e910 feat(deploy): add the air-gapped Windows installer
Roughly 2500 lines of tested installer had been living in ~/Downloads and an
untracked folder - nothing was under version control.

It goes here rather than in a repo of its own because it depends on application
internals: the `flask plugin` verbs, site-profile.json, MOUNT_PATH, and the
plugin registry. Versioned separately it would drift out of step with the thing
it installs.

Contents: the read-only preflight, the staged installer (bundled MySQL, runtime,
schema, IIS, verify, uninstall), the operator console, the Inno Setup wizard, the
bundle builder and the artwork generator.

bundle/ and Output/ are ignored - regenerable, and ~220MB. plugins.iss is ignored
because build-installer.sh generates it from the staged payload. The artwork IS
committed so a Windows build box does not need Python and cairosvg.

Verified end to end on Windows Server 2025 against a bundled MySQL 8.0 and an
existing MySQL 5.6: fresh install, upgrade with backup and rollback, re-run
idempotency, uninstall, and both deployment methods including switching between
them. Not yet verified: a hypervisor-level air-gapped run, and any load from a
real browser (every HTTP check so far used curl, which sends no Origin header).
2026-08-03 01:47:34 -04:00