The Modules screen as built (website#143), and the three things standing it
up against a live server exposed. Two of them are older than this phase and
neither was reachable from a test.
- A fresh install over a row the previous boot left `startup_failed` rendered
the old failure - "module directory not present on the volume", one second
after the files were written there - and, because that branch is not
pending, suppressed the restart banner the install had just told the
operator to use. Fixed by a derivation rather than a special case: the
loader scans once at require time, so a module on the volume with no live
record arrived after that scan and anything the row says predates it.
- The boot refresh had been nulling every install's provenance. source and
sha256 exist for this screen and never survived a restart, because
lifecycle.boot() re-records with neither and the upsert assigned both
unconditionally. It could not have been found before Phase 4 wrote the first
non-null value those columns ever had - and the model's test fake
reproduced the defect faithfully, assigning exactly like the SQL.
- The restart killed the server outright on Windows. process.kill(pid,
'SIGTERM') reaches the graceful handler on Linux and is unconditional
termination where POSIX signals do not exist, so a Windows host got no
module onShutdown, no pool close and no log flush. process.emit('SIGTERM')
reaches the same listener everywhere. The test had stubbed process.kill and
asserted the call - precisely the call whose meaning differs by platform.
That last one generalises: deployment is Linux containers and would never
have shown it. A smoke that only runs where the code ships cannot find a class
of defect that only bites the people developing it.
Also documents the COALESCE in BACKEND_DESIGN's installed_modules section.
Co-Authored-By: Claude <noreply@anthropic.com>
The install service and admin API as built (website#142), plus the two
things §2.7.2 got wrong before there was any code to check them against.
The top-level directory rule was checking against nothing real. module-uo's
release workflow packs module-uo-<version>/, not uo/, so "reject a top-level
directory whose name is not the manifest's id" would have refused every
bundle that exists. That level is stripped instead - its name belongs to
whoever published the bundle, the directory it lands in has to be the id the
loader scans for - and what replaces the check is stronger: the UNPACKED
module.json must agree with the install manifest about id and version.
And tar has to be pinned forward rather than merely depended on. Installing
it gets 6.x, which npm audit reports as critical, and the advisory list reads
as this feature's own threat model: hardlink traversal via drive-relative
linkpath, symlink poisoning, hardlink escape through a symlink chain, PAX
size override on GNU long-name headers, decompression DoS. Refusing symlink
and hardlink entry types outright is what takes the extractor off most of
that list rather than depending on the library to contain them.
Also records the measurement behind the two-pass unpack, which was assumed in
the plan and is now known: node-tar DOES reject an escaping member, but late.
An archive whose fourth member escapes throws and leaves the first three on
disk - and the loader only asks whether module.json is present, so a
half-unpacked bundle is a module as far as the next boot is concerned.
Refreshes api-route-inventory.json (158 -> 166 public) and the route count in
BACKEND_DESIGN, and documents module_source_hosts beside the other seeded
settings keys - bootstrapped from the environment, owned by the database.
Co-Authored-By: Claude <noreply@anthropic.com>
Both came out of writing slice 1 against §2.5 rather than from planning, and
one of them means §2.5 was wrong rather than merely vague.
5. Purge is offered inside the uninstall flow, because it cannot be offered
after it. §2.5 promised a disabled row an operator could purge later — but
purge.sql is a file INSIDE the module directory that uninstall deletes, so
"later" would have left a Purge button with nothing to run. The destructive
choice now sits at the one moment the file is still there: an opt-in
checkbox in the uninstall dialog, beside the standalone Purge action on a
module that is still installed. "Never bundled into uninstall" becomes
"never implied by uninstall". The cost is stated rather than hidden — an
operator who does not tick it keeps their tables, and reinstalling is how
they get the ability to drop them back.
6. The host allowlist bootstraps from MODULE_SOURCE_HOSTS and then lives in
the database, admin-editable, with the variable no longer overriding it —
the shape core already uses for seeded settings. The argument for keeping
it in the environment does not survive contact with what this screen is: an
admin who can install a module already has code execution in the process,
so an admin who can name a host has gained nothing they did not have.
Also renames the subsection to six decisions and updates acceptance criterion
2 to name the checkbox.
Co-Authored-By: Claude <noreply@anthropic.com>