Compare commits

...

279 Commits
v0.11.0 ... dev

Author SHA1 Message Date
cproudlock
64e932ce5c Rescue the work-PC wizard and kiosk-link edits
Some checks failed
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / backend (push) Failing after 4m58s
CI / migrations-mysql (push) Successful in 42s
Two tracked files had been edited directly in the work-PC relay clone, which
only fetches bundles and pushes them to GitHub. Nothing there reaches gitea, and
the next bundle pull would have overwritten both silently. Brought across here,
with three changes to what was written.

Kiosk links now need the route AND the plugin. The edit swapped hasRoute for
isPluginEnabled, which trades one wrong answer for another: a full build stages
every route, so a site that turns printedparts or usb OFF still saw a link to a
kiosk its backend refuses - the symptom being fixed - but isPluginEnabled fails
OPEN when it cannot reach the plugin list, and a copied registry can report a
plugin that was never staged into this build. That is what the comment above
those lines was already warning about. Both tests together answer both states.

The wizard shows the filename it is uploading. The browser puts it in chrome the
full-screen wizard hides, so an operator picking a file got no confirmation it
was the one being sent.

Image previews get a cache-buster. The stored path does not change between
uploads, so the preview showed the PREVIOUS logo or blueprint and the upload
read as having silently failed. Display only - saveStep persists form values,
never these, so no cache-busted URL is ever written to a setting.

The wizard also loads the current site logo on mount, so re-running it does not
present an empty slot as though branding had never been set. The shipped-asset
fallback stays a root-absolute path: the template applies withBase, and prefixing
it here as well produced /shopdb/shopdb/ge-aerospace-logo.svg on a subpath mount.

Frontend 391 passing.

Scope: product
2026-09-06 19:24:50 -04:00
cproudlock
75ee3b7c1c Keep the agent's build output out of the publication tree
Some checks failed
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / backend (push) Failing after 5m8s
CI / migrations-mysql (push) Successful in 43s
A local 'dotnet build' leaves agent/*/obj and agent/*/bin full of absolute
host paths. They are gitignored, but the export rsyncs the WORKING TREE, so
.gitignore does not apply and the scrub gate stopped the whole export - which
is what it is for, but it made publication depend on whoever built the agent
last having cleaned up.

Same shape as the stray .env.bak the excludes already document. Note the trap
that follows from it: an --exclude also PROTECTS a path from --delete, so the
copies the first failed run had already placed in the publication tree had to
be removed by hand before the next run could pass.

Scope: product
2026-09-06 19:12:30 -04:00
cproudlock
a46058a4dd Close the last three restore-gate gaps, and make the gate test the real gate
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Failing after 5m13s
Three defects that all shared a shape: the restore path looked checked and was
not.

An unreadable data revision was treated as a legacy set. backup.sh discards the
mysql error when reading alembic_version, so a set could be written with an
empty datarev; the compatibility check then fell through to the code-to-code
head comparison - the very check datarev was introduced to replace - and badged
the set restorable on no evidence at all. A manifest that CARRIES the key and
left it empty is now refused, while one with no key at all is still the genuine
legacy case and keeps the fallback. backup.sh records the failure in run history
rather than passing it over in a log line, since the set it just wrote cannot be
restored.

Dumps were bound to the schema they came from. mysqldump --databases bakes
CREATE DATABASE and USE <name> into the file, so restoring onto a site whose
MYSQL_DATABASE differs loaded every row into the dump's schema, left the live
database untouched, and reported success. New sets are dumped without it and
load into the schema named at restore time. Existing sets are still bound, so
the gate reads which schema they name and refuses a mismatch instead of
restoring beside the live data. Also dropped --no-tablespaces into the main
dump: it needs the PROCESS privilege the app user does not have on MySQL 8.

The gate's own test carried a copy of the gate. test-restore-datarev.sh
re-implemented the predicate in its own shell functions, so it would have stayed
green through any drift in restore.sh - including the plugin-chain hole fixed in
the previous commit, which it could never have caught. restore.sh now takes
RESTORE_CHECK_ONLY, which stops it immediately after the gates having touched
nothing, plus overridable paths for the migration and plugin trees. The test
drives the shipped script and asserts on its exit code, so the verdict under
test IS the verdict that ships. Confirmed non-vacuous by planting a violation:
disabling the plugin revision check turns the suite red.

Nine cases now, up from five, covering both plugin-chain directions, a plugin
the build does not have, and the schema binding.

Moving the schema check earlier was prompted by that test: it had been placed
after the safety copy, so it needed a live database to reach a verdict it can
reach for free. It sits with the other gates now and stops before anything is
written.

Round-tripped against a real stack after the dump format change: backup, mutate,
restore, data back to backup-time state, and the new dumps confirmed to carry no
schema name.

Suite 2607 passed / 9 skipped.

Scope: product
2026-09-06 18:31:37 -04:00
cproudlock
e2705d732a Gate a restore on every schema chain, not just the core one
Some checks failed
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Failing after 15s
CI / backend (push) Failing after 4m56s
The compatibility gate read alembic_version and nothing else, but plugin schemas
move independently of core. Roll the code back to an older plugin revision and
the core head can be identical, so a dump carrying plugin tables the code has
never heard of passed the gate, loaded - destroying what was there - and only
then failed in 'flask plugin upgrade-all' with "Can't locate revision". A failed
restore that has already replaced what it was restoring over is the worst
outcome this feature can produce, and it was reachable from an ordinary rollback.

Backups now stamp the applied revision of every alembic_version_<plugin> table
alongside the core one, read from the dumped database the same way. restore.sh
checks each against that plugin's own versions directory and refuses before
touching anything, naming the plugin and the revision to deploy past. A plugin
in the dump that this build does not have at all is refused for the same reason.
The list endpoint's compatibility badge asks the same question, so the UI cannot
offer a set the restore will reject.

Sets taken before the stamp existed carry no dataplugins and keep their old
core-only verdict. Their plugin revisions are unknowable, and making every
existing set unrestorable to close this would be a worse trade than the gap.

Proven against a real stack, reproducing the exact case: core head identical,
endpoints data stamped at a revision the build does not have. The restore
refused, the live row was still there afterwards, and it stopped early enough
that the pre-restore safety copy was never needed. The same set with its true
revision restored normally.

Suite 2605 passed / 9 skipped, with the gate covered both ways plus the legacy
fallback.

Scope: product
2026-09-06 18:02:39 -04:00
cproudlock
fa6b99bf91 Finish a restore without a browser, from the set that was asked for
Some checks failed
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 14s
CI / migrations-mysql (push) Failing after 14s
CI / backend (push) Failing after 4m57s
Closes the three defects in the imported restore path, at Ben's request. Each
was proven against a real stack before and after: two backup sets with different
data and different instance files, then a restore of the OLDER one, which is the
case that was broken.

A restore no longer depends on a tab staying open. The backup sidecar has no
Flask, so it loads the dump and leaves a sentinel for the api container to run
the migrations. The only thing that ever acted on that sentinel was the poll
loop of the modal that queued the restore, so closing the tab - or letting a
token expire - during the minutes a load takes left the data at the old revision
under new code, every query on a newer column failing, and nothing retrying. A
watcher in the api container now claims and runs it. The endpoint no longer runs
the upgrade at all; it only makes sure something is pending, which is also how a
failed upgrade is retried. That takes Alembic out of the request path, where it
could outrun the gunicorn worker timeout and be killed mid-chain, leaving a
half-applied revision the next attempt trips over.

The claim is a rename, because rename(2) is atomic: several workers and a
browser poll can race the same sentinel and exactly one wins. A failed upgrade
parks itself as .upgrade-failed rather than re-arming, since putting it back
would rerun the same broken chain on every poll forever.

Instance files come from the set being restored. The restic call asked for
'latest' regardless of which set was named, so restoring anything but the newest
paired that set's DATABASE with the NEWEST snapshot's uploads and plugins.json -
a mismatch nothing downstream could detect. Now scoped by the set's own tag,
which backup.sh has always written. The tree is replaced rather than restored
over, so files created after the snapshot are gone afterwards: a restore has to
put the site back to the snapshot, not merge into it. Done as a staged swap
rather than 'restic restore --delete --target /', because that aims a delete
pass at the container root and the cost of getting its filter wrong is the whole
filesystem.

Nothing destructive happens before a copy is taken. The dump carries DROP TABLE
per table with no transaction around it, and the instance tree is replaced
wholesale, so a wrong set or a failure part-way through left nothing to go back
to. restore.sh now writes a pre-restore-<stamp>/ set - dump, instance tarball
and a README saying what it is and how to load it - and refuses to touch
anything if that copy cannot be written.

Two more found while proving the above, both of which stopped the overlay
working at all:

* Every script under scripts/backup/ was committed non-executable, while every
  other .sh in the repo is 755. The compose entrypoints are
  /opt/backup/watch.sh and backup.sh from a read-only bind mount, so the backup
  and watcher containers could not start from a clean checkout on any machine,
  and nothing could fix it at runtime.

* Dockerfile.backup could not build without the corporate CA staged. A plain
  COPY of the gitignored cert fails outright; the trailing-glob form does too,
  because BuildKit fails a COPY whose pattern matches nothing - which is exactly
  the case being handled. Copying the directory and testing for the file inside
  it is the form that works both ways. Verified by building the image with no
  cert present.

Suite 2600 passed / 9 skipped. The finalizer has unit tests covering the atomic
claim, the parked failure and the endpoint no longer running the upgrade.

Scope: product
2026-09-06 17:38:10 -04:00
cproudlock
83190422c9 Act on the review of the wilm import
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / backend (push) Failing after 5m8s
CI / migrations-mysql (push) Successful in 45s
Six findings from a four-agent review of the imported work, plus the contract
bump the import owed. Nothing here changes what the imported features do.

The contract version was not bumped for a change that widens what a plugin may
declare. The asset-panel vocabulary gained two tab groups (serviceprocess,
reports), a datetime column format, a map.body key and a tabs section that
renders as a table. 0.29.0 bumped for exactly this class of change when the
optional group was added, so this is 0.31.0 with a ledger row. All four are
optional and resolve server-side when absent, so a plugin declaring none of them
is unaffected.

Site Backups now answers honestly where it has nothing behind it. The overlay is
a compose profile: a separate container writes sets onto a shared volume and the
api container only reads them. A Windows/IIS install runs no such container and
the default root resolves to a drive-letter path nothing creates, so Back Up Now
either failed on PermissionError or reported a run queued that would never
happen, under a banner reading "No verified backup yet" - which describes a
backup that failed, not a feature that was never installed. The three write
routes refuse with the same 404 shape the configuration-policy routes use for a
disabled feature, the list and status routes report whether a store exists, and
the page says so instead of offering buttons.

Two listAll callers still read a response that is not there. listAll resolves to
a plain array through fetchAllPages, so response.data.data throws: PC Types
settings rendered empty, and in Configuration Policies the throw happened inside
the shared try, so PC types, applications, the action catalog and monitors were
all left unassigned by one bad read.

The agent package upload cannot succeed on IIS as shipped. MAX_CONTENT_LENGTH
went to 256 MB to admit the ~69 MB tray binary, but IIS request filtering caps a
body at 30,000,000 bytes and answers 413 before waitress sees the request, so
web.config needs the matching requestLimits.

The backup image could not be built by anyone outside a TLS-intercepting
network. Dockerfile.backup did a plain COPY of a gitignored corporate root CA,
and a COPY of a missing file fails the build. The trailing glob makes it a no-op
when the cert is absent.

endpointstates.domain was the one new string column written without a clamp.
Under STRICT_TRANS_TABLES an over-long value raises Data too long and aborts the
whole check-in, so a single malformed report would stop a machine reporting
telemetry and events entirely.

Not fixed here, and left with upstream: the restore path finalizes its schema
upgrade only from a browser poll loop, so closing the tab mid-restore leaves the
data behind the code; it restores instance files from the newest snapshot rather
than the set that was asked for; and it takes no safety dump before the
destructive load. All three are in the Docker-only overlay, which this site does
not run, and none can be validated here.

Scope: product
2026-09-06 16:47:47 -04:00
cproudlock
6e36df841e Import the wilm endpoint telemetry, PC Detail rework and backup overlay
Ports 37 of Benjamin Baker's commits from the wilm branch, mirrored from the
corporate GitHub at 7905917c. wilm rides the publication lineage and shares no
ancestor with wj, so this is a filtered tree patch rather than a merge:
94 files, and every path the publication export strips (docs/, tools/, mcp/,
CLAUDE.md, start-*.sh, .gitea/, the three excluded tests) is held back, because
a raw diff would delete each of them from this tree.

What comes across:

* Endpoint telemetry, phase 2. Physical disk health from two sources, NIC link
  state, boot type, Defender health, processor/memory/chassis inventory, the
  listening TCP set, mapped network drives and Windows Update history. Five
  additive migrations, 0012 through 0016, off the 0011 head.
* Mapped drives are collected by a probe running in the interactive user's
  session, because they do not exist in the agent's session 0. Two Windows
  sources can disagree about one drive and the worse status wins, so a
  half-broken share is never rendered as healthy.
* Windows events are reported once each, keyed on the channel's own record id,
  instead of being re-sent whenever a report overlapped the last one.
* PC Detail rework: Details cards flow, the hand-written Network card gives way
  to a full address history, Storage and disk health merge into one card with
  two sections, Operator Reports and Corporate Tools get their own tabs.
* An opt-in restic backup overlay with a third container, restore gated on the
  dump's own data revision rather than the code head, and backup/restore history.
* The agent's version is read from the binary's PE version rather than trusted
  from the csproj, and the Fleet view gains an Agent Version column.
* The enqueue route now asks caller_holds instead of the owner's permissions,
  which closes the scoped-token elevation on run_script, update_app and
  system_restart.

Held back deliberately:

* agent/installer/install-shopdbagent.ps1 stays deleted, as upstream deleted it.
  The installer that reaches a bay is generated by _install_script() in
  plugins/endpoints/api/siteagent.py; the standalone script had stopped being
  shipped and was collecting fixes no endpoint ever ran.
* The .gitignore hunk adding docs/*.md. Correct on the publication lineage,
  where docs/ becomes the wiki and its absence is how the gates detect a
  published checkout. Here it would silently ignore every new doc.
* Their deletions of docs/DEV-BUILD-TEST.md and docs/PLUGIN-HOOKS.md. Upstream
  those were stubs; here PLUGIN-HOOKS.md is the 31KB document CLAUDE.md points
  readers at.
* Five zero-byte files committed by an accidental `git add -A`, a run log from
  the author's own machine, and docs/notes/, which is working scratch that says
  so in its own header and belongs in devnotes/ on this side.

Carried with the import so it lands green: the 17 new routes are described in
docs/api-inventory.json with the spec and project map regenerated, and two new
frontend specs read fixtures through fileURLToPath(URL) which node rejects under
jsdom's own URL global.

Suite 2591 passed / 9 skipped; frontend 391 passed; naming, style and doc gates
clean.

Scope: product
2026-09-06 16:47:24 -04:00
cproudlock
9947a477b8 Seed DeviceId in the registry from the identity a bay already carries
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 43s
CI / backend (push) Failing after 5m6s
Phase 3 of the device-identity proposal moves device identity to
HKLM:\SOFTWARE\GE\ShopDB\DeviceId, which survives the profile cleanup and disk
hygiene that eats C:\Enrollment and needs no new key: the collector already
opens that path for BaseUrl and ApiToken. This is the backfill that gets a
value in there without re-imaging anything.

The source depends on the pc-type, and that is the whole point. DeviceId names
the DEVICE, while a machine number answers "which bay is this" - a different
fact that coincides for exactly one family:

  CMM           the machine number IS the instrument id (CMM4), which is why
                cmmid.txt has always doubled as identity. Copied.
  part marker   the machine number is the OPERATION, and several markers serve
                one operation. Copying it would name the operation as the
                device, which the collector refuses as the wrong kind of asset
                because a machine filed under a device label reads as a marker
                everywhere downstream. Never copied.
  Keyence,      no per-bay id has ever existed; asset-id.txt is the only
  Genspect,     identity these have.
  wax-trace
  anything else not a device bay, so nothing is written. DeviceId is not
                consumed for those pc-types and a value left there is a
                landmine for the day one gains a device spec.

Resolution per family mirrors Report-AssetToShopDB.ps1, including the 9999
placeholder skip and reading WOW6432Node before the native eDNC key.

Refuses to overwrite a DeviceId that disagrees, unless -Force. asset-id.txt is
written once at imaging and nothing updates it on a device swap, so a stale
file clobbering a corrected registry value would reintroduce the exact defect
this move exists to fix. GE-Enforce already reached the same conclusion for
machine-number.txt.

Verified in the win11 VM across ten cases: not-a-device bay, part marker,
CMM precedence, idempotent re-run, machine-number exclusion, eDNC fallback,
9999 skip, stale-file refusal (exit 2), -Force, and -WhatIf. The part-marker
guard was then planted - flipping it to treat the machine number as identity
writes operation 0613 into DeviceId, so the passing test is not vacuous.

Scope: product
2026-09-04 09:24:15 -04:00
cproudlock
a6775c1da1 docs: ADR-018 supersedes ADR-005, metrology instruments are machines
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Failing after 5m2s
ADR-005 split machines from measuring tools on the grounds that they are two
asset classes. It named the metrology population as CMMs, vision systems, wax
trace and Genspect; the plugin was then built against a docstring naming
calipers, micrometers and thread gages. Those are not the same population, and
the difference is the whole decision: bay-mounted capital equipment with a
controlling PC on one side, hand tools in a drawer on the other.

Sixteen months on, measuringtools holds zero rows against 204 machines. The
metrology inventory waiting to be entered is 134 instruments - 35 CMM, 32 form
tracer, 23 vision, 18 wax, 14 measuring, 8 Genspect, 2 eddy, 1 spline - every
one bay-mounted with a controlling PC, and not one hand gage among them. CMM is
already a machinetype, and Part Marker is already a machine while Form Tracer
is a measuring tool, so the line is already drawn somewhere no operator can
predict.

Retires the plugin and the measuring_tool asset type. Calibration is not lost:
it moves onto machines as four nullable columns with status still derived at
read, which is a gain rather than a port because a machine could not record
calibration at all before. ADR-005's one concrete objection - that "show me all
measuring tools" would need an enumeration of type names - is answered with
machinetypes.ismetrology, so it stays a single-column filter.

Taken now because measuringtools is empty. After the 134 rows are entered they
carry relationships, audit history, map positions and collector links, and the
migration stops being a drop.

Scope: product
2026-09-04 09:00:42 -04:00
cproudlock
349e8c89bd docs: EventSaver has no compiled-in folder fallback any more
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Failing after 5m3s
The adoption guide told an adopting site that a missing EventSaver.ini makes
the binary fall back to "the reference site's file server". That was true of
EventSaver.cs until the ADR-015 fix set Folder to empty, and EVENTSAVER.md was
updated then while this page was not, so the two documents disagreed about what
a missing ini does.

Current behaviour is that it shows nothing, which is the point: failing visibly
beats displaying another site's slides. Says that, and adds the case the old
text was accidentally describing - a .scr compiled before the fix still carries
the path, so a binary somebody was handed is not the same as one they built.

Scope: product
2026-09-04 08:28:39 -04:00
cproudlock
bfc30f4c7c docs: branch model documents one branch per instance
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 1m37s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Failing after 4m49s
CONTRIBUTING.md still said "main is the product every site runs and the
publication mirror exports" and framed wj/wilm as site branches feeding it.
That was true when main was the only thing anyone deployed. It is not now:
WJ production runs wj, the /ops instance runs dev, and main deploys nowhere.
Nothing in the repository mentioned dev at all, so the branch a deploy script
refuses to run without was undocumented.

Replaces that section with the branch/instance table and the dev -> wj -> main
promotion order, and says why promoting before validating is the wrong way
round. Keeps the wilm note but moves it up with the reason it cannot be
merged locally: it sits on the publication lineage and shares no ancestor.

CLAUDE.md gains the branch-to-server pointer and records that
export-github.sh now builds a dist only for wj and dev.

docs/DEPLOY.md is deliberately left alone: `git pull origin main` is correct
there. That document is the generic connected-Docker deploy another site
follows, and main is exactly what another site adopts.

Scope: product
2026-09-03 18:52:55 -04:00
cproudlock
b5737f47c1 Serve gunicorn with threaded workers so a retry storm cannot starve the UI
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Failing after 4m55s
Taken from the other site, who hit it in production today (their commit
db75ca7): agents that fail the TLS handshake retry in a loop, and with four
blocking sync workers a handful of them pinned every worker. Real UI requests
queued for 20+ seconds while the CPU sat idle - the box was not busy, it was
blocked.

gthread, 4 workers x 8 threads, and a 30-second timeout rather than 60. A stuck
connection now holds one thread out of 32 instead of one worker out of four, and
it lets go in half the time, which is the difference between absorbing a retry
storm and queueing behind it.

Their server, our agent. The client behaviour that caused it is the endpoint
agent, so it will reach any site running one - which makes this a product fix
rather than something that belongs on one site's branch.

Applied to the Dockerfile as well as docker-compose.yml. A compose file
overrides the image CMD, but docker-compose.airgap.yml, .channel.yml and
.pull.yml do not specify a command at all, so they take the image default - and
so does anybody running the image by hand. Fixing only compose would have left
every air-gapped install on sync workers.

WJ production is unaffected either way: it runs waitress under IIS, already with
--threads=8, so it was never exposed to this. That is worth knowing before
anyone treats it as a prod hardening item that was missing.

Scope: product
2026-09-03 17:35:48 -04:00
cproudlock
b9f4aea4b8 Make the deploy scripts enforce which branch reaches which server
Some checks failed
CI / naming (push) Successful in 3s
CI / migrations-mysql (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
The rule for this site, stated plainly: only `wj` reaches /shopdb (WJ
production), only `dev` reaches /ops, and `main` is the curated release other
sites adopt and deploys nowhere. The tooling did not know that, and in two
places actively assumed the opposite.

export-github.sh refused --dist on any branch but main, because production was
assumed to install main. It does not - it installs wj. That guard fired today: a
wj export produced its bundle and then died at the dist step, so the dists on
the share came from the main export that happened to run next. The code going to
production and the frontend going to production would have come from different
branches, silently, and nothing downstream could tell.

Now one branch builds one dist: wj builds the /shopdb dist, dev builds the /ops
dist, and any other branch is refused because a dist it built could only ever be
installed by mistake. Each dist carries a .dist-source stamp naming the branch,
commit, base path and build time.

The two deploy scripts copy from the clone on WHATEVER branch it is sitting on
and only ever checked the destination (the web.config mount path), so a clone
left on the wrong branch deployed the wrong code to a correctly-identified
server without a word. Both now refuse unless the clone is on their branch, and
both read the dist's .dist-source and refuse a frontend built from anywhere
else. A directory of hashed filenames says nothing about where it came from, and
robocopy /MIR will mirror whichever one it is pointed at.

pull-shopdb-bundle.cmd and update-shopdb-github.cmd silently defaulted to main.
That was safe when main was the only thing anyone deployed; with three branches
it is a way to carry the wrong one to a server without ever typing its name. The
argument is required now, and the error lists what each branch is for.

tools/mirror-wilm.sh is the missing half of fetch-wilm-branch.cmd: that script
only puts a bundle on the share, and wilm does not appear on gitea by itself.
The new one fetches the carried bundle and pushes wilm to gitea, printing the
bundle's age first - a mirror that looks authoritative and is a week old is
worse than no mirror - and noting that wilm sits on the publication lineage, so
it shares no ancestor with main/wj/dev and cannot be merged here.

Scope: product
2026-09-03 17:32:25 -04:00
cproudlock
c1a4c9eba6 Let a PC be assigned a business unit, and a retired asset be found
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Failing after 4m47s
The last two items from the run.

The PC detail page has always rendered a Business Unit row, and the API has
always accepted the field. Only the form had no control, so it could be read and
never set from the UI - a value that appeared the moment somebody set it through
the API and could not be reached otherwise. The form has the picker now, loaded
with listAll() rather than a capped page.

Global search excluded inactive assets, so a retired record existed, was
reachable by URL, and could not be found by anybody who did not already know
where it was - including whoever wanted to restore it, which the retire dialog
promises they can. Retired assets are searchable now.

That is only safe with the ordering, so it is part of the change rather than a
follow-up: somebody searching a machine number almost always wants the machine
still on the floor, so a retired match drops 60 points of relevance, sorts below
every live one, and carries a 'retired' flag the results page renders as a
badge. Indistinguishable retired results would be worse than none.

Four tests, verified by planting the isactive filter back: three fail.

Scope: product
2026-09-03 17:08:03 -04:00
cproudlock
a1802a2bf5 Match the number on the bin, and let an unset kiosk say so
The last two kiosk findings.

Keypad entry collected EVERY digit in an item's code, and the shipped prefix
contains one: '3DP0004' collapsed to 30004, so an operator typing the 4 printed
on the label was told no part matches that barcode and had to work out that the
leading 3 belonged to the prefix. The number on a label is the number at the
end, so the trailing digit run is what is compared now. An ambiguous number is
still refused rather than guessed - taking from the wrong bin is worse than
being asked to scan the label.

A parts kiosk that had never been provisioned drew its normal "Scan the barcode
on the bin" screen and answered "Not found" to every scan, which reads as "we
do not stock that part" rather than "this screen was never set up". The server
cannot say which: the routes answer a bare 404 on purpose, so a caller without
the device token does not learn the door exists, and that stays. But the BROWSER
knows whether it holds a token, and saying so discloses nothing - an
unprovisioned screen now says it is not set up and how to fix it.

For the case the client genuinely cannot distinguish - kiosk switched off versus
a part that is not stocked, both a 404 by design - three misses in a row add a
line suggesting the kiosk may have been switched off. Truthful without the
server having to leak anything.

Three tests, verified by planting the all-digits match back.

Scope: product
2026-09-03 17:00:49 -04:00
cproudlock
f4d3827214 Record how a PC is reached, and stop pages implying they can do things
Three findings, all about a screen or a log saying something that is not so.

Editing a PC wrote an audit entry for its named fields and nothing for the two
changes that matter most when somebody investigates later: the primary IP
address, which is how the machine is found, and the remote-access protocols and
ports, which is how somebody connects to it. Both are on the same form, in the
same request, and both were invisible afterwards - a PC that moved address had
no history of moving, and a PC that gained a VNC listener had no record of
gaining one. Both are recorded now, and re-sending the same value still records
nothing, because an audit log full of non-events is one nobody reads.

While proving that, a silent discard turned up in the same lines: both the
create and the update path did `if ip_comtype:` around the IP write and did
nothing at all when the 'IP' communication type row was missing. Somebody typed
an address, saved, got a 200, and the address was gone. A missing reference row
is a seeding gap, not a reason to drop what was typed, so it is created on
demand.

The PCs list rendered a 403 as "No computers found", which tells a person
without the permission that the fleet is empty and sends them looking for the
data rather than for the permission. It now says which permission is missing.

The Site Policies and Configuration Policies pages rendered their empty table
and an Add button while the policy feature flag was off - every route answers
404 "Policy feature is not enabled", so the button could never work. Both pages
now say the feature is off and that the empty list is a consequence of that,
not of nothing being configured.

Nine tests. The audit entries verified by planting: remove either and two fail.

Scope: product
2026-09-03 16:56:22 -04:00
cproudlock
30d0d4f6d5 Stop asking for more rows than the server will ever return
You are right that this is the one we keep hitting. perpage is capped at 100
server-side, and the cap is applied SILENTLY: a request for 500 returns the
first 100 rows with a 200, so a picker built that way shows the first 100
options and drops the rest with nothing anywhere reporting a problem. The
missing vendor simply is not in the list.

Twelve call sites were still doing it - vendor and business-unit pickers on the
machine and measuring-tool forms, and the type lists behind PCs, machines,
printers, network devices, measuring tools and configuration policies. Each now
either pages through with listAll()/fetchAllPages() or asks for what it can
actually have. vendorsApi and businessunitsApi gained listAll(), which they
lacked, which is why those two sites reached for 500 in the first place.

The gate is the point. tests/test_core/test_perpage_cap.py reads every .vue and
.js source under frontend/src and plugins/*/frontend and fails on any perpage
over 100. It has to read the SOURCE rather than the running app, because the
response is a valid 200 either way - no runtime assertion can see this one.
Verified by planting a perpage: 500 back into MachineForm: the gate names that
exact line.

Separately, /api/auditlogs built its own meta with the pagination fields flat
and a 'pages' key, while every other list returns
meta.pagination.{page,perpage,total,totalpages,hasnext,hasprev}. A client
written against the documented contract found nothing where it looked, on the
one list whose whole purpose is being read by somebody checking what happened.
It uses paginated_response now.

Also carried here, from the previous batch's working tree: Asset.to_dict emits
ownlocationid/ownmapx/ownmapy/ownlevelid alongside the resolved values, and the
PC form binds to those. The merge of an inherited location is right for a reader
and wrong for a form, which could not tell an owned value from a derived one and
wrote the inherited one back as the record's own.

Scope: product
2026-09-03 16:49:55 -04:00
cproudlock
81ad221e6a Let a typed employee name find the employee
Reported from the floor: somebody types the employee's whole name into the
notification or recognition picker and presses Enter instead of clicking the
suggestion, and the entry does not pull from the employee database.

Two causes, one symptom.

The search matched the WHOLE query against first name or last name, so "Dana
Reyes" found nobody - no single column holds that string. Typing a full name is
what a person does before pressing Enter, and it was the one thing guaranteed to
return an empty list. A multi-word query is now treated as a full name: every
word must appear somewhere in the name, in either column, so "Reyes Dana" works
too, and "Dana Reyes" does not also return Sam Reyes. A single word still
searches broadly, and an SSO still resolves. Both directory backends changed,
the self-hosted tables and the external HR database.

With no suggestion to click, the form then turned the typed text into
`NAME:<text>` - a label linked to nothing. On screen that chip is identical to a
real selection; the difference only shows up later as no photo, no directory
link, and a recognition the person it is about cannot be found by. Both pickers
(the notifications form and the shared EmployeeSearch component) now look a
typed name up before deciding it is free text, through one helper.

An exact full-name match wins over a longer partial one, because somebody typing
"Dana Reyes" means that person and not "Dana Reyes-Smith". A genuinely ambiguous
name is NOT guessed at - the candidates are shown and the operator picks, which
is exactly when picking wrong matters most. A directory that cannot be reached
reports failure rather than silently producing a label.

Eleven tests. The search rules verified by planting the single-term query back.

One note for whoever reads the spec: the failure-branch test uses a thenable
that rejects rather than a mock returning a rejected promise. The runner
collects such a promise as an unhandled error even when the code under test
catches it, and fails the very test that proves the catch works.

Scope: product
2026-09-03 16:45:15 -04:00
cproudlock
cbdfcbabb7 One badge vocabulary, and images a browser can actually load
Two operator-visible defects and three doc corrections, closing the tail of the
2026-09-03 run.

THE BADGE. The two kiosks read the same physical badges and disagreed. The
parts kiosk understood both shapes a scanner emits - the bare SSO and the PayNo
wrapper 0<digits>BZ - while the USB kiosk compared the raw string to an SSO, so
the wrapper resolved to nobody. An operator whose keyboard-wedge scanner emits
it could take a 3D-printed part and could not check out a drive, with the same
badge, at two cabinets a few metres apart.

The shape now lives in shopdb/utils/badges.py on the contract surface, because
a plugin may not import another plugin and because a badge is a property of the
site rather than of a cabinet. Both kiosks use it, and both store the DIGITS
rather than the string that arrived: one row per spelling makes a custody
history that cannot be joined to itself.

That closed a subtler hole in the same place. str.isdigit() is true for
Arabic-Indic and other decimal forms, and int() parses them, so a badge written
in those characters was accepted, matched a REAL employee, and was then stored
as an SSO string nothing else matches. ASCII digits only. An empty scan and an
unreadable one now get different answers, because they are different problems
for the person at the cabinet.

THE IMAGES. /api/applications/image/<filename> and /api/models/image/<filename>
required a permission, and a browser fetching <img src="..."> sends no
Authorization header - so the gate did not protect the file, it guaranteed the
picture never appeared. Both are ungated now, like the four sibling image routes
(branding logo, map blueprint, employee photo, part picture), and registered in
PUBLIC_ENDPOINTS with that reasoning.

/api/printers/lookup?fqdn= never matched anything: both arguments were compared
against Communication.ipaddress, so a hostname lookup 404d on printers whose
hostname was on the record. An FQDN now matches the hostname, with a fallback to
the short name.

The slide manager gained the per-slide hold time. The PATCH route and the feed
have always carried `seconds`; nothing called it, so the one documented
per-slide control was curl-only.

Docs: API-REFERENCE listed the employee lookups and the whole notifications
surface as unauthenticated when they answer 401, and called both kiosks
"deliberately open" when they are off by default and refuse anything without the
site's device token. The installer download says applications.download, which is
what the code requires. api-inventory.json said printers/lookup was
jwt-optional.

Fourteen tests. The badge rule verified by planting the old per-kiosk behaviour:
three fail, one in each kiosk.

Scope: product
2026-09-03 16:25:08 -04:00
cproudlock
8fee09e748 Validate what a form accepts, and keep a diff to one asset
The validation tail from the 2026-09-03 run. Each is small; together they are
the difference between a field that checks its input and one that looks like it
does.

Subnets accepted any string containing a slash, so 'notanip/xx' was stored -
and _devices_in_subnet then swallowed the ValueError from ip_network and
returned an empty device list, so the subnet looked real and permanently empty.
It is parsed with the same library that has to read it later, a prefix length is
required (ipaddress reads a bare 10.1.1.0 as a /32, which is legal and never
what somebody typing a subnet meant), and the stored form is normalised so
10.1.1.5/24 and 10.1.1.0/24 cannot become two rows nobody can tell apart.

VLAN numbers had no range check: 99999 and -5 were stored. Worse, a non-numeric
value reached a duplicate probe that compares against an integer column, which
MySQL coerces to 0 - so 'xyz' matched an existing VLAN 0 and the 409 named a row
that had nothing to do with it. 1-4094 per 802.1Q, checked before the probe.
Deleting a VLAN that subnets still reference now answers 409 like every sibling
in-use guard, rather than 400, which told the caller their request was malformed
when the request was fine and the state was the problem.

An appversion sha256 was stored raw, so an over-long value was truncated to the
column width and came back as 64 plausible hex characters - indistinguishable
from a real digest, and wrong. A digest is a claim the delivery path checks
against, so it is 64 hex characters or it is refused. The releasedate goes
through the strict parser for the same reason it did everywhere else.

PUT /api/settings/<key> ignored the declared valuetype. "A6-not-a-number" went
into an integer key verbatim and every consumer then read 0, because
get_typed_value swallows the ValueError - the setting looked set and the
behaviour was the default.

/api/maplevels told a caller who omitted the level name that 'buildingname and
buildingid are required'; buildingname is a field that handler never reads. It
now names the fields it actually wants.

Restoring a guide revision that is already current writes nothing, because
save() dedups on the content hash - and the response still said "Restored
version N as a new draft version", describing a revision that does not exist
while the unchanged revision list disagreed.

GET /api/backups/revisions/<id>/diff?against=<id> accepted any revision id, so
pointing it at another asset's revision rendered that asset's configuration as
the "before" side of this asset's history: a diff that reads as a change on this
machine and describes a different one. Same asset and same kind, or refused.
The default branch was always asset-scoped; only the explicit one was not.

Two frontend fixes: the per-page selector rendered blank on any list defaulting
to a size not in [10,20,50,100] (network devices uses 25), so the operator could
not see the current page size; and an emptied PC alias was dropped from the
payload instead of sent, so it could not be cleared - the form came back showing
the alias that had just been deleted.

Twenty-two tests. The backups guard verified by planting.

Scope: product
2026-09-03 16:13:59 -04:00
cproudlock
1a8f426e93 Make revoking final, keep password rules in one place, drop a grant nobody read
Three credential and authz findings, all the same shape: a second path to a
decision, with weaker rules than the path built for it.

PUT /api/apitokens/<id> {"isactive": true} un-revoked a token, and the ORIGINAL
secret worked again - so "revoked" meant "paused until somebody flips it back",
for a credential whose secret is assumed to be out by the act of revoking it.
Revocation is one way now. Deactivating through the same route still works;
bringing one back does not, and the refusal says to mint a new token.

PUT /api/users/<self> accepted a `password` field with no current-password
proof and no length rule, while /api/auth/change-password requires both. When
two routes reach the same state the weaker one is the real rule, so the stronger
one was decoration. Changing your own password through the users route is
refused and points at the right endpoint. An ADMIN may still set one - that is
how a temporary password is issued - with the same 8-character floor, and
mustchangepassword is now set, because a password somebody else chose is
temporary by definition.

tools.secrets was declared guest-grantable, so the role grid offered it and
stored it, and the secure-send route never consulted the guest role at all: it
decides on securesend_allow_guests, which fails closed and carries the rate
limit. Ticking the box changed nothing in either direction, so the screen and
the behaviour disagreed. The declaration is gone; tools.view stays, because it
really does gate the nav entry.

Removing it needed care: update_role refuses the WHOLE request when a submitted
permission is not grantable, and the grid resubmits what the role already holds,
so a site whose guest role held tools.secrets could no longer save that role at
all - the deadlock fixed in 723a4ff, re-created from the other end. Migration
7d41 revokes the grant from the guest role, and nothing is lost because it
granted nothing.

Eight tests. The first two verified by planting: remove either guard and three
fail.

Scope: product
2026-09-03 16:03:47 -04:00
cproudlock
f6e5f6ca47 Store and serve a maintenance date as a date
The machine maintenance panel showed every date one calendar day early on this
site, and would show it a day late east of UTC.

lastmaintenancedate and nextmaintenancedate were DATETIME columns holding
midnight, and BaseModel.to_dict serialises a datetime as isoformat + 'Z', so
2026-12-15 00:00:00 went out as "2026-12-15T00:00:00Z" and the browser
converted it into local time. There is no time in a maintenance date: the form
offers a date picker, the CSV importer takes '2026-06-01', and the value is only
ever compared against other dates. Every sibling already models it correctly -
measuringtools.lastcalibrationdate, warranties.startdate and enddate are all
db.Date - so machines were the odd one out.

Migration 7d40 converts both columns; MySQL truncates a midnight time so no
data moves, and SQLite neither enforces nor needs it.

That exposed two more things. The route was assigning the raw request string to
the column, which a DATE column rejects outright - it now goes through the
strict parser from wave 2, so a mistyped maintenance date is refused with a 400
rather than stored or nulled. And BaseModel.to_dict had no branch for a plain
date, so one reached Flask's encoder and came out as an HTTP-date
("Thu, 15 Jan 2026 00:00:00 GMT") - which a browser parses back into a moment
and can still render as the day before. Dates now serialise as "2026-01-15",
with datetime checked first because it is a subclass of date.

Verified against MySQL on a live instance: the column is DATE, the API serves
2026-01-15, a mistyped date is refused with 400, and a rename on a machine that
has maintenance dates saves - the 500 from the audit-log fix in 5d81f50c, now
confirmed end to end rather than only in tests.

Scope: product
2026-09-03 15:51:22 -04:00
cproudlock
fb63ef9d30 Make a retired asset findable and restorable from the product
The retire dialog says "History and relationships are kept, and it can be
restored." The API always kept that promise - ?active=false lists retired rows,
PUT {"isactive": true} restores one - and no screen in the product could reach
either. A retired asset simply vanished: the lists filtered it out, global
search missed it, and its detail page still rendered Edit and Retire with
nothing anywhere saying it was retired.

Five list pages gained a "Show retired" toggle that passes active=false, and a
retired row now carries a badge. Five detail pages gained a banner saying the
record is retired and what that means, a Restore button, and the suppression of
Edit and Retire while it is retired - offering Retire on an already-retired
record was the part that made the state invisible.

Verified end to end in a browser: retire a machine, the detail page says so and
offers Restore, the list hides it until the toggle is ticked and badges it when
shown, and Restore puts it back (isactive true again on the API).

One thing this does NOT do: global search still does not reach a retired asset.
That is a separate surface with its own filter, and worth deciding on
deliberately rather than sliding in here - a search that returns retired
records by default would change every result page.

Scope: product
2026-09-03 15:33:21 -04:00
cproudlock
61a1c0b4fb Answer API errors in the envelope, say when supplies are unread, badge each chain
Three more findings from the 2026-09-03 run.

Werkzeug answered 405, 415 and a malformed JSON body itself, in HTML. A client
written against the documented {status,data,meta} contract got a page of markup
with no error code, which is indistinguishable from the server having fallen
over. The 405 had a second edge: on a route the caller cannot otherwise see, a
method-not-allowed CONFIRMS the path exists, so a gated kiosk route meant to
read as absent announced itself by refusing the wrong verb. All three now
answer in the envelope, and only under /api/ - everything else is the SPA and
keeps the framework's behaviour, deep links included.

The Toner Report rendered "0 Printers Checked / 0 Low / 0 Critical" and "No
printers match the selected filter" while the API it had just called said
supply monitoring was unreachable. Zeros read as a clean bill of health for a
fleet nobody has looked at. The page now carries the reason the API gave and
suppresses the counts, because a number nobody measured is not a result. Same
defect shape as the supplies card fixed on 2026-08-29; `unavailable` was
already in the payload and the page ignored it.

Backup revisions badged "current" per (asset, kind). NTLARS settings describe a
MACHINE but are read off the controlling PC, so a machine with two reporting
PCs has two independent chains of the same kind - and only the higher revision
id was badged, so the second PC's newest revision showed as history and lost
its last-verified stamp. The machine looked like one source re-reporting rather
than two. sourcehostname is the chain; a revision without one forms its own,
which keeps the old behaviour for everything that has only ever had one source.

Nine tests. The envelope ones verified by planting: remove the three handlers
and three fail.

Scope: product
2026-09-03 15:21:58 -04:00
cproudlock
2fb924f18e Let a printer driver carry the name Windows actually matches on
The driver form and the API both dropped `drivername` and `vendorid`. POST
returned 201 with both null, PUT accepted them and stored nothing, and no
screen offered either field - so there was no interface in the product that
could set them.

That is not a cosmetic gap. `drivername` is the exact string the INF declares,
and Add-PrinterDriver matches on it; `name` is ours to choose and means nothing
to Windows. With it null the shipped converger has nothing to match and
installs nothing, and the drift check that compares an observed driver name
against the expected one could never fire, because the expected value was never
stored. `vendorid` is how a universal driver (HP UPD, Xerox GPD) attaches to a
whole make instead of needing a near-duplicate row per model.

Both are now read on create and update, and the driver form gained the two
fields with hints saying what they are for - the INF name box says plainly that
leaving it blank means the converger cannot install that driver. A whitespace
value clears rather than stores: an empty box means "not known", and a blank
string would look set and match nothing.

Three tests. Verified by planting: drop the two fields from the create path
again and the round-trip test fails.

Scope: product
2026-09-03 15:16:33 -04:00
cproudlock
b1e5dd734d Draw a dual-bay secondary where it actually is, and make two counts add up
Three map and aggregation defects. The first is a behaviour change you asked
for; the other two are the 2026-09-03 run's findings.

A dualpath secondary bay was excluded from /api/assets/map unconditionally
while the collapse setting is on, so positioning it was a silent no-op: the
form took the pin, stored mapx/mapy, and the map never drew it. One marker per
pair is right when the pair sits in one place, and that stays the default - but
a dual-bay machine whose second bay is physically elsewhere is exactly what
positioning it separately means. Now: a secondary with its own position draws
its own marker; one without is represented by the primary's, which carries the
combined '2007 / 2008' label. Nothing extra is needed for the fallback, because
the feed already requires mapx/mapy - an unpositioned bay never reaches the
loop.

The combined label is now suppressed when the partner draws its own marker.
Labelling two separate pins '2007 / 2008' would say one machine is in two
places, which is worse than either bay being unlabelled.

Verified against a live instance: unpositioned secondary gives one marker
labelled with both bays; positioned gives two markers each with its own name;
clearing the position puts it back to one. That third case matters - a
one-way door would be its own bug.

machines/dashboard/summary applied the dualpath exclusion to total and bytype
but not to bystatus, so the status bars summed to more than the headline above
them, by exactly the number of secondary bays. A breakdown that does not add up
to its own total is an afternoon of reconciling.

The measuring-tool calibrationstatus filter ran AFTER pagination. It is a
derived value so it cannot be a SQL filter, but applying it to the current
page meant asking for 'overdue' with one overdue tool in sixteen returned an
empty page 1, a total of 16, eight pages, and the row itself on page 3. When
that filter is in play the matching set is now built and filtered before
paging - the same tradeoff the old comment named, spent on correct answers
instead of a cheaper query.

Seven tests. The pagination one verified against the committed version, which
fails it.

Scope: product
2026-09-03 15:07:49 -04:00
cproudlock
1d2bbb3630 One key for audit retention, and stop the SAML page claiming a feature exists
Wave 6: two settings pages that told an administrator the site was configured
when it was not. That is worse than a missing feature, because nobody goes
looking for it.

Audit retention had TWO keys. shopdb/core/services/auditretention.py reads
`audit_retentiondays` (category site, default 0). A second `audit_retention_days`
was seeded under category audit with a default of 90, and the Audit & Logging
page wrote to that one - so an administrator set a period, reloaded, saw it
persisted, and nothing ever consulted it. Retention stayed off. The page now
writes the key that is read, the duplicate is out of the seed list, and
migration 7d39 carries a value an operator actually set on the dead key over to
the live one before deleting it: their intent was "keep 30 days", and the fix
should not silently discard it. The page's copy also claimed logs "will be
automatically purged", which nothing does - purging is manual, from the site
settings page or `flask audit purge --apply`.

The Authentication page offered a full SAML configuration - metadata URL, entity
id, ACS URL, auto-create, admin group - and reported "Configured - SAML login
available" once two fields held text. There is no SAML anywhere in the codebase:
no login route, no ACS, no assertion handling. SAML IS planned, so the settings
stay and the page keeps them; what changes is that it now says plainly that
nothing consumes them yet, and the status line never claims availability.

The dangerous control there was "Allow Local Login". Turning it off did nothing
at all, so an administrator could believe they had closed local password login
while it stayed wide open. It is locked on until there is another way in, and
says why. CONFIG.md marks the whole block reserved rather than describing it as
working configuration.

Scope: product
2026-09-03 14:53:57 -04:00
cproudlock
45acaf0b8f Resolve an asset type by name, and send a map position with its level
Wave 5, the asset forms. Both defects are the same mistake in different
clothes: a form deciding something it should have been told.

Four forms carried a hardcoded asset-type id - const COMPUTER_ASSETTYPEID = 2,
and the same for machines, printers and network devices - to tell
CustomFieldsInputs which type's fields to render. Those ids come from seed
order. On the instance they were written against, computer was 2; on a freshly
seeded database computer is 1 and 2 is network_device. So the PC form asked for
another type's custom fields, rendered them, and dropped everything typed into
them behind a success toast, because the values were keyed to a type the record
does not have. Verified in a browser against a correctly built dist: the form
now requests assettypeid=1 on an instance where computer is 1.

The name is the stable thing - assettype is a fixed vocabulary and the API
already publishes the mapping - so composables/assetTypeIds.js resolves it once
per page load. A failed load leaves it undefined, which renders no custom-field
panel: a missing panel is honest, a guessed id is what this replaces.

Three of the five forms that emit a map position sent mapx and mapy without
levelid. ADR-017 is explicit that a position without its level cannot be drawn
on the right floor plan, so a picked pin saved a null level and rendered
nowhere - while the form displayed "Position: x, y" back, because it kept the
value it had never sent.

The ADR-017 gate should have caught that and did not: it globbed '*.py' only,
so it policed what the server emits and never what the browser sends. It now
covers .vue and .js too, and accepts both quoting styles. Verified by planting:
drop levelid from PCForm again and the gate names that exact line.

Five composable tests, including a fixture with a DIFFERENT seed order - the
case the constants got wrong.

Still open from this area, and deliberately not in this commit: the PC form
writes an INHERITED location back as the record's own. Fixing it needs the API
to distinguish an owned locationid from a derived one, which is a contract
change rather than a form fix.

Scope: product
2026-09-03 14:42:24 -04:00
cproudlock
ffced2f822 Keep a share path inside the share, a UTC timestamp in UTC, and a public map public
Three confirmed findings from the 2026-09-03 run, each independent.

GE-Enforce scope names are directory names on the imaging share, and create_scope
checked only that the name was non-empty. "../escape" wrote a manifest one level
above the share root, "../../escape2" two, and "/tmp/anywhere" wherever it liked
- proven by writing the files and then deleting them. The exposure is the
permission split rather than the traversal itself: geenforce.manage creates the
scope and geenforce.publish writes the file, so a manage-only author could aim a
publisher's write at any path that process can reach. Names are now a single
safe path segment, checked at creation AND again at the write (rows predating the
check exist, and the write is the line that acts), with the resolved target
confirmed to sit under the share root.

Agent timestamps were converted with astimezone(tz=None), which means the
SERVER's local zone, not UTC. An agent reporting 17:26:16Z was stored as
13:26:16 on this EDT box and re-served labelled 'Z', while the same row's
server-written lastreportedat stayed true UTC - one row carrying two time bases
four hours apart. The dashboard's 48-hour stability window compares a real UTC
cutoff against these values, so events left it four hours early. Invisible on a
UTC server, which is how it survived, and nothing in the Dockerfile, the compose
files or the Windows deploy pins TZ. AGENT-CONTRACT.md mandates UTC on the wire,
so a naive value is now taken as UTC rather than guessed at.

The three /api/maplevels reads went back to optional-auth. ADR-017 rule 6, the
API inventory (auth "none"), the module's own docstring and the comment in
PrinterInstallerMap.vue all say they are public, because the printer installer
map runs before anyone logs in and needs a blueprint to draw on. afce2437 swept
165 genuinely-open GET routes behind require_permission and caught these in the
net, so the anonymous installer map has drawn no floor plan since. Restored
rather than amending the ADR: blueprint paths and level names are not secrets,
and the asset positions that go on top already render on public kiosk pages.
The writes stay gated, and a test proves it.

Registered in PUBLIC_ENDPOINTS with the reason. That gate checks a public route
is DECLARED public; the new test checks it actually answers, which is the half
that was broken.

Ten tests. All three verified by planting: restore the non-empty scope check and
eight fail, restore the local-zone conversion and two fail, put the permission
back on the level list and one fails.

Scope: product
2026-09-03 14:34:12 -04:00
cproudlock
5d81f50c33 Stop the audit log from being what fails the write
Wave 4, second half. PUT /api/machines/<id> returned 500 and rolled the whole
edit back for any machine with a maintenance date recorded - including a rename
that never touched the date. The Machine edit form could not save such a
machine at all, which is most of them once a maintenance schedule exists.

The route builds {'old': getattr(row, field)} straight off the model, so the
changes dict carries whatever the column holds: a date, a datetime, a Decimal.
AuditLog.changes serialised it with a bare json.dumps, which raises on all
three, and nothing caught it. The audit entry and the edit share a transaction,
so the failure took the edit with it.

json.dumps now has a fallback encoder: dates and datetimes become ISO strings,
Decimals become floats, sets and bytes get something readable, and anything
else becomes its repr rather than an exception. An audit entry that reads a
little awkwardly is worth more than a failed write - the alternative, and what
happened, is losing the change AND the record of it.

Fixed at the audit log rather than in the machines route: every audited route
builds its changes dict the same way, so the next one to audit a date field
would have hit the same wall.

Four tests, including the exact shape the machines route produces. Verified by
planting the bare dumps back: two fail.

Scope: product
2026-09-03 14:25:55 -04:00
cproudlock
13e992d863 Ask what the caller may do, not what its owner may do
Wave 4, first half. A personal API token scoped to endpoints.command alone
could queue system_restart, run_script and update_app against a shop-floor PC.

require_permission has always suspended the admin bypass for a scoped token,
and tests pin that. What nothing covered was a route checking a SECOND
permission by hand: the endpoints command route asked
current_user.haspermission(elevated), which reads the token's OWNER. An admin
minting a narrow token for a script therefore handed out remote restart, script
execution and app deployment - the exact bypass require_permission exists to
prevent, reintroduced one hand-rolled check at a time.

caller_holds is now scope-aware, so the answer is about the caller rather than
the person behind it: the owner must hold the permission AND, when the request
carries a scoped token, the scope list must name it. An unscoped token is the
person and is unchanged; a session is unchanged; guests still answer from the
guest role.

Both hand-rolled checks in the tree now use it - the endpoints elevated gate and
the secure-send tools.secrets check, which had the same shape and the same
consequence at a smaller scale.

Two tests, one proving the refusal and one proving an unscoped token still
passes (a fix that quietly broke every service token would be worse than the
bug). Verified by planting the owner-based check back: the first fails.

Reported by the fleet-agent lane of the 2026-09-03 test run and confirmed by an
independent verifier that minted its own token and queued its own commands.

Scope: product
2026-09-03 14:23:10 -04:00
cproudlock
debef39a18 Answer a nonsense filter with a 400, and sweep every list route for the rest
Wave 3. Six findings, one cause: `int(request.args.get('typeid'))` with nothing
catching the ValueError. The caller sent a bad argument and got
500 INTERNAL_ERROR plus a traceback in the log, which says the server is broken
when the truth is that 'abc' is not a number.

shopdb/utils/queryargs.intarg reads it and raises QueryArgError, which an
app-level handler answers as a 400 naming the argument. App-level on purpose: a
route should not have to remember, and forgetting was the entire defect. The
policy differs from get_pagination_params, which falls back to a default for a
bad page number - a nonsense page is harmless and the caller still wants their
list, but silently ignoring a nonsense FILTER returns rows they believe they
excluded.

The gate matters more than the six fixes. tests/test_core/test_bad_query_args.py
sweeps all 131 registered no-parameter GET routes under /api/ with twelve
argument shapes and fails on any 5xx. It found sites the test run had not
reported - statusid and locationid on /api/assets, the second asset-list route,
limit on the employee lookup and the printer supply history - because the
pattern is how a list filter gets written rather than a property of any one
route. Verified non-vacuous by planting the old `int(...)` back into
/api/machines: four tests fail.

Four usb routes are skipped by exact path, with the reason: they reach the
EXTERNAL cmmc_usb database, so their 500 in a test environment is a missing
database rather than an argument fault. That is the complete set - every usb
GET route calling cmmc_usb_connection - not a convenient subset.

One correction worth recording: the first pass rewrote every
`if x := request.args.get(...)` in the touched files, including text arguments,
so a search for 'ZZNOTHINGMATCHES' briefly became a 400. Caught by
test_list_search_identifiers. Only id-shaped arguments are integers.

Scope: product
2026-09-03 14:18:49 -04:00
cproudlock
12392a361e Refuse a value the database cannot store, instead of quietly changing it
Wave 2 of the remediation plan. Eight findings from the feature test run were
the same defect twice: a write that reports success while storing something
other than what was sent.

The first half is a date parser that swallowed its own error. Two plugins
carried an identical private _parse_date returning None on ValueError, so an
unparseable date and a deliberately cleared one produced the same write.
PUT /api/warranty/<id> with enddate '30-06-2027' answered 200 "Warranty
updated" and stored NULL, dropping the record out of every expiry surface on
the strength of a typo; the measuring-tool calibration date did the same and
flipped the tool to 'unknown', out of the calibration report and the map
overlay. shopdb/utils/datevalues.py now holds one strict parser on the contract
surface: an explicitly empty value clears the field, anything that is not a
date is refused with a 400 naming the field, and those are different intents
with different answers. The vendor-lookup paths keep a lenient wrapper on
purpose - a provider changing its date shape must not fail the refresh of an
otherwise good record, and no person typed that value.

The second half is why the truncation findings were possible at all. MySQL was
running non-strict: sql_mode on the 5.6 server is NO_ENGINE_SUBSTITUTION with
no STRICT_TRANS_TABLES, so it accepted writes it could not store and adjusted
them. A 257-character username became its first 100 characters (and the next
distinct one then collided with it); an over-long asset number was cut at 50; a
malformed releasedate became '0000-00-00' and permanently broke every read of
that application. All with a 201. The mode is now set on the CONNECTION rather
than left to the server, so a site whose my.cnf disagrees still gets it.

That trades silent corruption for a DataError, which uncaught is a 500 - and a
500 says the server is broken when the truth is that the value does not fit. An
error handler maps it to a 400 naming the column where MySQL names one.
Verified by hand against MySQL 5.6: POST /api/users with a 257-character
username now answers 400 "That value is too long for username." and stores
nothing, where it previously stored a truncated row and answered 201.

Twelve tests. The date guard is verified by planting: restore the swallowing
parser and four of them fail. The DataError tests raise the error directly
because the suite runs on SQLite, which does not enforce VARCHAR length at all -
there is no value that would trigger it there, which is precisely why this went
unnoticed.

Scope: product
2026-09-03 13:58:41 -04:00
cproudlock
aa8028f96e Let the row refuse the write both kiosks were deciding in Python
Wave 1 of the remediation plan for the 2026-09-03 feature test run. Two kiosks,
one shape of defect, one critical consequence.

Both kiosks read the state, decided in Python, and then wrote. Nothing stops two
requests interleaving between the read and the write, so both see the same
pre-state and both proceed. Reproduced on MySQL 5.6 over HTTP, five concurrent
takes of 5 from a bin holding 5: all five were accepted and quantityonhand
landed on -20, because each request wrote its own arithmetic. The ledger had
recorded every one of them, so the cached quantity and the ledger disagreed
permanently, which is the number the shop floor actually reads. The USB kiosk
had the same shape: concurrent scans of one drive all succeeded and inserted
several open custody rows, so one drive was recorded out to two people and the
documented 409 never fired.

The comparison now lives in the WHERE clause, where the database settles it at
write time. printedparts._move_quantity issues
UPDATE ... WHERE quantityonhand >= :n and treats rowcount 0 as the refusal,
raising InsufficientStock, which take and adjust turn back into the message they
already gave - carrying the quantity the database holds now, not the stale one
the request read. The low-stock alert derives its before-value from what the
write produced for the same reason. Both USB paths claim the device with
ischeckedout (self-hosted) or status (external cmmc_usb) in the WHERE clause;
the external one claims BEFORE writing the log line, so a refused claim leaves
nothing to undo. Check-in is guarded the same way.

After the fix, the same five concurrent takes: one accepted, four refused,
quantityonhand 0 and the ledger sum 0.

Check-in also closes EVERY open row for the device rather than the newest.
One device can only be out once, so a second open row is corruption, and
closing only the newest left the older ones open forever - the device then read
as returned while its custody history said otherwise.

Separately, usbcheckouts now records who handed a drive back
(checkinsso/checkinname, migration usb0004checkinidentity). Returning for a
colleague is a supported flow that the kiosk asks to confirm, but the row it
wrote attributed the check-in to the HOLDER, so the custody history and the
audit report both named somebody who was not there. Nullable and deliberately
not backfilled: copying the holder's badge into old rows would manufacture
evidence for the exact claim this stops making.

Four tests. Both guards verified by planting: strip the condition out of either
UPDATE and its test fails.

Scope: product
2026-09-03 13:58:26 -04:00
cproudlock
377cc69264 Make a partly-undone import retryable, and refuse a plan it cannot deliver
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Failing after 4m44s
Five findings from the review of the WINC import, plus the rendering each fix
needed to be usable.

An undo that hit a conflict wrote status 'undone' anyway, and undo_batch returns
immediately for an undone batch. So the one entry that needed a retry was the
only one that could never get it: the human resolves the conflict, clicks Undo,
and gets a no-op. A conflicted undo is now 'partially-undone', undoneat is
stamped only when the batch is genuinely finished, and the batches table offers
"Retry undo" for that status. The column was String(16) and the new value is
exactly 16 characters, so migration 7d38 widens it to 32 rather than rely on an
exact fit.

_UNDO_REFERENCES listed only assets, so a business unit this batch created was
hard-deleted even when something else still pointed at it. Two of the three
missing referrers are real FKs (dashboarddefaults.businessunitid,
businessunits.parentid): MySQL raises and the whole undo rolls back, so the
batch cannot be undone at all. The third, notifications.businessunitid, has no
FK, so the delete succeeded and left a notification pointing at an id that no
longer exists - silently, on both engines. All four are listed and the guard is
tested through the route.

The reference guard also queried tables that need not exist. A lean site drops
the tables of plugins it does not install (ADR-014), and notifications is
plugin-owned, so on such a site the query raised 1146, rolled the undo back and
left the batch permanently un-undoable. Absent tables are skipped; a table that
does not exist cannot hold a reference.

_PACKET_GROUPS reports on location, but apply_plan only ever creates business
units - it sets locationid only when the location already resolved. A plan
needing a new location asked the admin to acknowledge it, got the tick, wrote
nothing, and reported nothing. build_packet now takes the groups the writer can
actually create; anything else needed is 'uncreatable' and blocks at plan time,
where the admin can act on it, rather than dying at commit on a message about
acknowledging something the screen had no control for. The writer refuses the
same case independently. Both the console packet and the import page name the
blocking groups: setting blocking without printing the reason would leave the
banner pointing at nothing.

The web undo route caught ValueError only, so refusing a batch belonging to
another importer surfaced as a 500. That refusal is new too: the journal is a
general primitive and undo_batch does not filter on kind, so any other
importer's batches were reversible through this endpoint, carrying WINC's
references map - which lists none of that importer's tables, making every
reference check pass and every delete unconditional.

Nine tests. The two guards were verified by planting violations: dropping
dashboarddefaults from the references map, and passing creatable=None, each fail
the test written for them.

Scope: product
2026-09-03 09:09:55 -04:00
cproudlock
405a2ef919 Cap request bodies, close a hole in the gate, fix to_dict and two settings pages
Six fixes that were ours regardless of what the sister site does. Four came out
of our own reviews; two you reported from the settings pages.

MAX_CONTENT_LENGTH was set nowhere. Every upload route caps its own payload, but
each of those checks runs after the body has arrived: the first touch of
request.form or request.files makes Werkzeug parse the whole multipart and spool
the file to a temp file, so a route can reject the content and still have paid
for the transfer and the disk. Three separate reviews found that shape in three
different routes, which makes it a property of the app rather than of any
handler. 128 MB, clearing the 64 MB agent binary with room for the envelope.

The naming gate excluded devnotes with --exclude-dir, which matches a directory
of that NAME at any depth - so a force-added plugins/<plugin>/devnotes/ was
invisible to every check in the file, ADR-015 included. Replaced with a path
anchored post-filter. Verified by planting the same site literal in root
devnotes/ AND in plugins/endpoints/devnotes/: the gate now reports exactly one,
the nested one. Before, it reported none.

BaseModel.to_dict walked __table__.columns and published every one, so a model
without an override published whatever it carried - including columns added
later by someone not thinking about this method. User has no override, so
User.to_dict() returned its bcrypt hash. Nothing calls it in a response path
today, which is luck rather than design. A blanket allowlist would empty the
output of all 34 models relying on the default, so this pins a floor instead:
named secret-bearing columns are never emitted, and a model may extend the set.
Eight tests, including a sweep over every mapped model.

ActionPicker's Esc handlers had .prevent but not .stop, so the event bubbled to
the wrapper and closed the whole picker instead of returning to the monitor list.

The branding page reported "error loading image" on the site and badge logos.
Two bugs stacked. The load failure was real: the preview used the stored value
as a raw src, and that value is root-relative, so under /shopdb or /ops it
resolved against the SERVER root and 404d - siteSettings.js wraps every logo in
withBase() and this page was the one place that did not. With that fixed two of
three were still invisible, because the shipped GE mark is fill="white" for the
dark sidebar and was rendering white-on-white. The preview tile now sits on a
mid-tone checker so artwork of any colour reads against it.

The floor-map levels table ran off the right with its cells crushed - nine
columns of inputs, no scroll wrapper, and .data-table is styled nowhere. My
first attempt reached for the global .table-container and made it WORSE: that
class also sets white-space: nowrap, and a full sentence of hint text lives in
one of the cells, so nowrap forced the table wider still. It now scrolls in a
local wrapper, the hint wraps, and the name input has a width instead of
collapsing to one character.

Suite 2377, frontend 324, gate green.

Scope: product
2026-09-03 08:38:47 -04:00
cproudlock
28269e429a Import the WINC batches CLI and its README
One commit from the sister site, purely additive: a read-only `batches` command
so an operator working from the shell can find a batchid to undo, and a README
covering the four WINC commands. Two new files under scripts/site_imports/winc/,
nothing existing touched.

Verified rather than assumed: it changes none of the files fixed in 13be0ef9
(the workflow YAML, the approval check, the agentpackage description, the upload
audit), the gate passes, all five workflows still parse, and the project map is
current.

Suite 2369.

Scope: product
2026-09-03 08:14:18 -04:00
cproudlock
13be0ef90f Import the wilm agent store and WINC adoption work, with four fixes
Eleven commits from the sister site: a reversible Maximo/WINC machine-adoption
import, a versioned agent binary store with upload and rollback, remote-BuildKit
CI, tray version reporting, and UI fixes. Applied as a patch again - the
published mirror rewrites hashes, so the branches have unrelated histories and
git refuses to merge them.

Reviewed by three agents before merging. Four things fixed here.

THE PUBLISH WORKFLOW WAS DEAD. The new remote-BuildKit step in
publish-images.yml sits at column 0 instead of indented under steps:, so the
whole file is invalid YAML and GitHub would refuse it. Their entire GHCR
distribution channel - which Start-ShopDBDev.ps1 and the compose files consume -
was broken at the source and clearly never run. Indented; all five workflows now
parse.

THE IMPORT'S APPROVAL CHECK COULD NOT FAIL. commit() built the plan and the
approval packet from the same upload and then asked the writer whether they
matched, so packet_matches_plan compared a hash against itself. The documented
contract - "a human approves a specific, frozen plan, and the writer can prove
nothing drifted" - did not exist in the web path: an admin could preview file A
and commit file B, gated only by the needs-create group NAMES they ticked. The
preview's planhash is now required at commit and checked against a freshly
rebuilt plan. Their tests committed without previewing, which is what let this
through; they go through the real flow now, and three new tests pin it - all
confirmed to fail with the check stubbed out.

endpoints.agentpackage QUIETLY GREW. It used to mean "wrap the staged exe in a
ZIP"; with the upload routes it also means "choose the executable every future
package installs and runs as SYSTEM". Before this, staging needed filesystem
access to the server. The permission description still said only "generate and
download a package", so an admin granting it could not see what they were
granting. Reworded.

NOTHING RECORDED WHAT WAS STAGED, OR BY WHOM. The manifest held version, size
and timestamp; re-uploading a version replaces the bytes in place under the same
label. A sha256 of the stored file is now recorded, and the upload is
audit-logged with it - after an incident there was previously no answer to who
put that binary on the fleet.

Also documented the nine new routes in docs/api-inventory.json (that gate cannot
run on the mirror, so it falls to us at promotion time again), regenerated the
spec and the project map, and kept our own docs where they conflicted: docs/ is
scrubbed from the mirror, so their DEV-BUILD-TEST.md still had the dev-be
channel mapping we corrected, and their PLUGIN-HOOKS.md was a new 45-line file
rather than our 737-line canonical one. Their journal section is appended to
ours.

Contract version comes across as 0.30.0 for a genuinely additive surface.

Suite 2369, frontend 324, gate green.

Scope: product
2026-09-03 07:54:14 -04:00
cproudlock
7f6aaf2e64 Gate the USB kiosk the same way, and make an unreachable setting a test failure
The USB kiosk had a feature flag and nothing else. A flag is a decision, not a
credential: with the kiosk on, anyone who could reach the app could move a drive
between checked-out and checked-in and sign it with a badge they guessed. Same
reasoning that closed the parts kiosk, smaller blast radius - which is why
Darkwing did not raise it and why it was worth asking about anyway.

Both kiosks now share one mechanism. The device token moved out of the
printedparts api wrapper into a request interceptor that attaches X-Kiosk-Token
by URL prefix, with one localStorage key per kiosk - they are separate screens
in separate rooms, and a shared token would mean provisioning one grants the
other. claimKioskToken() is shared too, so both views take the token from a
?token= URL once and strip it from the address bar identically.

Then the part that matters more than either fix. I told you to set the new
settings in the UI, and that was impossible: every plugin settings page carries
a hardcoded key list, and nothing had added them. printedparts_kiosk_enabled,
printedparts_kiosk_token and usb_kiosk_token were declared, seeded and read
correctly, and rendered by no page - changeable only by writing SQL.

tests/test_plugin_settings_reachable.py now fails when any declared setting
appears nowhere in its plugin's frontend. It found seven MORE that were already
in that state, all pre-existing:

  collector_corporate_ranges          computers_configpolicies_enabled
  computers_machinelink_alerts        computers_quietreporthours
  geenforce_reportstaleminutes        printers_dashboardpercent
  warranty_expiringdays

All seven now have fields. I first parked them in a KNOWN_GAPS ratchet, then
fixed them and deleted the ratchet - a backlog list in a new check is a way of
agreeing the check does not apply yet. There is no backlog; the check is
absolute.

Two of my own mistakes on the way, both caught by tests rather than by reading:
the blanket rename of the USB guard also rewrote the call inside _kiosk_open, so
it called itself until the stack ran out; and the first version of the reachable
check skipped all 17 plugins because it looked for get_settings() when the
method is get_settings_defaults() - it passed by testing nothing, which is the
exact shape it exists to catch.

Suite 2336, frontend 316, build clean.

Scope: product
2026-09-02 18:08:34 -04:00
cproudlock
e4db112296 Use a valuetype the settings column actually documents
printedparts_kiosk_token declared valuetype 'secret'. The column documents
string, boolean, integer and json, and that was the only 'secret' in the
codebase - it worked only because Setting.get falls through to the raw string
when it recognises nothing.

Masking never depended on it: settings.py::_is_secret keys on the NAME
containing 'token', so the value is still hidden in API reads and in the audit
log. Changed to string, which is what it is.

Scope: product
2026-09-02 17:49:38 -04:00
cproudlock
744bc4341a Shut the parts kiosk behind a device token, and stop the health probe leaking
Both from the second Darkwing scan. Every claim in it was verified against the
code before acting.

The kiosk was the product's only unauthenticated write, justified by a decision
record saying it was "physically rate-limited: it serves a touch screen on the
shop floor". Nothing in code enforced the physical half, and an HTTP route is
reachable from wherever the app is. What that left open:

  - _kiosk_find_item falls back to matching bare digits against the numeric
    tail of every active item, so 1, 2, 3 walk the catalogue without knowing a
    barcode;
  - a badge is a short numeric SSO, and the route answered 422 for an unknown
    one and 200 for a real one - an oracle for sorting guesses into employees;
  - the write then decremented stock and stamped the ledger, which IS the audit
    trail, with whatever SSO the caller supplied;
  - quantity was bounded only by what was on hand, so one request emptied a bin.

A badge is attribution, never authorization. Both kiosk routes now need the
site's kiosk flag AND a per-device token, and 404 rather than 403 without them
so a caller does not learn the door is there. The READ is gated too: gating only
the write would have left the enumeration oracle running against a kiosk a site
had switched off.

The token is per-DEVICE, not per-page, because a kiosk is a browser on a touch
screen and a token served inside the page is readable by anyone who can load it.
An admin opens the screen once as /parts-kiosk?token=..., the page moves it into
that browser's localStorage and strips it from the address bar.

Also: quantity is capped per take, JSON true no longer passes as 1
(isinstance(True, int) is True), and every badge failure on the anonymous route
returns one message. The gated restock and adjust handlers keep the specific
message - there the person is known and the detail is help, not an oracle. I
changed the wrong one of those three handlers first; caught by the test.

/api/dashboard/health formatted str(e) straight into the body of a route
PUBLIC_ENDPOINTS declares open because "monitoring has no account". A pymysql
OperationalError names the database account, the app host's address, the driver
and the failing statement. It is logged server-side now and the body carries a
coarse status. The version goes with it - it told an anonymous caller which
release to look up. Status stays 200 either way: a monitor keyed on the body's
status field is the contract here, and turning a degraded database into a
transport failure is a separate decision from not leaking the exception.

Nine tests. The three gate tests were confirmed to fail with the gate stubbed
out, rather than assumed to cover it. Suite 2325, frontend 316, build clean.

Scope: product
2026-09-02 17:42:54 -04:00
cproudlock
852aec5b7f Verify the launch-path hardening on Windows, and fix what that found
The previous commit said the link-resolving change had never met a real
filesystem. Run in the win11 VM against real fixtures, it turned out to reject
EVERY legitimate launch path.

ResolveDirectoryChain walked up looking for a junction and eventually asked
whether "C:\" was a link. That throws DirectoryNotFoundException, and the caller
treats a resolve failure as a rejection, so the first legitimate case came back
"cannot resolve the real path: Could not find a part of the path 'C:\'". The
agent would have failed closed on everything - an outage wearing a security
fix's clothes. It compiled with zero warnings and read correctly.

Stop at the volume root, and never let "cannot tell whether this is a link" turn
into a rejection: TryResolveLink returns null on any failure, and the assembled
path is re-checked against the roots regardless, so an unresolvable component
still cannot widen what is accepted.

One expectation in the probe was mine being wrong rather than the code. 8.3
short names used to be rejected because the lexical prefix check could not match
C:\PROGRA~1 against C:\Program Files - fail closed, but by accident of not
understanding the path rather than by intent. Resolution now expands it to the
real directory, which is inside the approved root, so accepting it is correct.

Verified on Windows, all ten cases:
  accept  a real exe under the approved root
  accept  8.3 short name resolving into the approved root
  reject  exe reached through a junction pointing out of the root
  reject  alternate data stream on a file in the root
  reject  traversal back out of the root
  reject  UNC path
  reject  sibling directory sharing the root's prefix
  reject  wrong extension
  reject  file that does not exist
  reject  empty path

The probe is committed as agent/ShopDbEndpointAgent.PolicyProbe so this is
repeatable. It is NOT in CI and its README says why: junctions, alternate data
streams and 8.3 names do not exist on Linux, and a suite that skipped every case
it exists for would report green and mean nothing. This repo has been bitten by
that shape more than once already.

Scope: product
2026-09-02 16:13:01 -04:00
cproudlock
e98e15259e Unpick one site's conventions from the shipped bundle, and bind the launch-path
check to the file it actually runs

Two clusters the five-agent review found. Both are about a product that another
plant has to be able to install.

ADR-015: the shipped monitor bundle was the originating plant's fleet
convention frozen into product seed.

  The business policy was seeded as "RPCA Shopfloor App Monitors". RPCA is that
  plant's facility designator, the row is source='shipped', and the API refused
  to rename it - so every adopting site would carry another plant's label at the
  top of its policy list forever. 0007 now seeds the neutral name, and 0010
  renames it on installs that already stamped 0007, guarded so it cannot collide
  with a policy a site made itself. Downgrade is one-way on purpose: putting
  another site's designator back is not a state worth reaching.

  Which PC type the bundle attaches to was the literal 'Shopfloor PC'. A plant
  whose type is "Shop Floor Workstation" silently got no shipped monitors at
  all, and one using that name for something else got all of them. It is a
  setting now, defaulting to the seeded name so nothing changes here, and a
  blank value attaches the bundle to nothing - the right neutral answer for a
  site that does not want it.

  Shipped rows refused every edit, including isactive and launchpath. A plant
  whose eDNC is on D:, or that runs no UDC, could not correct the path or switch
  the monitor off, and then carried permanent false drift for it. ADR-015 allows
  seed data BECAUSE a site can edit it afterwards. Paths and the active flag are
  now editable; kind and displayname stay fixed, because that pair is the
  identity 0007's bundle matches on.

  Also corrected two false claims in 0007's docstring: the policy tables belong
  to the computers plugin, not the core baseline, and a guarded no-op does NOT
  reconcile later - Alembic stamps the revision either way, so the bundle would
  simply never seed.

LaunchPathPolicy was purely lexical. GetFullPath is string normalization that
never touches the disk, so the root check constrained the NAME while File.Exists
and CreateProcess followed reparse points to somewhere else. A junction under a
writable directory in an approved root - creating one needs no privilege, only
write access to the parent, and vendor trees under Program Files are not
reliably locked down - turned WebUI monitor-edit rights into code execution as
the console operator. It now resolves the real target, leaf and directory chain
both, and re-checks THAT against the roots, rejecting if resolution fails.

Alternate data streams passed the whole policy on net8.0: an interior colon no
longer throws in GetFullPath, so "readme.txt:payload.exe" reported an .exe
extension, passed the root check, and existed. Rejected explicitly now.

Six tests added for the shipped-row and pctype behaviour. The Core project
compiles clean. The launch-path change has NOT been exercised against real
junctions, ADS or 8.3 names - that needs the win11 VM, and per the reviewer it
wants a planted-violation suite there rather than my say-so.

Still open from the review: monitor revocation does not take effect until agent
restart, ActionPicker is keyboard-inoperable, the lightbox has no focus trap,
one attached monitor grants generic any-target process control, and screenshots
are retained unencrypted in the user profile.

Scope: product
2026-09-02 15:44:17 -04:00
cproudlock
88a683dad3 Act on the five-agent review of the wilm import
Findings the reviewers verified, fixed here. The larger cluster they found is
NOT fixed and is listed at the end.

Frontend. reportImageUrl is called from the template, so it ran on every render
with no in-flight guard: a report whose fetch was pending was re-requested, and
because each resolution reassigns the map the whole grid re-rendered and
re-fired the rest, costing up to N-squared requests for N thumbnails. Two blobs
resolving for one key orphaned the first object URL where revoke could not
reach it, and a failure was never recorded so a broken image retried forever.
Guarded, deduplicated, failures remembered.

Three CSS variables did not exist, so their hardcoded fallbacks always won:
--bg-alt (the "No screenshot" tile, near-black in light mode), and
--warning-text/-bg/-border (dark khaki on a dark modal). Replaced with real
tokens. The DEV badge's literal #c62828 is var(--danger).

Installer and launcher. The generated site installer ran docker compose with no
-p, so the project name defaulted to the package folder - which carries the
version. Installing the next version created a new project with new empty
volumes and the site came up looking wiped, old data stranded under the old
project. Pinned to shopdb-<channel>.

Start-ShopDBDev deleted any existing -TargetDir that lacked a .git, recursively
and without asking. For a script handed round as "save it anywhere and run it",
a mistyped -TargetDir pointed at Documents would have destroyed it. It now only
reclaims a directory that is empty or carries this launcher's own marker, and
refuses otherwise.

Dev secrets came from Get-Random, which is System.Random: a 32-bit seed, and all
four values off one stream, so recovering one recovers the rest. They sign JWTs
for a stack a pilot may point at real imported data. CSPRNG now, and the API
port binds to loopback like the DB rather than every interface.

Two of my own from earlier in this session. The operator-report size check sat
below request.form, and the first access to that parses and spools the whole
multipart - so it ran after the thing it prevents. Moved above. And the channel
remap missed build-channel-installer.ps1 entirely, where it set BOTH channels to
main: a "stable" package built from the trunk, the exact mislabel the remap was
for. stable now resolves the newest v* tag.

Also removed a zero-byte docs/ShopDB-Planner-Tasks-v2.csv that rode in.

NOT FIXED - needs a decision, and it is the biggest thing the review found.
Migrations 0006/0007 freeze the originating site's fleet convention into
product seed: a shipped policy literally named "RPCA Shopfloor App Monitors",
a hardcoded 'Shopfloor PC' type deciding who gets the shipped watch-list, and
shipped rows the API refuses to edit or deactivate - including their launch
paths. A site whose eDNC is on another drive cannot correct it and gets
permanent false drift. Those are stamped migrations, so this gets harder to
undo at every install. See the review notes before anyone upgrades a second
site through 0006-0007.

Suite 2314 passing, frontend 316, all three PowerShell files parse.

Scope: product
2026-09-02 15:33:48 -04:00
cproudlock
b5540e631c Merge the wilm endpoint-monitor and channel-distribution work into main
Six commits from wilm-import: the squashed import, route documentation, the
channel remap onto our tag-and-trunk model, the project map, parallel test
support, and the four smaller review findings.

Merged rather than cherry-picked: the published mirror rewrites hashes, so the
commits do not apply one at a time against our history even though the squashed
diff applies clean.

Scope: product

# Conflicts:
#	docs/PROJECT-MAP.md
2026-09-02 15:19:55 -04:00
cproudlock
720f4ddbb2 Close the four smaller findings from the wilm import review
None blocked the merge; all four were mechanisms that did not mechanise.

_RUNNING_STATES existed twice, in api/routes.py and services/reconciler.py, with
a comment asking a human to keep them in lockstep. The panel and the reconciler
read the same agent snapshot, so a drift between the copies would paint a
service green and flag it as drift on the same page. One definition now, owned
by the reconciler and imported by routes - api -> services is the direction the
plugin layout already runs in, and the reconciler's import of routes is deferred,
so there is no cycle.

The operator-report upload checked its 10 MB cap AFTER upload.read() had already
pulled the whole body into memory, so the check could not prevent the thing it
was there to prevent. content_length is now an early out and the read itself is
bounded to cap+1, which is enough to know the cap was exceeded without taking a
body that lied about its length. The multipart envelope carries the other form
fields too, so the early out allows 64 KB of slack rather than rejecting an
image that is just under.

The same handler forced the extension to .png and the mimetype to image/png
whatever the bytes were, then served the file back under that type. The upload
headers (CSP sandbox + nosniff) make the document case inert, so this was never
the hole it looks like, but nothing is gained by storing a file we already know
is not what it claims. The PNG signature is now checked.

The lightbox bound Escape and the arrow keys to a div and never focused it, so
every one of those bindings was dead until the user happened to click the
backdrop. It takes focus on open and hands it back to the thumbnail on close.

On the two upload tests, honestly: the PNG one was confirmed to fail with the
check removed. The oversize one passes either way, because the old code did
reject an oversized image - it just did so after reading all of it. That test
pins the response, not the fix. Whether the read is bounded is not observable
through the response, so nothing here proves it; the change stands on reading
the handler.

Suite 2311 passing, frontend 316 passing, build clean.

Scope: product
2026-09-02 15:07:50 -04:00
cproudlock
d45a0d15b4 Make the full suite runnable in parallel, and close the dev/prod pin drift
The suite had grown to ~2300 tests and 5m30s. Profiling says there is no hot
spot to fix: the 25 slowest tests sum to about 60s of 331s, and the rest is
per-test fixture cost. The db fixture drops and recreates all 94 tables for
every test, measured at 0.058s a cycle, which is roughly 40% of the wall clock
on its own.

That shape parallelises almost linearly, so pytest-xdist buys more than any
rewrite would:

    pytest -q          2309 passed in 331s
    pytest -q -n auto  2309 passed in 103s     (8 cores)

Deliberately NOT in pytest.ini addopts. Worker startup is dead weight on the
targeted single-file runs that make up most of a working day; the whole-suite
run before a push is where it pays. Opt in with -n auto.

A run distributed across 8 workers also has to be order-independent to pass at
all, so this is incidental evidence that the suite has no inter-test coupling.

Recompiling requirements-dev.txt to add xdist also moved idna, packaging and
pyjwt. That is the -c requirements.txt constraint doing its job rather than a
drift: all three ARE shipped, and the dev file had fallen BEHIND production
(idna 3.13 in dev against 3.19 in prod). CI was testing against older versions
than sites install, which is the exact failure the header comment on that file
was written about. Now matched.

Scope: product
2026-09-02 14:43:00 -04:00
cproudlock
28bf7ea1bf Regenerate the project map for the imported plugins and tests
Scope: product
2026-09-02 14:26:10 -04:00
cproudlock
2e42972f44 Map the image channels to how this repo ships, and disarm auto-publish
The channel work assumed the branch model it was written against: upstream,
main is a release branch and dev-be is the trunk. Neither holds here. main IS
our trunk - 212 commits past v0.11.3 - and a release is a tag (ADR-007). dev-be
does not exist at all, so the edge half would never have fired and the launchers
would have cloned a missing branch.

Taken literally it would have published our unreleased trunk to every site as
:stable, which is the opposite of what the word promises to someone who has no
way to check.

Remapped throughout:

  tag v*  -> :stable   the thing that was actually released
  main    -> :edge     the trunk, DEV badge lit

publish-images.yml keys the channel on github.ref_type = tag rather than on a
branch name, so the mapping cannot silently drift back. Start-ShopDBDev.ps1,
dev-bootstrap.ps1 and build-channel-installer.ps1 resolve stable to the newest
v* tag via git ls-remote --sort=-v:refname, warning and falling back to main if
a repo has no tags yet. DEV-BUILD-TEST.md follows.

The push: trigger is commented out. Automatic publishing waits on someone
confirming the GHCR package exists under this owner and deciding who may pull
it; workflow_dispatch works meanwhile. Turning it on is one uncomment.

All three scripts parse clean under pwsh, and the tag-selection expression was
run against this repo rather than eyeballed. Per the standing rule, the runtime
paths still want a pass in the win11 VM before anyone leans on them.

Scope: product
2026-09-02 14:16:24 -04:00
cproudlock
30b7a5a8bf Correct the claim that the two kiosks are gated to the same bar
Darkwing's reconnaissance flagged POST /api/printedparts/kiosk/take as an
unauthenticated write reaching a ledger commit and an outbound mail/webhook
trigger. Analysis dropped it, correctly: it is a declared exemption with a
decision record. Nothing to fix in the code.

The comment beside it was wrong, though. It described the USB kiosk writes as
open at the "same bar as the parts kiosk" while listing a gate the parts kiosk
does not have: usb_kiosk_enabled, off by default, every route a 404 until a site
sets it. grep -rn kiosk_enabled plugins/printedparts/ returns nothing.

So a site that adopts the product and has no parts kiosk still serves a live
unauthenticated inventory-decrement endpoint with no switch to turn it off, and
the registry that is supposed to be the record of these decisions said the
opposite. Whether printedparts should gain the same flag is a behaviour change
for a kiosk that is in daily use here, so it is left as a decision rather than
made silently; this only stops the comment misleading the next reader.

Scope: product
2026-09-02 13:54:06 -04:00
cproudlock
f3a291a28d Stop a request-supplied host reaching the generated installer script
Found by the Darkwing scan of main (run 33654922078). Its own review dismissed
the finding as not remotely exploitable and it is right about that: nothing can
make a victim's browser attach a custom header, and deploy/windows/web.config
does no output caching. It still recommended the hardening, and the reasoning
for the dismissal is a property of OUR IIS config, not of the product - every
site runs its own instance behind its own proxy under ADR-004.

_batch_base_url() interpolated a caller-supplied host into the single-quoted
PowerShell -Uri literal of the .bat this route hands out. site_base_url ships
blank, so that fallback is the default path on a fresh install, not an edge
case, and GET /api/printers/install-batch is public.

Three rules now:
  - the host is validated as a hostname before it is interpolated, so the quote
    that would end the literal never reaches it,
  - X-Forwarded-Host is only believed when TRUSTED_PROXY_COUNT declares a proxy
    -- the rule shopdb/utils/clientip.py already applies to X-Forwarded-For.
    That was centralised for this exact reason and did not cover the Host
    variant,
  - a request supplying neither usable header gets a relative base, which still
    downloads correctly from the browser that fetched the script.

Three tests pin it, and all three were confirmed to fail with the old body
restored rather than assumed to cover it.

Scope: product
2026-09-02 13:52:06 -04:00
cproudlock
56ed89bb7b Document the ten new endpoint routes and undo the stray re-indent
Two of the three things holding the wilm import out of main.

The monitor CRUD, the two resolver reads and the three operator-report routes
had no entry in docs/api-inventory.json, so test_api_inventory_parity failed.
The gate cannot run on the published mirror - the test ships but its data file
is scrubbed, and it skips on the file's absence - which is why the branch that
added the routes never saw it. Cost lands here at promotion time instead.

/api/endpoints is registered both with and without a trailing slash and both
forms are served, so both are described rather than guessing which one counts.
Spec regenerated: 353 paths, 504 operations.

plugins/endpoints/plugin.py had a contiguous run of continuation lines shifted
by one space. Python accepted it and git diff -w showed nothing, but it put
deletions in a diff that is otherwise purely additive. Restored; the file now
differs from the published main by additions only.

Scope: product
2026-09-02 13:49:54 -04:00
cproudlock
302a309b9d Add the Darkwing vulnerability scan workflows
GE Aerospace now expects a Darkwing check on every repo. Two files rather than
one, because they answer different questions and only one of them is ours.

scan-darkwing-portable.yml is copied byte-for-byte from the ge-aero/darkwing
README so it keeps matching the sanctioned integration. Manual dispatch only.

scan-darkwing-pr.yml runs on pull requests and is ours to change. It exists
because the README workflow cannot simply be given a pull_request trigger: it
resolves its target as `inputs.target_ref || 'main'`, and a pull_request event
carries no inputs, so the expression falls through to 'main' and the job scans
the default branch instead of the code under review. That is a check that is
green because it never looked. This one resolves the PR head SHA explicitly.

Neither file gates a merge. Darkwing exits 0 whatever it finds, and its results
vary between runs on identical code, so a passing job means the scan ran and
nothing more. The SARIF-parsing step that would turn a finding into a failure is
written but commented out, to be switched on once the false-positive rate on
this codebase is known.

Scope: product
2026-09-02 12:17:35 -04:00
cproudlock
3587a35ad6 Regenerate the project map
Test count had drifted from the tree. The map is generated; a stale one fails
scripts/gen_project_map.py --check in CI.

Scope: product
2026-09-02 12:17:24 -04:00
cproudlock
27b2817fd7 Import the wilm endpoint-monitor and channel-distribution work
Everything on origin/wilm that is not on the published main, applied as one
change because the published history has rewritten hashes: per-commit
cherry-pick conflicts against our main even though the squashed diff applies
clean.

Contents: endpoint monitor catalog + policy attachment + warn-only drift
reconciler, operator issue reports from the tray, agent launch-path allowlist,
and the GHCR channel-image distribution path for dev workstations.

NOT READY FOR main. Open items:
  - 10 new routes are absent from docs/api-inventory.json, so
    test_api_inventory_parity fails. That gate SKIPS on the published mirror
    (the test ships, its data file is scrubbed), which is why wilm never saw it.
  - dev-bootstrap.ps1 and Start-ShopDBDev.ps1 hardcode the ge-aero GitHub org.
  - plugins/endpoints/plugin.py carries a whitespace-only re-indent.

Scope: product
2026-09-02 10:05:44 -04:00
cproudlock
1480b30e8c Let a person edit the asset fields the pages already display
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m19s
Eight fields across three asset types were shown on the detail page and
accepted by the API, but had no input on the form. A wrong value therefore
needed database access to correct, which is not a thing anybody should need
for a device category or a maintenance date.

The shape is the same in each case: a column was added, the detail card was
written, the API allowlist was updated, and the form was missed. Nothing was
broken - the values arrive by import or collector - so nothing complained.

PCs gain azureobjectid and azuredevicecategory. azuredeviceid was already
there, which is what made the omission easy to miss: the page showed two ids
and the form offered one. The two are not interchangeable and the pairing is
counterintuitive - the DEVICE id opens Intune, the OBJECT id opens Entra - so
each input says where it goes.

Machines gain the three maintenance fields. These matter beyond the immediate
fix: they are the columns a measuring instrument's calibration dates map onto
when metrology moves to machines, and until now there was no way to enter a
calibration date on a machine at all.

Printers gain iscolor, isduplex and isnetwork as a capability group.
isnetwork defaults to true in the form to match the API's create default; a
new printer claiming otherwise would be wrong for nearly all of them.

Checked before making any of these editable that nothing writes them
automatically - a hand edit silently reverted by the next collector poll would
be worse than no input at all. The printers collector reports queues and
supplies, not capabilities, and nothing writes machine maintenance dates,
which is exactly why they are all empty. The two genuinely collector-owned
computer fields, lastreporteddate and lastboottime, are deliberately still not
editable.

Scope: product
2026-09-02 07:53:25 -04:00
cproudlock
c8e0de4dc1 Let the server decide which NIC is a PC's address, and keep the rest
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / backend (push) Successful in 4m15s
CI / migrations-mysql (push) Successful in 41s
A CMM reported 100.0.0.1 as its address and never reported 10.48.255.210 at
all. Both halves of that were the same mistake.

THE BAY WAS CHOOSING. The collector schema took a single `ipaddress`, so the
script picked one NIC and dropped the others - preferring an address inside a
configured range, else whichever interface carried a default route. A bay
cannot make that judgement: it does not know the site's addressing. On this
CMM the Marvell AQtion 10Gbit link to its controller is a real hardware NIC,
up, and carries a default route of its own, so it satisfied every test the
script had. It won, and the corporate address was discarded before the request
was even built - which is why it is not in ShopDB even as a second row.

So the bay now reports EVERY connected hardware NIC with a routable IPv4, and
takes no view. The server picks the primary from collector_corporate_ranges, a
new per-site setting, and writes a Communication row for each of the others.
Naming the ranges is what turns the answer from a guess into a decision; with
none set it still falls back to the default-route flag and then to the first
reported, both guesses, both better than before only because nothing is thrown
away now.

Rows are keyed on the MAC where the bay sends one, because an address moves
between adapters and a MAC does not. A NIC that is down at check-in is left
alone rather than deleted - being down is not evidence the record is wrong,
and hand-entered gateway, DNS and serial settings live on those same rows.
Exactly one row ends up flagged primary, and only a row this report touched
can claim it, so a check-in cannot silently demote an address set by hand.

-AllowedRanges and the CollectorRanges registry value are now accepted and
ignored, logging a line that says where the setting moved. The parameter stays
because a scheduled task still passing it would otherwise fail on an unknown
argument.

A bay on an older collector sends one address and no interfaces; that keeps
working and is pinned by a test.

Scope: product
2026-08-31 17:40:21 -04:00
cproudlock
241d8f40fc Add two commands for cleaning up computer types
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m10s
Twenty computer types existed and six had a machine on them. Three of the
empty ones were near-duplicates of live types - Shopfloor beside Shopfloor PC,
CMM beside CMM PC, Engineer Workstation beside Engineer - which is the kind of
list that grows because deleting from it by hand is nerve-wracking.

retype-by-role types a PC by WHAT IT CONTROLS. A metrology box has an active
relationship to a measuring_tool asset, so the fact is already recorded and
does not need inferring from a hostname or a tool's name. The imaging scope is
the authoritative source and stays so, but only 17 of roughly 292 PCs have
ever reported one, so it cannot answer for the fleet yet. Relationships can:
49 PCs drive a measuring tool, and 36 of them are currently typed as ordinary
shopfloor machines.

prune-types deletes a type only when nothing is on it AND the live pctype map
does not name it. The second guard is the one worth having: the map is what
the collector assigns from at check-in, so deleting a mapped type would leave
every future check-in logging 'mapped ComputerType not found' instead of
typing the machine.

Both report by default and write only with --apply, and prune prints why it is
keeping each type it keeps rather than only what it removes.

Also fixes `computers stats`, which counted the computer type named
'Shopfloor' - the EMPTY duplicate - rather than 'Shopfloor PC'. It has been
reporting 0 shopfloor PCs for as long as both rows existed, which is the same
duplication these commands clear up.

Scope: product
2026-08-31 16:51:54 -04:00
cproudlock
1bf4efa9e1 Link the Entra ids on a PC to the Azure portal
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m23s
The Entra Device Info card showed two GUIDs and nothing to do with them.
Copying one into the portal by hand is the kind of errand a detail page exists
to remove.

Each link sits BESIDE the id it is built from rather than in a row at the top
of the card, because the pairing is counterintuitive: the DEVICE id opens
Intune and the OBJECT id opens Entra. A separate action row would have to
re-label which is which, and would still leave the reader matching them up.

BOTH URLS ARE SETTINGS WITH AN EMPTY DEFAULT, and that is the whole design.
The host is a tenant fact, not a product one - this site is on the US
Government cloud, portal.azure.us, where a commercial-cloud default would send
people to a page that does not exist. Microsoft also moves these blade paths,
and a site that hits that can fix it by editing a setting rather than waiting
for a release. Empty means no link and the id still renders as text, so a site
that configures nothing is unchanged. The example values live in each
setting's description, per cloud.

Same shape as the ServiceNow URL templates already in integrations, down to
the {token} placeholder, so there is one idiom for "deep link into a system we
do not own" rather than two.

Scope: product
2026-08-31 16:23:58 -04:00
cproudlock
259fe0de8f Read a numeric natural key as a name before reading it as an id
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m18s
An Azure device import died part-way with a foreign-key error, and the crash
was the lucky outcome.

The Azure adapter routes a device that already exists to its real asset by
writing that asset's assetnumber into the assetid column of the CSV it
generates, expecting the engine to look it up. The resolver instead takes any
all-digit string at face value as a literal id. This site numbers operations
0NNN, so numeric assetnumbers are ordinary here: `0704` was read as id 704,
which does not exist, hence the error. `0600` would have been read as id 600 -
a printer - and bound a PC to it silently, reporting success.

Two changes, both needed.

The resolver now tries the label first for an all-digit string and only falls
back to reading it as an id when nothing is named that. The face-value
behaviour exists for a real reason - exports round-trip and carry real ids, as
its original comment says - so it stays as the fallback rather than being
removed. Somebody writing `0600` in a column keyed on assetnumber means the
asset called 0600, and binding the row to an unrelated record is far worse than
failing.

_existing_hostname_map now hands over the assetid rather than the assetnumber.
Giving the resolver the id it is going to treat the value as makes the
passthrough correct by construction instead of correct by luck.

Verified against a restored copy of the production database: 234 devices, 221
matching an existing computer and updating in place, 13 new, no problems. The
same run previously failed on the foreign key.

Neither half was wrong alone. The passthrough dates from the original CSV
import and the hostname map from the wilm merge months later; they only meet
at a site whose natural keys are numeric.

Scope: product
2026-08-31 15:55:21 -04:00
cproudlock
2feb569686 Rename Metrics to Monitors and move it second
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 43s
CI / backend (push) Successful in 4m17s
"Metrics" promised numbers. The tab holds tracked services, tracked processes
and network interfaces - things being WATCHED and their current state, only
one of which is a measurement. Monitors is what the panels actually are, and
it matches the word the agent's own config already uses for them.

Moved to second, ahead of Software. What is being watched on this box is the
question asked right after "what is it", and well before its application
inventory - which is reference data somebody goes looking for deliberately.

Tab order is now Details, Monitors, Software, Activity.

Contract 0.29.0 keeps its number. It is unreleased, so the group vocabulary it
introduces is still being settled and nothing can yet depend on the old name.
Both the server and the client fall back to 'details' for a group they do not
recognise, so a site that deploys these halves out of step gets a misplaced
card rather than a missing one.

Scope: product
2026-08-31 15:21:46 -04:00
cproudlock
c71183375f Fix four things found by looking at the tabbed page
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 43s
CI / backend (push) Successful in 4m18s
SOFTWARE OPENED COLLAPSED. The installed-software panel was collapsible and
started that way, because it used to be low-priority reference data near the
bottom of one long page. It is now the Software tab's whole reason to exist,
and somebody who clicked that tab has already said what they came for. Made
it neither collapsible nor collapsed.

TRACKED SERVICES VANISHED FROM METRICS. The group is decided by the SERVER, so
a new frontend against a server still serving the old vocabulary puts a panel
in a group the build does not render, and it is fetched, held, and shown
nowhere - with nothing logged. Moving services out of 'actions' is exactly that
shape. The client now falls back to 'details' for any group it does not render,
the same way the server does, so a version-skewed pair degrades to a misplaced
card instead of a missing one.

A TABLE PANEL WITH NOTHING IN IT HID ITSELF. panelVisible honoured `empty` for
list panels and ignored it for tables, for no reason anybody wrote down. A
declared empty message is an answer - "No installed software reported." - where
an absent card is a question. It matters more now that a panel can be the only
thing in a tab, because the tab disappears with it.

THE TAB STRIP HAD A VERTICAL SCROLLBAR. Per the overflow spec, when one axis is
not `visible` the other computes from `visible` to `auto`, so `overflow-x: auto`
alone made the strip scrollable vertically too and the -1px margin the tabs use
to sit on the border was enough to trip it.

Also moved .detail-full into the global stylesheet next to .content-grid, per
frontend/CLAUDE.md: it is detail-page layout, and the other four detail pages
need it when they adopt tabs.

Scope: product
2026-08-31 15:01:14 -04:00
cproudlock
f4b2ed7613 Drop the Actions tab, and give wide panels the width instead of a scrollbar
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m26s
Four changes from looking at the built page.

ACTIONS HELD ONE CARD. Tracked services was the only thing in it, and it was
the wrong split anyway: a service row reads as state first and a Start/Stop
second, so separating the buttons from the state they act on made a tab that
answered no question on its own. Services move to Metrics, beside the rest of
what the endpoint reports, and the group is gone from the contract.

WIDE PANELS SPAN, THEY DO NOT SCROLL. Network interfaces carries adapter,
address, MAC, speed and state; at half page width in the two-column flow it
either squeezed every cell to three lines or offered a horizontal scrollbar.
A scrollbar for data that just needs room is a layout failure. Table panels
now take column-span, which is the multi-column layout's own answer, so short
fact cards stay two-up around them. The wrapper keeps overflow-x only as a
floor for a genuinely narrow viewport.

SOFTWARE AND ACTIVITY RUN FULL WIDTH. Both are single long lists, and a list
split into two balanced columns reads as two unrelated lists with no way to
tell where the first ended. Details and Metrics keep the two-column flow
because they are short cards, which is what that flow is good at.

Contract 0.29.0 keeps its number: it is unreleased, and the group vocabulary
it introduces has simply lost one value before anybody could depend on it.

Scope: product
2026-08-31 14:41:11 -04:00
cproudlock
2122c3b45c Tab the PC detail page, and let a panel say which tab it belongs to
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m24s
The page had grown to roughly ten cards in one flow, and three more surfaces
were wanted on it: installed software, live metrics, and the service controls.
Stacking those into the same column would have buried what the page is for.

Tabs were mocked and rejected on 2026-08-28, on the grounds that the strip
duplicated the sidebar and cost hero space. That objection is answered by
where the strip sits: the hero and the triage band stay above it and never
move, so "is this machine OK" is still answered without a click, and the strip
partitions the detail underneath rather than navigating anywhere.

WHY A PANEL DECLARES ITS OWN GROUP

Most cards on a detail page are not the page's. They arrive through the
ADR-010 get_asset_panels hook, so a page that sorted panels into tabs by
knowing their ids would defeat the hook it is built on. Panels now carry an
optional group instead, and the page only knows the five group names.

Resolution happens server-side, in the asset-panels route, so that an absent
or misspelled group becomes 'details' before the client ever sees it. The
client renders known groups only; an unrecognised value that survived to it
would be a panel that exists and is displayed nowhere. Contract 0.28.0 ->
0.29.0, additive - every existing plugin keeps working untouched, and the
endpoints status panel is deliberately left ungrouped so the default path is
exercised by a shipped plugin and not only by tests.

A tab is offered only when something fills it. An empty tab reads as broken
rather than as "this site does not collect that", so the strip is built from
the groups that actually returned data, and a tab that disappears on refetch
falls back to Details rather than leaving the page blank.

AssetDetailTabs is core, not a PCDetail component: machines, printers,
network devices and measuring tools all have the same shape, and the shared
asset-detail shell they need starts here.

Scope: product
2026-08-31 14:24:16 -04:00
cproudlock
ae7b30a056 Stop naming remote-access schemes as if they were constants
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m25s
A link is built by formatting that protocol's own linktemplate, and the
templates are rows in accessprotocols that a site can edit. So no scheme
is a fixed fact about the product, and writing one down as though it were
misleads whoever reads it next.

Two places did. The guest-grant docstring said the detail route resolves
"working rdp:// and vnc:// links", which is wrong for the row we actually
ship: VNC is com.realvnc.vncviewer.connect://{host}:{port}, the RealVNC
handler, not vnc://. And the protocol admin form offered vnc://{host}:{port}
as its placeholder, in the one screen where somebody authors a new template,
so the example contradicted the seeded data it sits next to.

Neither changes behaviour. _computer_access_links already reads the
template from the database and the frontend only ever renders the resolved
link, so nothing in the code assumed a scheme - only the prose did.

Scope: product
2026-08-31 13:03:32 -04:00
cproudlock
df083776b3 Offer the search box to whoever can actually search
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m24s
The sidebar box was drawn for anyone signed in, on the reasoning that
offering it to a signed-out reader gives them a control that can only
answer 401. That stopped being the right test when the guest role became
able to hold assets.view: the box was hidden from exactly the visitor the
grant was reopened for, and a guest could reach search only by typing
/search?q= by hand.

The client cannot decide this. Its hasPermission getter reads the signed-in
user's permission list and a guest has no user at all, so asking it about a
guest answers no for everyone. get_navigation already had the problem and
already solved it by letting the server decide, so capabilities does the
same thing and reports what the CALLER holds.

It reports booleans about the caller and never what the guest role holds in
general, so it tells an anonymous reader nothing the rendered page does not.
Public because it is the pre-login call that decides whether the box is
drawn at all.

Members who lack assets.view now lose a box that only ever 401'd for them.
On a failed call the box stays hidden rather than falling back to the old
signed-in test, which would put it back in front of those same members.

Scope: product
2026-08-31 09:56:38 -04:00
cproudlock
723a4ff19b Declare the guest grants that already shipped
Inverting the grantable rule from the .view suffix to explicit declaration
dropped every permission that had qualified by how it was spelled. Two of
them were shipped guest features with grants already in the field.

guides.view: the nav item and list_guides both reason in comments about a
signed-out reader holding it, and the route is written around one - drafts
are filtered out, search matches the published revision so draft wording
cannot leak through a hit, and the author branches cannot open for a caller
with no user id.

tools.view: without it a guest holding tools.secrets has permission to use
Secure Send and no way to reach the page, because tools.view is what puts
the entry in the nav. The tools themselves run in the browser and touch no
data.

The failure was worse than a missing checkbox. update_role refuses the
WHOLE request when any submitted permission is undeclared, and the role
grid resubmits what the role already holds, so a site that granted either
of these under the old rule could no longer save the guest role at all -
including to tick assets.view, which is the grant the shop floor needs to
look up an asset number without signing in.

The test asserts both stay grantable, so narrowing the set again means
looking at what is already granted in the field rather than only at what
the catalogue declares.

Scope: product
2026-08-31 09:56:19 -04:00
cproudlock
d9eb7a74db Regenerate the project map after promotion
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m11s
2026-08-31 09:06:25 -04:00
cproudlock
4d0baaff77 Gate each search domain on its own permission
Global search is gated on assets.view, and that was the whole check.
Behind it the route queried twelve domains: applications, the knowledge
base, guides, the staff directory, assets, measuring tools, USB devices,
printed parts, custom fields, notifications, hostnames, and vendor and
model reference data, plus addresses and subnets for an IP-shaped query.
None of them consulted their own permission.

So anyone who could look up an asset number could also read the staff
directory out of the HR database, the USB checkout history and who holds
which drive, and the network inventory. The SEARCH_DOMAINS settings look
like they bound this but they are site feature toggles, not
authorization.

Each domain now answers only to a caller who holds its permission.
Assets, custom fields and vendor/model stay on assets.view because all
three emit asset rows routed to asset pages.

caller_holds rather than require_permission: refusing the whole request
because one domain is off-limits would make search useless to anyone
holding less than everything. A domain the caller cannot see contributes
nothing and the rest still answers.

This is what makes assets.view safe to grant the guest role again. It
left the grantable set in 3474028 because of the leak above, and that
took the shop floor's asset lookup with it - a signed-out visitor at a
machine could no longer search for the asset number in front of them.
Now the grant reaches assets and nothing else.

The first version of the leak test passed with the gates removed. The
staff directory is an external HR database and is unreachable under
test, so the domain silently returned nothing and the assertion proved
nothing. It patches _employee_rows now, and fails when the gate goes.

Scope: product
(cherry picked from commit 0a01ddcf25)
2026-08-31 09:06:25 -04:00
cproudlock
dd52666d6b Order the supplies card by how empty the cartridge is
Rows sorted on iscritical then on printer NAME. Critical is everything
at or below 5%, so a cartridge at 5% and one at 0% tied on the first key
and the alphabet decided which came first.

Wrong order on its own, and worse than it looks: the dashboard queue
keeps only the first few rows per source, so a printer that was
genuinely empty was truncated off the card while a fuller one held its
place. Reported from the floor as "starts at 5% and hides one at 0%",
which is exactly the two bugs compounding.

remaining is already normalised for waste cartridges - a full waste bin
reads as low remaining - so one ascending sort is worst-first for every
supply type, and iscritical falls out of it rather than needing its own
key. Name only breaks ties.

worstremaining is carried on the row because the supplies list is
rendered to text ("Black 0%") before it reaches the client, so the
number the sort needs is gone by then.

The toner forecast report was already worst-first (it sorts on daysleft)
and is unchanged.

Scope: product
(cherry picked from commit 6644a07ae1)
2026-08-31 09:06:25 -04:00
cproudlock
b3cbeca1bf Keep local scratch notes out of the publication tree
The export rsyncs the WORKING TREE, so .gitignore does not apply to it:
anything ignored-but-present reaches the publication repo unless it is
named as an exclude. devnotes/ is declared never-committed and holds
review writeups and chat handoffs, and it went straight through.

The scrub gate stopped it, which is what that gate is for, and nothing
was committed or bundled. But a gate failure is a stop, not a filter,
and the fix a hurried person reaches for is deleting their notes to get
a bundle out.

Same shape as the stray .env.bak that reached this tree once before, so
the rsync now carries a note saying why excludes live there rather than
in .gitignore.

Scope: product
(cherry picked from commit f1136d920d)
2026-08-31 09:06:25 -04:00
cproudlock
dbc43162c5 Pin the approved content digest on an endpoint command
Delivery descriptors for run_script and update_app are resolved when the
agent claims a command, not when an operator queues it, and the command
row recorded no digest: the enqueue path resolved the descriptor purely
to validate it and threw the result away.

So what an operator approved and what an endpoint runs were two separate
lookups against mutable content, minutes or hours apart. Anyone able to
replace a script body or an installer package could do so in the gap.
The claim then re-resolves, the descriptor faithfully describes the new
bytes, the agent verifies correctly, and it runs as LocalSystem. The
audit trail names the operator who queued the original.

The gate on the verb and the gate on the payload were also different
grants: pushing needs endpoints.deploy, but replacing what gets pushed
needs only applications.edit.

pinnedsha256 records the digest as it stood at enqueue, and the claim
refuses a descriptor that has moved, failing the push with an audit row
instead of shipping it. Null for actions that deliver no content, and
for rows queued before this migration, which claim as before. A
descriptor that vanished entirely is not treated as tampering: that path
already sends the command through undescribed so the agent fails closed.

Separately, a result may now only finalize a command that is actually
running. That needs no agent change and closes two things: reporting on
a command that was never claimed, and overwriting a recorded failure
with a success.

The agent now sends its hostname on the result callback so the server
can check it owns the command it is finalizing. The shared collector key
does not say who is calling, so without it any agent can post a result
for any command id. The server treats the parameter as optional for
agents that predate this and enforces it when present, so there is no
flag day. This is a floor, not a fix: only a per-endpoint credential
makes the caller's identity provable, and that is tracked separately.

The .NET side is unbuilt here and needs a build plus a win11 VM run.

Scope: product
(cherry picked from commit d64b7122ab)
2026-08-31 09:06:25 -04:00
cproudlock
ccd8221adf Key the client address on the connection, not a header the caller sends
client_ip() read the first X-Forwarded-For hop unconditionally. The
login rate limit and the account lockout are keyed on that value and the
audit log records it, so rotating the header per request bought an
unbounded budget and an untraceable trail.

This walked around TRUSTED_PROXY_COUNT, which exists to decide exactly
this. The app leaves ProxyFix off by default and says why, ten lines of
comment away: ProxyFix trusts X-Forwarded-*, a client can send those
itself, and the login limiter is keyed on the address. Then client_ip
read the header anyway, so the stated protection never applied.

Resolution is now from remote_addr, which ProxyFix has already filled
from the header wherever a proxy is genuinely configured. Both cases end
at the same trustworthy source, and the port still comes off because ARR
forwards clientip:port and ProxyFix passes it through verbatim.

geenforce and tools had each hand-rolled the same mistake for their own
limiters. Rather than duplicate security-relevant logic a third time,
client_ip and strip_port join the shopdb.api surface (contract 0.28.0).
geenforce's _trusted_client_ip, which already refused the raw header for
its IP allowlist, is untouched and was the model for this.

The stability table was also missing rows for 0.26.0 and 0.27.0.

INSTALL-WINDOWS-IIS.md never mentioned TRUSTED_PROXY_COUNT, so a site
installed from it runs behind IIS with the setting at 0: every request
looks like it came from IIS, the whole site shares one rate-limit
bucket, and the audit log records the proxy for every action. It now
says so, and says not to set it when nothing is in front.

Three test modules simulated separate callers by setting
X-Forwarded-For, which is the header a real attacker rotates to escape
the limit. One of them, test_client_ip_prefers_forwarded_first_hop_
stripped, asserted the defect outright. The suite could not have caught
this. They now drive REMOTE_ADDR, with a regression guard that forging
the header buys nothing.

Scope: product
(cherry picked from commit 7260244b7e)
2026-08-31 09:06:25 -04:00
cproudlock
d1a7907a2c End a user's sessions when the account is deactivated
There is no token denylist in this product, so the JWT user loader is
the only place a deactivated account can be stopped, and it did a bare
lookup by id. Somebody deactivated at 09:00 kept a working access token
until it expired, and a refresh token that minted fresh ones for thirty
days.

/api/auth/refresh and the API-token path already checked isactive. This
makes the loader consistent with them rather than inventing policy.

Scope: product
(cherry picked from commit 9c1848fc61)
2026-08-31 09:06:25 -04:00
cproudlock
85e8a7b877 Decide guest grants explicitly instead of by permission name
guest_grantable_permissions() opened any permission whose name ended
'.view' or '.read' to the guest role, on the reasoning that reading is
the safe default. That is true of a permission gating a list of asset
numbers and false of one gating bytes, and a suffix cannot tell them
apart. Twenty-four of eighty-five qualified and none had been decided:

  applications.view  reached the SYSTEM-context PowerShell bodies and
                     the installer binaries pushed to bays
  backups.view       returned the full payload that the separate
                     backups.download permission exists to protect
  assets.view        gated a global search that queries the staff
                     directory, USB history and network inventory with
                     no per-domain check of its own
  audit.view         usernames, addresses, the before/after of every
  settings.view      change, and every setting row - both admin
                     category, opened by naming alone
  users.view         gates nothing at all; list_users is
                     require_role('admin')

Every entry is now declared, by CORE_GUEST_GRANTABLE or by the plugin
that owns the permission, having read what its routes actually return.
Adding one is a code change, which is the point: it puts the decision
in a diff rather than in a suffix.

The two routes serving script bodies and installer packages move to a
new applications.download permission, which is not grantable, so a site
can still open the catalogue metadata to its floor without the bytes
going with it.

The declared set preserves the grants this site had already chosen:
applications.view, computers.view, endpoints.view, reports.view,
tools.secrets, warranty.view.

Two known gaps are recorded on the hooks that expose them rather than
silently carried: the computer serializer publishes every column plus
resolved rdp:// and vnc:// links, and the per-asset endpoint panels are
keyed on a small integer so walking ids reconstructs much of the roster
that endpoints.fleet exists to protect. Neither is widened here.

A shipped test was granting guests audit.view and passing.

Scope: product
(cherry picked from commit 347402896c)
2026-08-31 09:06:25 -04:00
cproudlock
5f27f81e88 Clear the review blockers on the merged wilm work
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m18s
A multi-agent review of the merged branch produced 29 findings that survived
adversarial verification. This clears the blockers and the four worth fixing
before it reaches main. The design held up well: path-traversal defence on all
four serving routes is clean, the agent's SHA-256 check fails closed, enqueue
authority is proportionate and audited, and no site literals or credential leaks
in the new C#.

THE TESTS NEVER RAN

pytest.ini sets testpaths = tests, so plugins/endpoints/tests/ was collected ZERO
times. Fifty-five lines of delivery assertions were dead code, and run directly
five of them failed. plugins/printedparts/tests/ had been invisible for longer.

That is the worst shape a gap can take: the suite is green, the coverage is
imaginary, and the collector auth on a download route could be deleted without
anything going red.

Both files moved under tests/. tests/test_no_hidden_tests.py now fails if a test
file appears anywhere pytest will not look; it caught a staged copy inside the
installer bundle on its first run, and was verified by planting a violation.

The five failures were stale expectations, and they split by cause. His: the
applications panel made six panels where his test said five, and 'applications'
belongs in KNOWN_PAYLOAD_KEYS now that it is persisted - his own test contradicted
his own feature. Ours: we renamed the events card to activity, moved the
fleet-wide cards to endpoints.fleet, and made the list route a bare route('')
which Flask rejects unless the blueprint is mounted under a prefix.

FOUR FIXES

The snapshot table's key was a 32-bit autoincrement on a table rewritten
wholesale every check-in, so it climbs at (endpoints x apps) per report and
exhausts on MySQL. Widened while the table is unreleased. Widening it broke every
insert on SQLite, which only autoincrements a column typed exactly INTEGER
PRIMARY KEY, so it carries a dialect variant - and nothing caught either fault,
because no test inserted into that table at all. One does now.

Ingest truncated displayname but not displayversion or publisher. On strict MySQL
an over-long registry value fails the whole check-in transaction, so one machine
with a malformed Uninstall entry goes silent entirely. SQLite does not enforce
lengths, so the suite would never have shown it.

update_app's version: branch pinned AppVersion.sha256, but that route resolves the
version to its APPLICATION and serves application.installpath - so the digest
described an artifact the route never sends, and where it was null the agent had
nothing to verify and failed open. It now pins the file actually served.

The same branch ended the delivery URL at the id, so an MSI arrived with no
extension and could not be routed through msiexec. It carries the filename now.

Agent packaging moved off require_role('admin') to a new endpoints.agentpackage
permission. The generated ZIP carries a live collector key, so this is a heavier
grant than deploying to one endpoint - and a role cannot be given to one person
for one job the way a permission can.

Full suite 2231 passed (2198 before, plus the 22 that never ran and 11 new),
frontend 314.
2026-08-30 17:17:13 -04:00
benjamin.baker
7dc1f0bee8 Bump plugin contract to 0.27.0 for the Script surface addition
Commit 0c2b816 added Script to shopdb.api so a plugin can read the ShopDB-hosted script library that backs run_script delivery, but left __contract_version__ at 0.26.0. This bumps it to 0.27.0 so a plugin can pin core_version against the widened surface. Additive name, minor bump, matching the existing convention. The docs/PLUGIN-HOOKS wiki page (not tracked in-repo) still needs the Script entry.
2026-08-30 15:21:10 -04:00
benjamin.baker
7c09f1e4ca Add a standalone endpoint agent uninstaller
Reverses everything the installer and site-agent generator create: stops and deletes the service, kills agent/tray processes, removes the tray Run key and the HKLM\SOFTWARE\GE\ShopDB credential hive in both registry views, drops the Defender path exclusion, and deletes the install directory and ProgramData tree. Every step is best-effort and idempotent so it is safe to re-run and safe on a partially installed host.
2026-08-30 15:21:10 -04:00
benjamin.baker
4f01c3d2d4 Serve the dev/test stack over HTTPS with a mounted self-signed cert
Terminate TLS in gunicorn using a host-generated cert mounted read-only under ./certs, so the browser sees a secure context. Secure-context-only web APIs (navigator.clipboard and others) refuse to run over plain http on a non-localhost origin, which the FQDN-accessed test box hits. Healthcheck now probes https with -k to match the TLS listener. A site swaps in its own cert or fronts this with its own IIS/nginx proxy.
2026-08-30 15:21:10 -04:00
benjamin.baker
84f9cfe13d Agent: execute run_script and update_app delivery pushes
Bumps the agent to 1.4.1. run_script fetches a ShopDB-hosted script by digest and runs it in the script's configured context (LocalSystem or the interactive logged-on user), replacing the local scriptpaths allowlist; user-context runs wait for exit and report the real exit code instead of fire-and-forget. update_app resolves an app-level or version-level install source, fetches the pinned bytes, verifies sha256, and runs silently. Both stage into a fixed Defender-excludable folder (C:\ProgramData\ShopDB\EndpointAgent) instead of the LocalSystem temp path. Adds the DeliveryClaim contract for the claim payload.
2026-08-30 15:21:10 -04:00
benjamin.baker
4590156f30 Add endpoint software delivery: run_script and update_app push
Adds the two delivery actions to the endpoints command allowlist, each gated on a new endpoints.deploy permission ON TOP OF endpoints.command so pushing software is a heavier grant than a routine toggle. Enqueue validates update_app appversionid ownership and installer presence before queueing. New collector-authenticated download routes let the agent fetch installer packages and script bodies by digest rather than pre-staging them or sending bodies over the command wire. Wires the scriptsApi client, the siteagent generator routes, and a temporary dev-only Delivery test bench (view/route/nav) for exercising the path while the production UI is built.
2026-08-30 15:21:10 -04:00
benjamin.baker
7ac1772881 Add installer/script delivery metadata to the core software model
AppVersion gains sha256 and silentargs so the endpoint update_app push can pin exact installer bytes and carry per-version silent switches; Application gains installersha256 for the app-level install source. New ShopDB-hosted script library (Script model plus scripts API) backs run_script delivery, with the body fetched by digest on push rather than pre-staged or sent over the command wire. Two idempotent migrations with real downgrades. Script added to the shopdb.api contract surface for plugin use.
2026-08-30 15:19:35 -04:00
benjamin.baker
5f8061c55d Self-heal endpoint command queue wedged by a dead in-flight command
The command queue is at-most-one-in-flight, but nothing ever expired a stuck command. When an agent claimed a command (RUNNING) then died before reporting a result - killed, uninstalled, or offline mid-flight - the row stayed in-flight forever and every operator action for that endpoint returned 409 'already in flight'. Reap in-flight rows past a staleness ceiling (15m for RUNNING, 60m for PENDING) to a new STATUS_EXPIRED at enqueue time, inside the same transaction, so the queue self-heals lazily the next time someone queues work. No background job and no schema migration - status is free-text.
2026-08-30 15:19:35 -04:00
benjamin.baker
50987c96ad Harden endpoint agent command channel against request storms
A misconfigured BaseUrl (plaintext http:// against the TLS port) made the command long-poll fail at connect in milliseconds. The worker misread that fast failure as a normal idle hold-window close and re-polled with no delay, spinning into a request storm. Gate the idle-close path on the poll having stayed open at least MinIdlePollDuration, so a sub-second transport failure takes the transient backoff instead; add a MinPollInterval floor so no path can exceed a safe request rate. Also reject a non-loopback http:// BaseUrl at startup with a single clear error, turning a silent runtime flood into a fast fail. Loopback http stays valid for local dev.
2026-08-30 15:19:35 -04:00
benjamin.baker
aa702fb81e Ignore local dev scratch and delivery build output
Several days of endpoint-agent work left DB dumps, sample datasets, captured command output, generated maps, vuln scan output, merge-kit exports, and staged agent publish trees in the tree. None are product source. Ignore them so status shows only real work and they cannot be committed by accident. exports/ stays on disk for reference. agent/.gitignore also reserves dev-bin/ and _stage/ for delivery-build scratch.
2026-08-30 15:19:35 -04:00
benjamin.baker
1fa005eb8a Green up
Tracking docs/openapi.json (so the app can serve the spec at runtime) makes docs/ exist on the published GitHub mirror, which defeated the skipif(not DOCS.is_dir()) guard in the docs publish/version tests: they then ran against a prose-less checkout and failed on CI. Key the skip off docs/*.md prose instead, matching the canonical published-checkout definition in scripts/check-gates-ran.sh.

Also: the OpenAPI generator emitted a trailing-slash path (/api/docs/), which a strict proxy like IIS treats as a redirecting resource a subpath-mounted client cannot follow; normalise trailing slashes in _norm_path and correct the committed spec. And allow docx (python-docx) as an undeclared import: it is used only by scripts/docs/generate_docx.py, a developer tool that never runs on a deployed site.
2026-08-30 15:19:35 -04:00
benjamin.baker
902339baf9 Persist and surface endpoint installed-software (SBOM)
The endpoint agent already reports an installed-software list (SBOM), but ingress dropped it: the collector schema had no entry for it and there was nowhere to store it, so operators could not see what is installed on a PC.

Persist it as a per-asset snapshot in a new endpointapplications table, replaced on every report (current inventory is what operators ask for, not install history). Add applications to the collector schema and KNOWN_PAYLOAD_KEYS so ingress stops discarding it, a read route for the raw collection, and a collapsed-by-default Installed software panel to surface it. appid is nullable groundwork for a future Managed Applications link; no matcher is wired yet.
2026-08-30 15:19:35 -04:00
benjamin.baker
7040832565 Add agentless adoption tool for endpoints
Standalone one-shot enrollment tool that creates a ShopDB PC + Endpoint entry using only the endpoints collector key, reusing the existing collector adopt path. Collects identity, WMI hardware, and installed-software SBOM; requires admin. Server-side packaging route attaches to the existing endpoints blueprint; no core contract change.
2026-08-30 15:19:35 -04:00
benjamin.baker
cfb0b9ca4e collect installed-software SBOM from endpoint agent
The agent now reports installed applications from both Uninstall registry hives (64-bit and WOW6432Node) as {displayname, displayversion, publisher}, mirroring the Assess-Orchestrator applications collection. Registry-only, no Win32_Product/WMIC self-repair and no AppX. The server logs it as an unpersisted key until the SBOM store is built.
2026-08-30 15:19:35 -04:00
benjamin.baker
57845e1a9a log ignored agent collector keys for later UI enhancements
The endpoint agent now sends disk, bios, memory, processor, osinfo, and domain telemetry. The server does not persist these yet, so ingest logs any unpersisted payload keys with a compact preview. This makes incoming telemetry observable in the logs before any UI or schema work, so we can confirm the agent is sending it and enhance the UI later.
2026-08-30 15:19:35 -04:00
cproudlock
2a4b1bb040 Merge main into wj before taking wilm's work
wj held nothing main lacked - git cherry marks every one of its commits as
already present - but promotion cherry-picks, so the histories diverged and a
fast-forward is not possible. Merging rather than resetting keeps the branch
recoverable and needs no force-push.

Levelling first so wilm's patches apply against current product code.

# Conflicts:
#	CONTRIBUTING.md
#	frontend/src/views/Dashboard.vue
#	tools/wj-status.sh
2026-08-30 15:19:02 -04:00
cproudlock
511c3ffcee Show every status the asset total contains, and what it excludes
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m5s
Asked twice what "All assets" covered, which is the signal a label is
underspecified rather than a reader being slow.

The endpoint already counted every status to build its rollup and then returned
two of them, so a reader could see In Use and had no way to learn where the rest
of the total went. It now returns them all, and the panel lists them under the
total - indented and quieter, because they break the total down rather than
adding to it.

Three things this makes visible that were not guessable:

Retired assets are IN the total. Retired is a status on an active record, not
the same as deactivated, and seven of them sit inside this site's 610.

There is an "Inventory" status. I would have hardcoded In Use, In Repair and
Retired and silently dropped it. The rows are built from whatever the API
returns, sorted by count, so a status a site adds appears rather than
disappearing - which matters for sites adopting with their own seed data.

Rows held back by isactive are in NO status bucket, so they cannot be found by
adding the buckets up. That gap is exactly what makes a dashboard total
disagree with a list page, so the count is reported and captioned.

The caption states the counting policy - active records only, dual-bay pairs
counted once - and the dual-path clause renders only when that site toggle is
on, since asserting it unconditionally would be false at a site that has it off.

Full suite 2182, frontend 314.
2026-08-30 15:18:13 -04:00
cproudlock
4c8291628f Call the inventory panel Assets, not Estate
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 4m11s
The panel had no group label before it was one panel, so grouping it needed a
heading and it got 'Estate' - RMM idiom, and the word the leadership documents
already used for the whole of what a site owns.

Wrong register for this surface. The sidebar already groups exactly these things
under a section named assets, so the dashboard had invented a second word for a
group the app already names, and the panel's own total row says 'All assets' -
the heading and the total disagreed inside one panel. 'Estate' is also
enterprise-IT and largely British idiom; it earns its place in an executive
summary and not on a screen read at 6am.

The scoped classes follow the label, so the markup does not keep a word the UI
stopped using. The documents keep 'estate', where it is doing real work for a
leadership reader.

Scope: product
2026-08-30 12:57:05 -04:00
cproudlock
58b53b50f6 Refuse --dist anywhere but main
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m13s
The bundle became one file per branch, but the frontend dists did not: they are
still frontend-dist-subpath-{ops,shopdb}, fixed paths, and update-prod-server.cmd
robocopies the shopdb one onto the production server with /MIR - a full mirror -
with no idea which branch built it.

So --dist from a site branch would have replaced production's frontend with
unreleased work at the next deploy, silently, and nothing downstream could have
told. Worse than the bundle collision it was sitting beside, because the dist is
what actually lands on the server.

Branch-qualifying the directories would only move the trap to whoever pointed a
deploy at the wrong one. Production runs the product, so the export now refuses
and says which commits to promote instead.

Scope: product
2026-08-30 12:53:14 -04:00
cproudlock
faace01f2d Do not offer to promote work that already landed
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m4s
Promotion cherry-picks, which gives the commit a new sha on main. wj-status
compared by sha via main..wj, so after promoting ten commits it still listed all
ten as unpromoted - and would have invited promoting the same work twice.

git cherry compares patch content instead and marks an already-present commit
with a leading '-'. When nothing is genuinely outstanding it now says so, counts
what has already landed, and shows how far the branch is BEHIND main with the
rebase to run. Behind was the state that mattered here and the tool could not
express it at all.

Scope: product
2026-08-30 12:48:56 -04:00
cproudlock
1c88acad3c Correct the branch list: wilm exists and is active
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 4m2s
Recorded that wilm no longer existed, on the evidence of the local gitea clone
where it was merged and its ref deleted. That was wrong. It lives on the
corporate GitHub and the other site is developing the .NET endpoint agent on it.

The dev box cannot see that branch - no account there - so its absence locally
was never evidence of anything, and fetch-wilm-branch.cmd on the share exists
precisely to bring it back for review. Says so now, so the next reader does not
draw the same conclusion from the same missing ref.

Scope: product
2026-08-30 11:21:57 -04:00
cproudlock
7a8acbbcb5 Regenerate the project map after promotion
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Has been cancelled
2026-08-30 11:12:09 -04:00
cproudlock
ef962a1bf9 Print the first-time path for a new branch on the work PC
The work-PC instructions began with 'git checkout <branch>', which fails the
first time a branch goes over because it does not exist in that clone yet -
exactly the moment somebody follows them most carefully. Nothing to check out,
and nothing to merge into either.

Now prints both: fetch then checkout -b then push -u the first time, and the
checkout/fetch/merge/push cycle afterwards.

Scope: product
(cherry picked from commit 1fe788a3ed)
2026-08-30 11:12:09 -04:00
cproudlock
03804bb996 Stop the promote script from deleting itself mid-run
promote-to-main.sh checks out main. The tooling lives only on the site branch,
so that checkout removes tools/promote-to-main.sh while the script is still
running - and bash reads a script lazily, by file offset, so it would carry on
executing whatever bytes happen to land there. While manipulating branches.

Not theoretical: main does not have tools/ right now, so the first real use
would have hit it.

Both versions now copy themselves to a temp file and re-exec from there. The
bash copy unlinks itself immediately, since the open handle keeps working. The
Windows copy has the same hazard for the same reason - cmd.exe re-seeks the
batch file after every command - and two extra traps worth naming: the copy runs
from %TEMP%, so the repository root has to travel with it rather than being
derived from %~dp0, and %errorlevel% inside a parenthesised block is expanded
when the block is PARSED, before the call it is meant to report on.

Scope: product
(cherry picked from commit 6d42110a91)
2026-08-30 11:12:09 -04:00
cproudlock
cd86c27969 Show a guest what they can see, not a blank page
A guest got a heading and nothing else.

THE STRUCTURAL FAULT WAS MINE

Moving the Estate panel into DashboardCards' rail slot put it behind that
component's root v-if, which was `liveCards.length > 0`. A guest is permitted
zero cards, so the whole component disappeared and took the slot with it. Before
that move the tiles were top-level and always rendered. The wrapper no longer
vanishes with the cards.

TWO LIES THE EMPTY STATE WAS TELLING

"Nothing needs attention" is now shown only to somebody who can see the checks.
A reader permitted none of them is not looking at a healthy fleet, they are
looking at nothing, and saying otherwise is the same fault as reporting Low
Supplies 0 while the monitoring is down. The count of PERMITTED cards is now
tracked separately from the count that currently hold rows.

The Estate panel no longer renders zeros for a caller who cannot read totals.
/api/dashboard/summary needs assets.view, and the failure was swallowed into an
empty object that drew a tidy column of zeros - an estate of nothing, stated
with confidence.

WHAT A GUEST GETS INSTEAD

Where they can go: the same permission-filtered navigation the sidebar uses,
which the server already filters to the guest role for a signed-out visitor. So
it cannot offer a link that dead-ends at the router guard, and this page names no
plugin it had to know about. Verified against the running instance - a guest is
offered Guides and Tech Tools, matching guides.view and tools.view.

ALSO: the measuring tools row

totalassets counts five types - machines, PCs, network, printers AND measuring
tools - but only four had rows, so the total stopped matching the addends above
it wherever gages exist: 610 of visible rows under a stated 729. That is exactly
the fault this panel was built to remove, and it looked right here only because
this database has no measuring tools. Every counted type now has a row.

Frontend 314 passed.

(cherry picked from commit 0f2da9beaf)
2026-08-30 11:12:09 -04:00
cproudlock
e65053feeb One bundle per branch, named after it
The export wrote a single shopdb-flask-pub.bundle whatever it published. That
was fine while only main was ever published, and became a trap the moment it was
not: exporting a site branch silently overwrote the bundle holding main's work,
and the work PC's documented "git fetch <bundle> main" then failed because that
ref was no longer in the file.

It had already happened here - two wj exports overwrote a main bundle from the
same morning. The failure is at least loud on the work PC rather than pushing
the wrong branch, but the main bundle has to be regenerated.

Now shopdb-flask-pub-<branch>.bundle. Two branches cannot share one filename.

Scope: product
(cherry picked from commit 1311dea113)
2026-08-30 11:12:09 -04:00
cproudlock
442db6a664 Mirror the publication branch BEFORE the sync, not after
The branch checkout landed after the rsync, where git checkout --force reverts
the tree to the branch's committed state and discards everything rsync had just
written. The export then found no changes and published nothing, while still
printing a bundle path and looking like it had worked. A silent no-op is the
worst shape for this: the next person believes the export ran.

Scope: product
(cherry picked from commit 06c584240e)
2026-08-30 11:12:09 -04:00
cproudlock
d8a2bfeb29 Publish the branch you are on, not always main
export-github.sh syncs the WORKING TREE, not a branch, and then committed
whatever it found onto the publication repo's main and printed
"git push origin main".

So running it while checked out on a site branch published that site's work AS
THE PRODUCT, to the repository every other site consumes, and said nothing. The
branch model made that reachable in one wrong command, from a checkout that
looks completely normal.

It now reads the current branch, mirrors it in the publication repo (creating it
from main the first time, as in the dev repo), bundles that branch, and prints
work-PC instructions naming it. Publishing anything other than main also prints
a reminder that other sites read main and these commits are not there yet.

The publication checkout uses --force, which is safe only because that tree is a
mirror: the rsync has already replaced it, so there is nothing local to lose. It
would not be safe anywhere else.

Also cleans up the publication repo: origin pointed at a bundle in a scratchpad
from a session that ended long ago, and two orphaned remote-tracking refs
(github/main, wilm/review-fixes) survived the removal of their remotes. None of
it broke the export, which never pushes, but a `git fetch` in there failed with
a confusing path.

Scope: product
(cherry picked from commit 81df9607e7)
2026-08-30 11:12:09 -04:00
cproudlock
1cc725d4a7 Write down which branch work belongs on
The convention existed only in the scripts' comments and in one conversation,
which is not where somebody joining looks. CONTRIBUTING is.

Records the two branches, the Scope trailer, the promote flow, and the point
that most work is product: ADR-015 puts site differences in settings, seed data
or scripts/site_imports/<site>/, so a site branch is about timing rather than
locality.

Also names the failure worth watching. Promoting something site-specific by
accident is caught by the ADR-015 gate on the way into main. The real risk is
product work stranded on a site branch because nobody decided, which is what
turned wilm from a merge into a port.

Scope: product
(cherry picked from commit 1d4ac11572)
2026-08-30 11:12:09 -04:00
cproudlock
e838aaf737 Say which wj commits belong on main
The scripts could move a commit to main but nothing said which commits should
go, and that decision does not survive being deferred: months later it is a list
of subject lines and a guess.

So it is recorded at commit time, as a trailer:

    Scope: product     the rest of the world should have it
    Scope: site        this site alone, keep it here

tools/wj-status.sh reads them and prints main..wj in three groups, with the
promote command for the ones marked product.

The path fallback is deliberately weak. Under ADR-015 the only site-specific
home in the tree is scripts/site_imports/<site>/ - everything else is meant to
be product, with settings and seed data carrying what differs between sites. So
"touches only site_imports" is the one confident site verdict paths can give,
and everything else is reported UNMARKED rather than guessed into a bucket. A
tool that quietly sorted commits by directory would be wrong often enough to
stop being read.

The unmarked message says which way to lean, because the failure mode here is
not promoting something site-specific by accident - the ADR-015 gate catches
that on the way in. It is product work sitting on wj forever because nobody
decided, which is how the wilm branch became a port rather than a merge.

Scope: product
(cherry picked from commit 0400a58786)
2026-08-30 11:12:09 -04:00
cproudlock
28aee355f9 Add Windows versions of the push and promote scripts
Same two operations as the bash pair, for driving git from Windows rather than
from the development box.

CREDENTIALS ARE HANDLED DIFFERENTLY, ON PURPOSE

The bash version reads a token out of secrets.md and feeds it to git through
GIT_ASKPASS, because that box has no credential store. Windows has one: Git for
Windows installs Git Credential Manager and enables it by default, so `git push`
prompts once and Windows Credential Manager keeps it.

So the token handling is deliberately NOT ported. Copying a token into a script,
an environment variable or a remote URL on a machine that already has a
credential store is strictly worse than the thing it would replace, and the
comment in the file says so, because "make it match the other script" is exactly
the tidying somebody would otherwise do later.

THE PROMOTE SCRIPT REFUSES TO RUN WITHOUT BASH

scripts/check-naming-and-style.sh is a shell script, and it carries the ADR-015
site-literal check - the one gate that makes a site branch survivable rather
than a slow leak into the product. Skipping it on a machine without bash would
defeat the entire point of the script, so it stops and says how to fix it
instead of promoting without it.

CRLF

.gitattributes already pinned *.sh to LF so Git Bash can run them; *.cmd is now
pinned to CRLF for the same class of reason in the other direction. cmd.exe
parses a lone LF inconsistently around labels and goto, so an LF-only batch file
can run its happy path and then jump nowhere on the error path - which in a
promote script is the path that matters.

(cherry picked from commit 49a898fc5a)
2026-08-30 11:12:09 -04:00
cproudlock
334bc5cd04 Add push and promote scripts for the wj branch
wj holds this site's work; main is the product every other site sees and the
publication mirror exports. Two scripts, one for each direction.

tools/git-push.sh pushes a branch without leaving the token behind. The way this
was being done by hand - rewrite remote.origin.url to embed the token, push,
rewrite it back - writes the token into .git/config in plaintext on every push,
and leaves it there if the push fails or the terminal closes mid-way, where
`git remote -v` will happily print it. Putting it in the URL on the command line
is no better: argv is visible in `ps` to every user on the box. The token now
goes in through GIT_ASKPASS, so it never reaches the config or the process list
and there is nothing to clean up when the script dies.

tools/promote-to-main.sh cherry-picks NAMED commits onto main, rather than
merging the branch. Merging takes the site-specific work along with the product
work, which is how a site branch quietly becomes the product; naming the commits
keeps that decision explicit and per change.

It runs main's gates after the pick and before any push, and does not push at
all - it prints what landed and leaves that to a human. The ADR-015 site-literal
check is the one that earns its place: it catches a hostname or share path
riding along in a commit that looked generic, which is the failure that makes a
site branch dangerous rather than merely awkward. It caught this very commit,
which named the site in a comment.

Both refuse a dirty tree. Pushing one pushes the last commit rather than what is
in front of you, and cherry-picking from one mixes uncommitted work into the
promotion. On failure the promote script aborts the pick and returns you to the
branch you started on, with main untouched.

(cherry picked from commit 6d9954c6f9)
2026-08-30 11:12:09 -04:00
cproudlock
1fe788a3ed Print the first-time path for a new branch on the work PC
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m7s
The work-PC instructions began with 'git checkout <branch>', which fails the
first time a branch goes over because it does not exist in that clone yet -
exactly the moment somebody follows them most carefully. Nothing to check out,
and nothing to merge into either.

Now prints both: fetch then checkout -b then push -u the first time, and the
checkout/fetch/merge/push cycle afterwards.

Scope: product
2026-08-30 09:57:53 -04:00
cproudlock
6d42110a91 Stop the promote script from deleting itself mid-run
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m15s
promote-to-main.sh checks out main. The tooling lives only on the site branch,
so that checkout removes tools/promote-to-main.sh while the script is still
running - and bash reads a script lazily, by file offset, so it would carry on
executing whatever bytes happen to land there. While manipulating branches.

Not theoretical: main does not have tools/ right now, so the first real use
would have hit it.

Both versions now copy themselves to a temp file and re-exec from there. The
bash copy unlinks itself immediately, since the open handle keeps working. The
Windows copy has the same hazard for the same reason - cmd.exe re-seeks the
batch file after every command - and two extra traps worth naming: the copy runs
from %TEMP%, so the repository root has to travel with it rather than being
derived from %~dp0, and %errorlevel% inside a parenthesised block is expanded
when the block is PARSED, before the call it is meant to report on.

Scope: product
2026-08-30 09:53:04 -04:00
cproudlock
0f2da9beaf Show a guest what they can see, not a blank page
A guest got a heading and nothing else.

THE STRUCTURAL FAULT WAS MINE

Moving the Estate panel into DashboardCards' rail slot put it behind that
component's root v-if, which was `liveCards.length > 0`. A guest is permitted
zero cards, so the whole component disappeared and took the slot with it. Before
that move the tiles were top-level and always rendered. The wrapper no longer
vanishes with the cards.

TWO LIES THE EMPTY STATE WAS TELLING

"Nothing needs attention" is now shown only to somebody who can see the checks.
A reader permitted none of them is not looking at a healthy fleet, they are
looking at nothing, and saying otherwise is the same fault as reporting Low
Supplies 0 while the monitoring is down. The count of PERMITTED cards is now
tracked separately from the count that currently hold rows.

The Estate panel no longer renders zeros for a caller who cannot read totals.
/api/dashboard/summary needs assets.view, and the failure was swallowed into an
empty object that drew a tidy column of zeros - an estate of nothing, stated
with confidence.

WHAT A GUEST GETS INSTEAD

Where they can go: the same permission-filtered navigation the sidebar uses,
which the server already filters to the guest role for a signed-out visitor. So
it cannot offer a link that dead-ends at the router guard, and this page names no
plugin it had to know about. Verified against the running instance - a guest is
offered Guides and Tech Tools, matching guides.view and tools.view.

ALSO: the measuring tools row

totalassets counts five types - machines, PCs, network, printers AND measuring
tools - but only four had rows, so the total stopped matching the addends above
it wherever gages exist: 610 of visible rows under a stated 729. That is exactly
the fault this panel was built to remove, and it looked right here only because
this database has no measuring tools. Every counted type now has a row.

Frontend 314 passed.
2026-08-30 09:52:29 -04:00
cproudlock
1311dea113 One bundle per branch, named after it
The export wrote a single shopdb-flask-pub.bundle whatever it published. That
was fine while only main was ever published, and became a trap the moment it was
not: exporting a site branch silently overwrote the bundle holding main's work,
and the work PC's documented "git fetch <bundle> main" then failed because that
ref was no longer in the file.

It had already happened here - two wj exports overwrote a main bundle from the
same morning. The failure is at least loud on the work PC rather than pushing
the wrong branch, but the main bundle has to be regenerated.

Now shopdb-flask-pub-<branch>.bundle. Two branches cannot share one filename.

Scope: product
2026-08-30 09:06:56 -04:00
cproudlock
06c584240e Mirror the publication branch BEFORE the sync, not after
The branch checkout landed after the rsync, where git checkout --force reverts
the tree to the branch's committed state and discards everything rsync had just
written. The export then found no changes and published nothing, while still
printing a bundle path and looking like it had worked. A silent no-op is the
worst shape for this: the next person believes the export ran.

Scope: product
2026-08-30 09:04:56 -04:00
cproudlock
81df9607e7 Publish the branch you are on, not always main
export-github.sh syncs the WORKING TREE, not a branch, and then committed
whatever it found onto the publication repo's main and printed
"git push origin main".

So running it while checked out on a site branch published that site's work AS
THE PRODUCT, to the repository every other site consumes, and said nothing. The
branch model made that reachable in one wrong command, from a checkout that
looks completely normal.

It now reads the current branch, mirrors it in the publication repo (creating it
from main the first time, as in the dev repo), bundles that branch, and prints
work-PC instructions naming it. Publishing anything other than main also prints
a reminder that other sites read main and these commits are not there yet.

The publication checkout uses --force, which is safe only because that tree is a
mirror: the rsync has already replaced it, so there is nothing local to lose. It
would not be safe anywhere else.

Also cleans up the publication repo: origin pointed at a bundle in a scratchpad
from a session that ended long ago, and two orphaned remote-tracking refs
(github/main, wilm/review-fixes) survived the removal of their remotes. None of
it broke the export, which never pushes, but a `git fetch` in there failed with
a confusing path.

Scope: product
2026-08-30 09:03:25 -04:00
cproudlock
1d4ac11572 Write down which branch work belongs on
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m9s
The convention existed only in the scripts' comments and in one conversation,
which is not where somebody joining looks. CONTRIBUTING is.

Records the two branches, the Scope trailer, the promote flow, and the point
that most work is product: ADR-015 puts site differences in settings, seed data
or scripts/site_imports/<site>/, so a site branch is about timing rather than
locality.

Also names the failure worth watching. Promoting something site-specific by
accident is caught by the ADR-015 gate on the way into main. The real risk is
product work stranded on a site branch because nobody decided, which is what
turned wilm from a merge into a port.

Scope: product
2026-08-30 09:00:09 -04:00
cproudlock
0400a58786 Say which wj commits belong on main
The scripts could move a commit to main but nothing said which commits should
go, and that decision does not survive being deferred: months later it is a list
of subject lines and a guess.

So it is recorded at commit time, as a trailer:

    Scope: product     the rest of the world should have it
    Scope: site        this site alone, keep it here

tools/wj-status.sh reads them and prints main..wj in three groups, with the
promote command for the ones marked product.

The path fallback is deliberately weak. Under ADR-015 the only site-specific
home in the tree is scripts/site_imports/<site>/ - everything else is meant to
be product, with settings and seed data carrying what differs between sites. So
"touches only site_imports" is the one confident site verdict paths can give,
and everything else is reported UNMARKED rather than guessed into a bucket. A
tool that quietly sorted commits by directory would be wrong often enough to
stop being read.

The unmarked message says which way to lean, because the failure mode here is
not promoting something site-specific by accident - the ADR-015 gate catches
that on the way in. It is product work sitting on wj forever because nobody
decided, which is how the wilm branch became a port rather than a merge.

Scope: product
2026-08-30 08:50:32 -04:00
cproudlock
49a898fc5a Add Windows versions of the push and promote scripts
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 18s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m15s
Same two operations as the bash pair, for driving git from Windows rather than
from the development box.

CREDENTIALS ARE HANDLED DIFFERENTLY, ON PURPOSE

The bash version reads a token out of secrets.md and feeds it to git through
GIT_ASKPASS, because that box has no credential store. Windows has one: Git for
Windows installs Git Credential Manager and enables it by default, so `git push`
prompts once and Windows Credential Manager keeps it.

So the token handling is deliberately NOT ported. Copying a token into a script,
an environment variable or a remote URL on a machine that already has a
credential store is strictly worse than the thing it would replace, and the
comment in the file says so, because "make it match the other script" is exactly
the tidying somebody would otherwise do later.

THE PROMOTE SCRIPT REFUSES TO RUN WITHOUT BASH

scripts/check-naming-and-style.sh is a shell script, and it carries the ADR-015
site-literal check - the one gate that makes a site branch survivable rather
than a slow leak into the product. Skipping it on a machine without bash would
defeat the entire point of the script, so it stops and says how to fix it
instead of promoting without it.

CRLF

.gitattributes already pinned *.sh to LF so Git Bash can run them; *.cmd is now
pinned to CRLF for the same class of reason in the other direction. cmd.exe
parses a lone LF inconsistently around labels and goto, so an LF-only batch file
can run its happy path and then jump nowhere on the error path - which in a
promote script is the path that matters.
2026-08-30 08:48:27 -04:00
cproudlock
6d9954c6f9 Add push and promote scripts for the wj branch
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Has been cancelled
wj holds this site's work; main is the product every other site sees and the
publication mirror exports. Two scripts, one for each direction.

tools/git-push.sh pushes a branch without leaving the token behind. The way this
was being done by hand - rewrite remote.origin.url to embed the token, push,
rewrite it back - writes the token into .git/config in plaintext on every push,
and leaves it there if the push fails or the terminal closes mid-way, where
`git remote -v` will happily print it. Putting it in the URL on the command line
is no better: argv is visible in `ps` to every user on the box. The token now
goes in through GIT_ASKPASS, so it never reaches the config or the process list
and there is nothing to clean up when the script dies.

tools/promote-to-main.sh cherry-picks NAMED commits onto main, rather than
merging the branch. Merging takes the site-specific work along with the product
work, which is how a site branch quietly becomes the product; naming the commits
keeps that decision explicit and per change.

It runs main's gates after the pick and before any push, and does not push at
all - it prints what landed and leaves that to a human. The ADR-015 site-literal
check is the one that earns its place: it catches a hostname or share path
riding along in a commit that looked generic, which is the failure that makes a
site branch dangerous rather than merely awkward. It caught this very commit,
which named the site in a comment.

Both refuse a dirty tree. Pushing one pushes the last commit rather than what is
in front of you, and cherry-picking from one mixes uncommitted work into the
promotion. On failure the promote script aborts the pick and returns you to the
branch you started on, with main untouched.
2026-08-30 08:46:14 -04:00
cproudlock
b5fc5c01d1 Stay quiet when a site never asked for supply monitoring
Some checks failed
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 14s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Has been cancelled
Splitting "cannot see the cartridges" from "nothing is low" was right, but it
treated two unlike cases as one. isconfigured means enabled AND a URL AND a
token, so its absence means the site did not ask for supply monitoring at all -
and a permanent "Supply monitoring is not configured" line is a card reporting
nothing wrong every day, which is how a board teaches people to stop reading it.
It matters for sites adopting ShopDB without Zabbix, who would otherwise inherit
a warning about a feature they declined.

Not configured now returns a plain empty and the card hides. Only configured and
unanswerable reports itself, because that is something meant to work that is not
working.

Also withdraws an unfounded inference. A dev box has no route to Zabbix by
design, so "not reachable" there is the expected state and says nothing about
what production can see. The value of this change is not that it revealed a
production fault; it is that when production's Zabbix does go down, the board
will say so instead of displaying "Low Supplies 0".

Four tests pin the three cases apart: unreachable reports, unconfigured stays
quiet, and a real zero from a reachable Zabbix is not flagged as blindness. The
fourth holds the envelope's rows, since carrying a reason changed the response
shape. Verified they are not vacuous by restoring the original branch in place -
the unreachable test fails against it.

Full suite 2182 passed.
2026-08-29 21:34:48 -04:00
cproudlock
b80bd8731f Filter the queue by check, and say when a check cannot answer
TWO CONTROLS, ONE BOARD

Severity answers "how bad". It could not answer "what kind", and on a fleet
where eight endpoints go quiet at once, being able to set those aside and look
at the four crashes is the difference between a board and a list.

Chips per contributing check, coloured by its severity, composing with the
severity rollup above them. Both narrow what is already loaded rather than
refetching, so no combination can disagree with the counts. Picking a severity
that excludes the active chip clears the chip: two controls lit above an empty
queue, neither obviously wrong, is worse than losing the narrower one.

A chip counts every row, not the rows the queue had room for. A chip reading 5
above a "3 more" line would be its own small lie.

"LOW SUPPLIES 0" WAS NOT TRUE

Asked what happened to toner replacement, and the answer was worse than the
layout question that prompted it.

_get_low_supplies_data returned the same empty result for three different
situations: Zabbix not configured, Zabbix unreachable, and every cartridge
full. The dashboard strip therefore displayed "Low Supplies 0 / Critical 0"
while the monitoring was dead - and this instance is exactly that case, with
zabbix_enabled true against a URL nothing answers on.

So the strip removed in the previous commit was not information. It was
reassurance, and it read identically whether the printers were fine or nobody
could see them. That is the failure this codebase already names elsewhere: an
empty card and a broken card must not look the same.

The endpoint now distinguishes the three, and returns an envelope rather than a
bare list so a card can say WHY it has no rows. cardVisible shows such a card
whatever its empty rule, because "I cannot tell" is not "nothing to report".

It renders as a notice line, not a queue row: there is no asset to act on, and
burying "the monitor is dead" among the things it failed to find would be the
same mistake in a new place.

Note what this does NOT fix: the Zabbix URL on this instance is a dev
placeholder. The board will now say so instead of implying the cartridges are
full, but somebody still has to point it at a real server - and it is worth
checking what production points at, since nothing would have shown there either.

Frontend 314 passed (13 new), full suite 2178. The response shape changed from
a list to an envelope; no test depended on the old shape.
2026-08-29 21:16:28 -04:00
cproudlock
13c46af5ce Rebuild the dashboard around what needs a person
The board was one card per source in a fixed grid, and both halves of that hurt.

Every source got the same small box whatever it had to say. Warranties showed 5
of 50 behind "and 45 more"; the crash card truncated at 4; a one-row card sat
beside a four-row card and left a hole. Nothing had room, which is the complaint
that started this: not enough space to relay the data.

And a box per source flattened priority. An enforcement failure and a warranty
notice looked equally urgent because each owned a card, while the largest type
on the page belonged to counts that are true every day and need nobody.

WHAT THE PAGE IS NOW

A severity rollup, one worst-first queue of everything that needs a person, and
a row of context beneath it.

The queue merges every critical and warning row and sorts them, so reading order
is work order. It is full width, which is where the room came from. Rows are
capped per source, because fifty expiring warranties must not bury two crashed
PCs, and what did not fit is named per source with a link to it - a bare "and 45
more" that leads nowhere is worse than saying nothing.

The rollup counts ROWS, not cards, and filters the board in place rather than
navigating away, so the counts and the rows cannot disagree.

Severity, position and permission were already declared per card and already
sorted on. The old layout threw that away; this only stops it.

WHAT WENT, AND WHY IT SHOULD NOT COME BACK

Four asset tiles plus an "All assets" tile: machines + PCs + printers + network
IS all assets, so the page showed a total beside its own addends at the same
weight. It is now a line under a rule, where a total belongs.

The printer strip. It carried the printer count a third time - the tile row and
"Total Printers" were already the same number - and "Online" equalled total, so
three tiles held one fact. A genuinely low cartridge already reaches the queue
through the printers-supplies card, which is the moment anyone wants it.

Three cards whose whole job was to display 0.

Recent Devices: the largest block on the page, listing five machines that were
all "In Use" with business unit "TBD". Recency is not a reason to look at
something.

CONTRACT 0.26.0: 'shortname'

Cards may declare a short label for their rows in the merged queue. Their
'title' is written as a heading - "Endpoints with crashes or shutdowns" - and as
a row badge it ran wider than the hostname it qualified, putting the badge
rather than the asset at the start of every line. CSS-truncating the title was
tried first and was worse: four consecutive rows reading "ENDPOINTS WITH CR..."
say nothing at all. Optional and defaults to the title, so a plugin that does
not declare one is unaffected. Additive field, minor bump.

ALSO: "silent 1 days"

A meta suffix is written for the plural case and the card cannot know the value.
One row saying it was a shrug; the merged queue puts every quiet endpoint on one
screen, so it said it five times in a column and read as broken. Now singularised
at exactly 1, on the first word, keeping what follows: "1 event (48h)", "silent 1
day". The test asserting ' ms' survives is what caught the first version turning
it into ' m'.

DashboardRow.vue is extracted because the queue and the context panels render
the same row, and the map tooltip, link fallbacks and chips are too easy to get
subtly different in a second copy.

Verified on screen against live data. Full suite 2178 passed, frontend 301.
2026-08-29 21:05:48 -04:00
cproudlock
e562b405d4 Collapse the relationships card by default, with an honest count
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m7s
The triage band now answers the common case above the fold - what an asset
serves, or what drives it - so the full edge list below is reference rather than
the first thing to read. On a machine showing two relationships, both already
appeared in the band and the banner, and the card was a third copy.

Collapsed by default, using the same caret, roles and keyboard handling as the
collapsible panels on the same page rather than a second, slightly different
one.

The card is NOT redundant with the band and is not removed. It is the only
surface with Add Relationship and the per-row delete, and it keeps each edge's
exact type where the band deduplicates by asset. The printer page is the proof:
four rows there - usesprinter and defaultprinter, for each of two machines -
collapse to two band chips, and which machine DEFAULTS to that printer is a
distinction the band deliberately drops. Fine for triage, wrong for management.

THE COUNT IS OF WHAT IS RENDERED, NOT OF EDGES

A collapsed card that just says "Relationships" cannot be told from an empty
one, so the header carries a count. The first version counted raw edges and was
wrong on the first machine it was shown: a symmetric Dualpath link is stored in
BOTH directions in the legacy shape, so it is two rows in outgoing+incoming and
one "Connected" entry on screen. The badge read 3 above a card that shows 2 when
expanded - exactly the mismatch the badge exists to prevent. It now counts
rendered items.

Add Relationship expands the card before opening the modal, so a new row cannot
land behind a closed caret and read as the add having failed.

Verified on screen: machine reads 2 and matches, printer reads 4 and matches.
Frontend 280 passed.
2026-08-29 19:52:03 -04:00
cproudlock
78017eee2a Put the dual-bay sibling in the triage band, and fix the gage counterpart
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 4m8s
TWO STRIPS OF CHROME SAYING THE SAME KIND OF THING

The machine page opened with a banner - "Dual-bay machine - sibling bay: 2022" -
directly above a hero whose band said "Controlled by". Both answer "what am I
looking at", and splitting them across a banner and a band cost a strip of
vertical space to say so.

The sibling is now a chip in the band beside the controlling PC. The band takes
an `extraitems` prop rather than learning what dualpath is: the pairing is
resolved by the machines plugin, not from the relationships endpoint, so core
cannot derive it, and a dualpath-shaped prop on a core component would be the
machines plugin leaking upward. A page passes [{label, links:[{text, to}]}] and
the band renders it with its own markup, which also keeps the chips visually
identical to the ones it derives itself.

The label stays "Dual-bay sibling" rather than "Dualpath". The banner spelled
the meaning out because a dualpath pair is ONE physical machine in two bays; a
chip reading "Dualpath" would have dropped that.

THE GAGE PAGE FILTERED FOR A COUNTERPART THAT CANNOT EXIST

MeasuringToolDetail was wired counterparttype="machine" labelled "Used on".
Wrong on both counts. The import synthesizes one measuring-tool asset per
metrology PC and a 'controls' edge PC -> tool (stage_metrology), so a gage's
counterpart is a COMPUTER, exactly as on the machine page. Filtering for a
machine peer meant the band rendered nothing on every gage that has a PC.

It looked merely empty rather than wrong because this dev database holds one
measuring tool with no relationships at all, so there was nothing to be missing.
A hand-held gage genuinely has no such edge and correctly shows nothing; a
metrology station now shows the PC that drives it.

The relationships card stays. It is the only surface with Add Relationship and
the per-row delete, and it lists types the band filters out - the band is
triage, the card is management. It does now repeat what the band shows, which
is worth revisiting separately.

Verified on screen. Frontend 280 passed.
2026-08-29 19:24:07 -04:00
cproudlock
96a4a38c8c Make the legacy import safe to resume, and aimable without editing it
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m13s
Three defects broke the loader's own documented resume guarantee. All three
were silent: nothing raised, the run finished cheerfully, and fewer rows
arrived than anyone counted.

THE PAGE SIZE WAS CLAMPED, SO THE 409-RESOLVE SEARCHED THE FIRST 100 ROWS

_upsert asked for ?per_page=10000 and read one response. The server clamps any
page size to MAX_PAGE_SIZE, which is 100 - not an error, not a warning, just
100 rows. Anything past that resolved to None, so the id never reached the
crosswalk and the run quietly dropped a model's link on every machine that used
it, or an application's installs.

The target already holds more than 100 models and more than 100 applications,
so this fired on any re-run or resume - exactly the case the loader documents
as safe.

_resolve_existing now tries the exact natural-key filter the API has exposed
all along (?modelnumber= on models, ?appname= on applications, ?vendor= on
vendors) and then pages properly, bounded. An endpoint without such a filter
ignores the unknown parameter and the paged walk finds the row.

stage_metrology repeated the same mistake in its own way: it skipped 409s
entirely, so a resumed run left the tool out of the crosswalk and the Controls
edge unmade - metrology PCs driving nothing. stage_printers already had the
resolve branch; that stage was written later and missed it.

THE CROSSWALK WAS NOT BOUND TO THE DATABASE IT DESCRIBED

idmap.json maps this site's legacy ids to assetids in ONE database, and those
assetids mean nothing anywhere else. Keeping it across a drop-and-rebuild left
warranties and printer assignments attaching to whatever asset now holds that
id: wrong data, no error. The runbook's two "drop and rebuild" steps never
mentioned the file.

The loader now writes a uuid marker into the TARGET's own settings table, so it
dies with the data, and stamps it into the crosswalk. A crosswalk built against
a different database is refused before a row is written. The database name
alone would not do - a rebuild keeps the name. A file written before this guard
is adopted rather than refused, so no existing crosswalk is stranded.

Also adds --idmap, so one checkout can drive more than one target.

THE SOURCE DATABASES ARE NAMED, NOT HARDCODED

'shopdb_src' appeared at 29 call sites, so aiming the loader at the prodscratch
restore meant editing run.py. Those names are now LOGICAL and resolved in
Source.db() from SOURCE_DB_MAIN / SOURCE_DB_USB / SOURCE_DB_EMPLOYEES, with
host, port, user and password overridable too. The call sites are untouched -
resolving centrally avoids missing one, and the literals now read as
documentation of which source a stage draws from. The run prints every source
it redirected, so "it imported nothing" can be told apart from "it read the
wrong database".

This is what stood between the loader and the prodscratch run.

TWO SMALLER ONES

_source_password() raised IndexError rather than its intended message whenever
the DATABASE_URL user is not root, which it will not be on a real server.

main() returned 1 on failure into a caller that discarded it. A run that logged
200 endpoint errors exited 0 and read as a success in a deploy log; it now
exits non-zero.

TESTS AND DOCS

The loader had no tests at all. Sixteen now cover the resolve, the crosswalk
binding and the source aliasing - the logic that can run without a MySQL
source, which is the part that was wrong. Verified they are not vacuous by
running the old algorithm against the same fixture: it returns None where the
new one returns the row.

The README claimed 15 stages "complete" with counts from 2026-07-13 that two
later routing changes invalidated, and the runbook told operators to verify a
fresh import against them. Both corrected, with the three gaps NOT fixed here
recorded plainly: warranties, notifications and knowledgebase POST without a
duplicate check so resuming across them doubles rows; there is no --dry-run or
target guard; and the verify stage counts target assets against source machines,
which is apples to oranges.

Full suite 2178 passed.
2026-08-29 19:08:48 -04:00
cproudlock
c22c81afa5 Show the controls link from both ends, and fix the dead chip links
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m7s
THE RECIPROCAL LINKS BUG

A PC's page showed "Serves machine 7803" at the top. The machine's page showed
nothing equivalent, though the same edge was right there in the relationships
card at the bottom. That asymmetry read as missing data and was reported as
such.

It was neither missing data nor an API fault. The endpoint returns both
directions and always did; AssetTriageBand already walked outgoing AND incoming
and deduped, with a comment explaining that production holds reciprocal rows.
It then discarded every peer whose asset type was not 'machine', and hardcoded
the label "Serves machine". So the component could only ever work from the PC
side, and it was mounted only on the PC page.

The counterpart type and label are now props, defaulting to the old values so
the PC page is unchanged. The machine page mounts the band with
counterparttype="computer" and the label "Controlled by".

Eliminated with evidence rather than assumed, because this bug had already been
misdiagnosed twice: the endpoint runs two queries and returns both arrays; the
generic card renders both; that route has no pagination to clamp; and a single
permission gate cannot admit one direction and not the other.

THE RELATIONSHIP TYPE WAS ALSO HARDCODED

Not every asset pair is linked by 'controls'. A machine reaches a printer
through usesprinter and defaultprinter, so the band was blank on the printer
page - the one page where the link is not obvious from the asset itself. The
types are now a prop, and the printer page passes its own.

THE CHIPS WERE DEAD LINKS

Found while adopting the band more widely. It linked to /assets/<assetid>.
There is no /assets/:id route and no catch-all, so every chip in the band was
dead - and this change would have propagated that to four more pages.

AssetRelationships.vue already resolved this correctly and privately. That
function is now frontend/src/utils/assetRoute.js and both components use it, so
a third caller cannot get it wrong again. Two things it gets right that are easy
to miss: the detail routes key on pluginid, not assetid, so passing assetid
opens the wrong record; and the lookup is now case-insensitive, because
csvimport.py seeds 'Computer' and 'Machine' capitalised while this site's
database holds them lowercase - a case-sensitive match produced dead links at
whichever site seeded the other way.

The band now carries pluginid and assettypename on each chip, which the
resolver needs.

TESTS

assetRoute had none and neither did the band. Seven cover the resolver,
including the assetid-vs-pluginid confusion and the seeding-case difference.

On the backend, nothing asserted that the TARGET of a directional edge receives
it in `incoming` - only that rows carry isdirectional. That missing assertion is
this bug's exact shape, so it is now pinned: the endpoint was right all along,
and the next such report should not cost anyone a re-diagnosis of the API.

Verified on screen: the machine page shows "Controlled by G6K76CW3ESF" in the
hero, the PC page is unchanged, and a page with no such link renders no empty
band. Full suite 2162 passed, frontend 280 passed.
2026-08-29 18:58:06 -04:00
cproudlock
70e909054f Document the endpoints plugin in the catalogue
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m14s
docs/PLUGINS.md listed sixteen of the seventeen bundled plugins. The missing one
was endpoints - the newest, and the one another site is most likely to ask
about, since it is the Fleet console and the live panels on a PC detail page.

The row records the two things that are not guessable from the name: it depends
on computers because an endpoint is telemetry ABOUT a PC asset rather than a
second record of it, and its four permissions are split deliberately. Read is
grantable to guests by the .view suffix rule; fleet, command and restart are
not, and the plugin declares no override, so the command channel cannot be
opened to anonymous callers by ticking a box.

Ingest is called out as NOT being a route here - agent reports arrive on the
generic collector route under a service token scoped collector.ingest.endpoints.
Somebody looking for the ingest endpoint in this plugin will not find one.

applications/ stays absent on purpose: it has no manifest and no plugin.py, so
per the manifest-less rule it is core frontend surface that always ships, not a
plugin a site can choose.
2026-08-29 18:29:11 -04:00
cproudlock
76d34a4b25 Keep the lockfile's rationale where a compile cannot wipe it
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 4m12s
requirements.txt carried a hand-written header explaining why the lockfile is
universal and hashed, what each flag buys, and a warning not to hand-edit it
because waitress and tzdata were once added by hand and vanished on the next
compile.

Running `uv pip compile` to upgrade two packages wiped all of it, including the
warning about hand-edits vanishing. The header had been documenting its own
failure mode and still lost.

The explanation now lives in requirements.in, which is the file people edit and
which a compile never rewrites. The header is restored in requirements.txt for
anyone reading that file directly, but it is no longer the only copy.

Also records the --upgrade-package form for moving one package without moving
everything else, and the scan_sbom.py check to run before a release.
2026-08-29 17:42:43 -04:00
cproudlock
b8fc2e9e33 Upgrade the eight shipped dependencies carrying advisories
scan_sbom.py reported 50 advisory matches against shipped components, 16 of them
at or above HIGH. This clears all of them: the scan now reports 0 shipped
findings and the gate passes.

  pyjwt             2.12.1  -> 2.13.0
  idna              3.13    -> 3.19
  axios             1.13.2  -> 1.20.0   (29 advisories)
  postcss           8.5.6   -> 8.5.26
  dompurify         3.4.11  -> 3.4.14
  nanoid            3.3.11  -> 3.3.18
  follow-redirects  1.15.11 -> 1.16.0
  form-data         4.0.5   -> 4.0.6

Every one is an in-major bump inside a range the manifests already allowed, so
this is a lockfile refresh rather than a migration. The Python side was compiled
with --upgrade-package for each name so nothing else moved; the diff is two
version lines and their hashes.

nanoid is worth recording: it is transitive under postcss at ^3.3.11, and its
advisories are fixed on both the 3.x and 5.x branches. 3.3.18 is the fix that is
actually reachable.

The declared floors for axios and dompurify were raised to the versions in the
lockfile. They were ^1.6.0 and ^3.4.11, which still nominally admitted the
vulnerable range - npm resolves to the highest match so an install was never
going to pick one, but the manifest should state the minimum safe version rather
than rely on that.

axios 1.20 adds optional proxy-agent support, which pulls https-proxy-agent and
agent-base into the tree. Both are Node-only and neither reaches the browser
bundle; the build was checked for them. The main chunk grew 256kB to 271kB
(85.7kB to 90.5kB gzipped) from the rest of the axios delta.

The PyJWT entry in the allowlist was removed rather than kept. It stopped
matching the moment the upgrade landed, and an allowlist that accumulates
entries for advisories that no longer apply is one nobody rereads. The reasoning
is preserved as the worked example in docs/DEPENDENCY-SECURITY.md.

Verified: full suite 2161 passed, frontend 273 passed, build clean,
npm audit --omit=dev reports 0 vulnerabilities.
2026-08-29 17:42:35 -04:00
cproudlock
1cc2a6b782 Recommend the smallest upgrade that clears an advisory, not the highest
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m18s
The rollup took the highest fix version across a component's advisories. That
is wrong whenever an advisory is patched on more than one release branch.

nanoid is the case that exposed it. Both advisories against it are fixed on two
branches - 3.3.16 and 5.1.16, 3.3.18 and 5.1.6 - and the rollup recommended
5.1.16 with a MAJOR warning. But nanoid is transitive, pulled in by postcss at
`^3.3.11`, a range no 5.x release can satisfy. The recommendation was not merely
a bigger job than necessary, it was unreachable: the actual target is 3.3.18, a
patch inside the existing range.

Per advisory the target is now the lowest published fix above the version in
use; per component it is the highest of those, since one upgrade has to satisfy
every advisory against it. Fixes on older branches are skipped rather than
offered as a downgrade - axios advisories list 0.30.x fixes that would have been
a downgrade from 1.13.2.

This also gives the MAJOR label meaning. It now fires only when no fix exists on
the current branch, rather than whenever some branch happened to be higher.
2026-08-29 17:24:37 -04:00
cproudlock
c4e2db4755 Route the dependency-security doc from START-HERE
Some checks failed
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Has been cancelled
START-HERE routes by the question somebody arrived with, and 'are we exposed to
this CVE' had no entry - the SBOM was only mentioned in the Windows update doc,
which is not where anybody asking that would look.
2026-08-29 17:20:57 -04:00
cproudlock
794bb4668f Add vulnerability scanning on top of the SBOM
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Has been cancelled
The SBOM was generated and installed onto every server, and nothing read it.
"Are we exposed to this advisory" was still a person going and looking, which
meant it happened when somebody remembered to.

scripts/scan_sbom.py queries OSV.dev by purl - no account, no API key, no agent,
no vulnerability database to maintain on a box that cannot reach the internet.
The SBOM is already the input OSV wants, so this is a lookup against what
shipped rather than a second scan of the tree that could disagree with it.

Three decisions worth recording:

Shipped and build-only findings are both reported, but only shipped ones can
fail a build. Two thirds of the components here are build tooling the SBOM
already marks scope 'excluded', and the highest-severity match in the whole tree
today is a CRITICAL against the test runner. A gate that fails a release over
that trains everybody to bypass it, and then it catches nothing.

Severity is taken from the advisory, never derived. Where there is only a CVSS
vector the report says UNKNOWN, which is shown and never gates. A guessed score
is worse than an honest gap because somebody would act on it.

The allowlist is a record rather than a mute button: each entry carries the
reason, the accepter, the date, and what would invalidate the reasoning. The
first entry is a worked example - a PyJWT algorithm-confusion advisory that
needs both mixed algorithm families and a raw JSON JWK as the key, and this
application configures neither.

The report ends with a per-component upgrade rollup, since that is the part
somebody acts on. A target on a different major version is labelled rather than
listed beside patch bumps as though the two cost the same.

Current state: 8 shipped components have fixes available, none of them
exploitable through a path this application opens today.
2026-08-29 17:18:58 -04:00
cproudlock
d4105c2289 Say what a service state actually is, and make the badges readable
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 14s
CI / migrations-mysql (push) Successful in 43s
CI / backend (push) Successful in 4m18s
Three colour faults on the PC page, each hiding the next.

ACTIVITY BADGES WERE ALL GREY. The list renderer reads a badge colour from a
ROW FIELD, and the activity panel declared none, so every severity rendered
identically. The feed's whole job is to make a critical event visible and it
was painting it the same as a logon. The route now emits severitycolor - info
grey, warning yellow, critical red - and the panel declares it.

PAUSED AND THE TRANSITIONAL STATES WERE RED. statecolor was green-or-red-or-
grey, so a paused service looked like a stopped one. They are different: a
paused service was paused deliberately, and one stuck in Start Pending is
failing to start on a retry cycle - which is exactly what raises event 7011,
the thing behind the critical counts on the Fleet page. Both are amber now.

NAMED COLOURS ALWAYS GOT WHITE TEXT. readableText only parsed hex and returned
white for anything else, but panels emit NAMES. Green, red and grey are dark
enough that white worked by luck; amber is not, so the fix above shipped as
white-on-yellow and could not be read. Names resolve to hex before the contrast
test, so this holds for any named colour anywhere in the app rather than these
two panels.

colorStyle had no test file at all. It has one now.

Every one of these was found by looking at the rendered page. None of them
would fail a test that checked markup.

Full suite 2131 passed, vitest 273 passed.
2026-08-29 17:00:18 -04:00
cproudlock
828a62063b Filter chips on any card that shows a health dot
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m4s
Reported from the Activity card: the header dot was red while every visible row
was info, because the critical event was older and sat further down the list.
The dot and the list appeared to contradict each other, and the only way to
find the critical was to scroll.

The dot is computed from all rows the panel received, so a red dot always means
a critical IS in the data. The chips are computed from that same array, which
means the count sits directly under the dot and the two cannot disagree by
construction: the card now reads 'Critical 2  Warning 1  Info 12', and one
click surfaces them wherever they sit in time.

Derived from the panel's EXISTING status declaration rather than a new one per
panel. A card that shows a health dot has already said what it dots on, so
Tracked Services and Tracked Processes get chips with no plugin change, and any
future list panel with a status rollup does too. Two declarations of the same
thing would eventually disagree.

Details that matter: a colour-keyed panel is labelled by meaning, so services
read Critical and OK rather than Red and Green; chips are ordered worst first
rather than alphabetically; and no selection means show everything, not hide
everything - a card that emptied when you deselected the last chip would read
as broken. Filtering to nothing says so, and gives the hidden count.

vitest 267 passed, including the exact reported case.
2026-08-29 13:02:32 -04:00
cproudlock
60c35728e8 Import USB records off an external cmmc_usb database, so a site can retire it
A site running external mode keeps its devices and check-in/out history in a
separate cmmc_usb database. There was no way to bring that in, so the choice
was to keep the second database forever or lose the history. This adds
'flask usb import-external' - dry run by default, --commit to write - which
reads through the same connection settings the routes already use and never
writes to the source.

Two things it has to reconcile, and they are why it is a command rather than a
few INSERTs.

The schemas disagree about SHAPE. External is an event log, one row per action;
self-hosted is a paired row carrying both times. Pairing walks each device in
time order, and real logs after years of use are untidy. The rule is that
nothing is invented and nothing vanishes: a drive still out becomes an open
checkout and is NOT flagged (that is just a drive somebody has), a return with
no matching issue is skipped and named, and two issues in a row leave the first
open rather than inventing a return time nobody recorded.

Identity is the other. usbdevices.serialnumber is unique and NOT NULL and the
external devices table has no serial, so device_id becomes it. Blank or
duplicated ids stop that device rather than inventing a key. external
device_owner has no column here, so it is appended to the device notes rather
than dropped.

Also adds usbcheckouts.scannedviruses, the one field with nowhere to land.
waswiped already means what external calls sanitized - selfhosted.py was
mapping it - but scanned_viruses was HARDCODED None in the log output: the API
promised the field and had nothing behind it, so an imported history would have
rendered blank. It now reads and writes properly.

Nullable, never defaulted to false. An unrecorded scan is not a failed scan,
and for removable-media records those are very different claims.

Full suite 2131 passed; seven new tests pin the pairing rules.
2026-08-29 13:02:21 -04:00
cproudlock
99c40ae7c3 Say WHICH critical event, and stop the feed hiding it
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m15s
Reported from production: health showed '418 critical' while the ten most
recent events were all info. Both were true, which is the problem.

The health rollup counts critical events in a 48h window; the activity feed
shows the fifteen most recent events of ANY severity. A box that logged 418
service timeouts overnight and ordinary logons since produces exactly that
pair - a page contradicting itself with no way to tell why.

Two fixes.

The rollup now names the DOMINANT event instead of totalling: 'Service timeout
x418' rather than '418 critical'. Counting by event id costs nothing (one more
GROUP BY column) and turns a number into a lead. This is the same reasoning
that made health a phrase rather than a score; it stopped one step short.

The activity feed now fetches the newest criticals separately and merges them,
and keeps up to five of them when trimming to the cap. They hold their place in
time but survive the cut, so what health is reporting always appears in the
feed that exists to explain it.

Verified against the reported shape: 20 service timeouts two hours old under 12
newer logons. Before, the feed was fifteen logons; now it carries five critical
rows and health reads 'Service timeout x20'.

Worth noting the underlying signal is real - several hundred criticals on one
endpoint in 48h is a box crash-looping or a service failing on a retry cycle.
The display was hiding it, not inventing it.

Full suite 2124 passed, naming and parity gates green.
2026-08-28 23:41:59 -04:00
cproudlock
b1b9cdda72 Add bulk actions to the Fleet list, with a confirmation that names machines
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m8s
Select endpoints, then Collect now or Restart across the selection. Restart is
gated on endpoints.restart, both are re-checked server-side.

The confirmation names the MACHINES, not just the hostnames:

    Restart 2 computer(s)?
    GJX9B2Z3ESF, HOST-7
    This STOPS the machines these PCs serve:
      DT Office

A dialog listing eight hostnames tells an operator nothing about what they are
about to halt, and that is the whole consequence of the action on a shop floor.
Where no machine is recorded it says so, because 'none listed' and 'we did not
check' are different things at 6am. Only possible now the Machine column exists.

A 409 is reported as a SKIP, not a failure. The queue permits one command in
flight per endpoint, so a bulk action across eight PCs where two are already
busy says '6 queued' and '2 skipped - a command is already in flight'. Folding
those into an error count would make a working button look broken.

Selections clear on every reload: the rows they referred to may not be on the
new page, and acting on an invisible selection is how bulk tools surprise
people.

No per-row buttons. They were built first, and a tenth column on a nowrap table
pushed itself off the edge - selecting one row costs a single click and leaves
one command path instead of two.

Full suite 2124 passed, vitest 260 passed, naming gate green.
2026-08-28 23:16:24 -04:00
cproudlock
057cee1f02 Match the Fleet tile radius to the global card
The tiles were 6px while .card is 0.25rem, which left Fleet marginally rounder
than every other page. The global card treatment is a separate decision and has
been deferred; until it is made, this page should look like the rest of the app
rather than half-way to a mockup.
2026-08-28 23:03:29 -04:00
cproudlock
0baed4cc80 Close the gaps against the design: critical tile, filters, policy card, action placement
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m20s
Four things the mockup showed and the first cut did not have.

FLEET
  - A 'with critical events' tile, from the stability route that existed and
    nothing called. Clicking it narrows to rows whose health is not ok - client
    side, because the health rollup is assembled per row after pagination, so
    it narrows the PAGE rather than the fleet. Worth knowing before the count
    is read as fleet-wide.
  - PC type and Location filters. The list route already accepted both; there
    was simply no control. Both use listAll, not list: perpage is clamped to
    100 server-side, so a long location list would lose its tail in a dropdown
    while still looking complete.
  - The machine cell shows number and name together where they differ, so
    '3105 Okuma' rather than a bare number.

PC DETAIL
  - A Policy card, contributed by the computers plugin. It matters because the
    effective policy already narrows the command allowlist, so without it the
    reason a control is unavailable on a given PC is written down nowhere.
    Provenance, so it sits in the reference column, not the triage band.

    Its route returns an EMPTY field list rather than the feature-gate 404 when
    policies are disabled. The panel contract hides a card with no fields, so
    it disappears on sites that have not enabled the feature - a 404 would have
    rendered a broken card on most of them. That was flagged in the design
    review as a snag; this is the fix.

  - Refresh Status and Restart Computer moved out of the page header into the
    triage band. They act on the MACHINE; Print Label, Edit, Retire and Back to
    List act on the RECORD, and mixing them put the most consequential button
    on the page - it halts whatever machine the PC serves - between printing a
    sticker and editing a form. In the band it sits beside the liveness chip
    and the machine it would take down.

    The band takes them through an 'actions' slot rather than knowing about PC
    verbs, so a measuring tool can put 'Calibrate' there without the core
    component learning anything about computers.

Full suite 2124 passed, vitest 260 passed, gates green, spec at 333 paths.
2026-08-28 23:00:25 -04:00
cproudlock
414a7243d7 Merge events and commands into one Activity feed, and lead with live state
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m9s
Two changes to the PC page, both about what a responder meets first.

LIVE STATE FIRST. The contributed panels moved to the head of the card flow.
The grid is a two-column FLOW (column-count, with display:contents on the
column wrappers), so a card cannot be pinned to a side and only document order
decides where it lands - putting the panels first fills the left column with
endpoint status, services, processes, interfaces and activity, and leaves
identity, hardware, network and location to the right. Panels still self-order
among themselves by declared position and still self-hide when no plugin
answers for the asset type.

ONE ACTIVITY FEED replaces the Recent events card. Events and commands answer
the same question - what happened to this box - and reading them as two lists
meant interleaving by eye. GET /api/endpoints/asset/<id>/activity merges them
newest-first, capped at 15. Both are keyed by assetid, so it is two indexed
reads and a merge sort rather than a join, and it renders through the existing
ADR-010 list renderer with no bespoke frontend work. A failed command is marked
critical, because that is the row somebody needs to notice. Not collapsed by
default, unlike the card it replaces.

The core AuditLog is deliberately NOT a third source: its rows carry the action
as the entity name and the asset only inside a details string, so including it
would mean parsing prose to find out which machine a row belongs to.

Requester names are withheld from callers who cannot themselves issue commands,
via caller_holds. A read-only viewer - a guest, on a site that grants this
panel - gets the history without a roster of who restarted what, and the lookup
is skipped entirely rather than fetched and discarded. requestedby holds a user
ID, so names resolve through one batched query, not one per row.

Verified on dev telemetry: a queued command shows as 'Collect now / Pending by
admin' above the Windows events, ordered by time.

Full suite 2124 passed, vitest 260 passed, naming/contract/parity gates green,
spec regenerated at 332 paths.
2026-08-28 22:34:59 -04:00
cproudlock
0f9ffdb2f1 Put the 6am answers at the top of the PC page, and stop saying things twice
Adds a shared triage band to the PC detail hero: is it alive, what stops
without it, how do I reach it.

'Serves machine' is the reason it exists. On a shop floor a PC matters because
a machine stops without it, and that fact was buried in the generic
relationships card at the bottom of the page. Both edge directions are read,
deduplicated, and filtered to assets typed 'machine' - production holds
reciprocal rows, so reading one direction shows the link on half the pages and
not the other half.

The band is a CORE component, not a PCDetail block. Five asset detail pages
already rebuild the same hero and grid independently, which is how they drifted
apart; machines, printers and network devices can drop this in rather than
growing a sixth copy. Liveness is passed in rather than fetched, because only a
plugin that collects telemetry can answer it and a site may not have one.

The status chip's verdict comes from the SERVER: /api/endpoints/<id> now
returns the same reporting/quiet/never it computes for the fleet list. The
quiet window is a site setting, so a threshold guessed in the browser would
disagree with the Fleet list about the same endpoint the moment a site changed
it.

Two cards removed from the body:

  - Remote Access duplicated the band's chips. It is what a responder clicks
    first, so it belongs above the fold, once.
  - Status was worse than duplication. It reads the COMPUTER RECORD, while the
    endpoint panel beside it reads live agent telemetry, and on an
    agent-managed PC the record fields sit empty - so the page rendered 'Last
    Reported: Never' a few inches from live telemetry saying otherwise. It now
    renders only when that record actually holds something, which is the case
    on sites running the asset-report script instead of the agent.

Verified against dev telemetry: band shows 'Reporting, 1 h ago', the machine it
serves, and its access chips; both duplicate cards are gone.

vitest 260 passed, naming/contract/parity gates green.
2026-08-28 22:21:22 -04:00
cproudlock
4c43c4e1ba Fix the Fleet tile and health colours in dark mode
Two colour bugs on the same page, both invisible in light mode.

A button does not inherit color the way it inherits font, so the plain
Endpoints total rendered in the browser default black on the dark theme's card.
The coloured tiles set their own color, which is why only that one showed it.

The coloured ones were wrong too, just less obviously: dark mode redefines
--success and --warning to bright variants but leaves --success-dark and
--warning-dark tuned for a white background, so those numbers and the health
pills came out dark green and dark amber on a dark blue card.

Scoped to the stamped [data-theme="dark"] rather than a prefers-color-scheme
query, matching the note in style.css: the theme store always stamps the
attribute, and a bare media query leaks dark styling into light mode on a
dark-OS machine.
2026-08-28 22:15:09 -04:00
cproudlock
54f0857e13 Split the fleet roster from per-endpoint reads, and stop offering controls nobody can use
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 14s
CI / migrations-mysql (push) Successful in 45s
CI / backend (push) Successful in 4m34s
Twelve endpoints routes shared one permission, which made a reasonable policy
impossible to express. A site that wants signed-out visitors to see live status
on a PC page had to hand them the entire fleet - including every logged-in
username on the floor - because the per-asset panels and the fleet-wide list
were gated on the same string.

endpoints.fleet now gates the list and the three dashboard routes.
endpoints.view keeps the per-asset reads: status, services, processes,
interfaces, events, command history.

The name is load-bearing. guest_grantable_permissions() opens any '.view' or
'.read' permission to the guest role, so calling this one 'endpoints.fleet'
rather than 'endpoints.fleetview' is what keeps the roster off the public
surface while per-asset telemetry stays grantable. Verified both ways.

Also: the services panel emitted Start/Stop row actions to every reader,
because row_actions_for() never consulted the caller. Clicking was always
refused server-side, so nothing was exploitable, but showing a read-only viewer
controls over a shop-floor PC is the wrong signal - and it stops being cosmetic
the moment a site grants that panel to guests.

That needed a contract addition rather than a local check: caller_holds() on
shopdb.api answers whether THIS caller holds a permission without refusing the
request, which is the complement to require_permission for a surface that
serves everyone but should only offer what the caller can act on. Guests are
included, since a signed-out visitor holds whatever the guest role was granted.
Reaching into shopdb.utils.authz instead would repeat the ADR-002 violation the
contract gate caught earlier in this branch. Contract 0.24.0 -> 0.25.0.

DEPLOYMENT: endpoints.fleet is a new permission. Run 'flask seed permissions'
and grant it to the roles that should keep seeing the Fleet page. Until then
that page is admin-only, since admins bypass every check - a safe direction,
but not an automatic one.

Full suite 2124 passed, naming and contract gates green, map and spec
regenerated.
2026-08-28 21:14:27 -04:00
cproudlock
4ef22b6579 Make the Fleet list answer the question it exists for
The fleet list showed hostname, user, uptime, CPU, memory and last-reported,
sorted by last-reported descending. Two of those columns have never held a
value, and that sort floats the healthiest endpoints to the top and buries the
ones somebody needs to act on.

What a shop-floor fleet view is actually for is which MACHINE stops if a PC is
dead, so that is now a column. Both edge directions are checked, deduplicated,
and constrained to assets typed 'machine' - without that last filter the column
filled up with office locations that happen to carry a controls edge.

Also here:

  - Sorted WORST FIRST: never reported, then quiet, then reporting. Expressed
    as a sortable SQL rank rather than a label applied after the fact, so the
    order survives pagination.
  - Health rollup as a PHRASE, not a score: 'eDNC stopped', '2 critical'. A
    percentage does not tell an operator what to do.
  - Filters for status, PC type, location and business unit; a sort parameter
    (the route accepted none); and search widened from hostname alone to asset
    number and the machine number a PC serves, which is how people actually
    look a bay up.
  - CPU and Memory columns dropped. The agent declares those fields and has
    never assigned them, so they rendered '-' on every row.
  - A summary strip wired to the dashboard routes, which existed and which
    nothing called.

Two bugs found while building it:

The health rollup first read a 'running' boolean, but service rows carry a
STATE STRING and the vocabulary varies by source, so every service would have
reported as stopped. The panel and the rollup now share one _service_running()
helper; two copies of that rule would drift.

The summary counted quiet as total - reporting, which folded never-reported
endpoints in with quiet ones. Those are different failures - an enrolment or
key problem versus a machine that went down - so 'never' is counted separately.

Per-page batching for machines and critical-event counts: three queries per
page rather than two per row.

Full suite 2124 passed, vitest 260 passed, naming and contract gates green.
2026-08-28 21:00:35 -04:00
cproudlock
69178c0218 Stop sending the redundant per_page parameter on every list request
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 4m10s
get_pagination_params reads 'perpage' and only falls back to 'per_page', and
this client has always sent 'perpage', so the interceptor's duplicate never did
anything except make every list URL carry a parameter twice.

Removing it also clears a live production problem. The endpoints list route used
to answer a 308, which is a PERMANENT redirect: the browser caches it against
the exact URL including its query string. Clients that hit the old route keep
that rewrite after the server is fixed, because the request no longer leaves the
machine - nothing deployed can reach it, and it fails as mixed content when the
cached target is http. Confirmed in the field: the page failed on load with the
default query string while a search, whose query string differs, worked in the
same session.

Changing the query string changes the cache key, so an affected client misses
the poisoned entry and reaches the corrected route without the user having to
clear site data.

Plugin views that pass per_page explicitly still work - the backend fallback is
untouched. vitest 260 passed.
2026-08-28 18:04:15 -04:00
cproudlock
af42ba3589 Register the endpoints list without a trailing slash, and stop hiding its errors
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 4m7s
An endpoint reported telemetry correctly for hours while the Fleet page showed
"No endpoints found". The PC detail page displayed its panels the whole time, so
ingest was plainly working; only the list was empty.

The list route was the one route in 331 paths registered with a trailing slash:

    @endpoints_bp.route('/', methods=['GET'])      ->  /api/endpoints/

The browser client calls it without one, so Flask answered a 308 redirect to an
ABSOLUTE url. On dev that resolves and nobody notices. Behind the IIS subpath it
cannot: with no ProxyFix, Werkzeug builds that Location from the scheme and host
it believes it has, so it emitted http:// and dropped the /shopdb prefix. A
browser will not follow an https page to http, the request failed, and the
page's catch block rendered the empty state.

Every other route in the plugin worked throughout, because none of them carries
a trailing slash - the detail panels call /endpoints/asset/<id>/status and were
never redirected. Detail works, list empty is the signature of this bug.

Registered as '' now, so the rule is /api/endpoints. That is what
CONTRIBUTING.md's API section spells and what every other list route in the
product already did. The inventory and the generated spec are updated to match.

Also here:

  - tests/test_core/test_no_redirecting_routes.py fails the build if any /api
    rule is registered with a trailing slash and strict slashes. Verified by
    planting the violation: it named the offending route, then passed once
    reverted. It takes the shared app fixture rather than building its own
    application - doing that rebinds SQLAlchemy and leaves the NEXT module
    without tables, which showed up as three unrelated "no such table: settings"
    failures visible only in a full run.

  - The Fleet page now renders "Could not load endpoints (HTTP nnn). This is a
    failed request, not an empty fleet." A list that reports a 403 and an empty
    fleet identically is what made this take as long as it did.

  - TRUSTED_PROXY_COUNT (default 0) applies ProxyFix when set. Off by default on
    purpose: it makes X-Forwarded-* trusted, and a client can send those itself,
    while the login rate limit and account lockout are keyed on the client
    address. Set it to 1 on an instance behind IIS.

Full suite 2124 passed, vitest 260 passed, naming and docs gates green.
2026-08-28 17:48:06 -04:00
cproudlock
5da071fdda Stop the export aborting silently when a scrub finds nothing left to scrub
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 3m58s
The export died with no output and no dists, exit 1, having committed nothing.
grep exits 1 when it matches NOTHING, and under set -euo pipefail that failing
pipeline takes the script with it. Once every internal reference had already
been scrubbed, the gitea grep matched nothing, and the SUCCESS case became the
abort. It printed not one line explaining itself, which is what made it cost an
afternoon: the run looked like it had worked.

All three scrub greps are guarded now. This is the third failure of this shape
in this file, after the two fetch/push tag omissions.

Also excludes a stray .env copy from the sync. A hand-made .env.bak-<date>
reached the publication tree, because rsync walks the WORKING TREE and
.gitignore does not apply. The scrub gate caught it, which is what that gate is
for, and it never reached a commit or a bundle - but a file of live credentials
should not get that far. --include '.env.example' comes first so the example
still publishes.

Both verified by planting a violation: a no-match grep under pipefail, and a
planted .env.bak file that the sync now leaves behind.
2026-08-28 16:24:00 -04:00
cproudlock
2aea2bd818 File the Fleet nav item under Assets, beside the PCs it describes
Some checks failed
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Has been cancelled
The endpoints plugin declared a section of its own, so the sidebar rendered a
one-item 'Fleet' group at the bottom, below Information, far from the PCs the
view is about. Its own docstring said it belonged just after PCs, and its
position (16, against PCs at 15) already said so too; only the section
disagreed.

Filed under assets, which puts it directly under PCs. The now-empty fleet
section is removed from NAV_SECTIONS - an unused section is an invitation to
file something else there.
2026-08-28 16:20:28 -04:00
cproudlock
ae800772d1 Make the dev MySQL 5.6 index-prefix requirement actually reachable
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Has been cancelled
The printers observedqueues migration failed with error 1071 on a 5.6 dev
server whose file format was still Antelope. The unique index it needs is
(hostname, queuename), two utf8mb4 VARCHAR(255) columns, which is 2040 bytes
against a 767-byte cap.

The compiler hook in shopdb/utils/mysql_charset.py was already emitting
ROW_FORMAT=DYNAMIC, which raises that cap to 3072. What is easy to miss is that
Antelope makes 5.6 IGNORE the row format and build a Compact table with a
warning rather than an error, so the tables look created and keep the old limit.
docs/DEPLOY.md has required innodb_large_prefix plus Barracuda for pre-5.7
servers all along; nothing pointed a developer hitting 1071 at it.

So the troubleshooting row that said 'use MySQL 8 for dev' now gives the fix,
names the silent Compact downgrade, and says such tables stay capped until they
are rebuilt with ALTER TABLE ... ROW_FORMAT=DYNAMIC.

DATABASE_URL also gains ?charset=utf8mb4, which DEPLOY.md already requires and
this environment did not have.
2026-08-28 16:17:51 -04:00
cproudlock
784964c907 Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.

What arrives:

  endpoints plugin  - a ShopDB agent reporting endpoint state, telemetry,
                      services and interfaces, with a command channel the agent
                      long-polls. Ingest stays on the generic collector route
                      (ADR-006); the agent key is a UI-managed single-scope
                      token whose revocation is an instant kill switch.
  computers policies - Configuration and Business Policies (RMM-style), scope
                      trees validated at write time, actions checked against
                      the endpoints command allowlist. Feature-gated: every
                      route 404s while the setting is off, so enabling it can
                      never half-expose the surface.
  asset CSV import  - templates, a collector script carrying the site path, and
                      a validate-then-commit import. A plugin can own a virtual
                      column plus a post-write row hook, which is how a PC's
                      machine number becomes a controls link without a
                      hand-built relationships sheet.
  agent/            - the Windows agent and its installer.

Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.

MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).

Review fixes carried on top:

  - six policy GETs were jwt_required(optional=True), which authenticates a
    caller without checking anything; they require computers.view now, and six
    redundant jwt_required decorators are gone
  - the agent installer took a default BaseUrl and CollectorKey, so a mistyped
    run silently pointed an endpoint at the wrong instance; both are mandatory
    and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
  - a committing CSV import wrote no audit row

Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.

Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
2026-08-28 16:17:17 -04:00
cproudlock
93ca7587ce Move the workflow actions onto their Node 24 releases
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m50s
Every job on GitHub is warning:

    Node.js 20 is deprecated. The following actions target Node.js 20 but are
    being forced to run on Node.js 24: actions/checkout@v4, actions/setup-node@v4

Working today because the runners shim it, and a hard failure the day that shim
goes. checkout@v5, setup-node@v5 and setup-python@v6 are the releases built for
Node 24, so the warning goes away rather than being suppressed.

Nine references across five jobs. The comment above them says why, so nobody
pins them back to the v4 generation on the assumption it is the safer number.

The org's actions policy allows GitHub-owned actions with no SHA pinning
required, so the tag change needs no allow-list update.
2026-08-28 15:22:13 -04:00
cproudlock
f4b17d0fa9 Prove the gates ran, and ship the spec the app serves
Two faults with one cause: docs/ is excluded from publication because the prose
becomes the wiki, and two things under docs/ are not prose.

shopdb/core/api/docs.py reads docs/openapi.json AT RUNTIME to serve /api/docs.
Excluding it meant every site installed from the published repo shipped the
ReDoc page, loaded it, and got {"error":"openapi.json not generated"}. The spec
now publishes - it is a product file that happens to live under docs/. The prose
still goes to the wiki and nowhere else, and docs/api-inventory.json stays out,
because nothing reads it at runtime.

The second fault is the reason this was worth chasing. Three gate modules skip
themselves when the file they read is absent, which is correct in a published
checkout and silently wrong on GitHub, which runs against exactly that checkout.
The modules skipped, CI reported green, and an outside branch added nine
undocumented routes and passed. A gate that stops running is worse than no gate,
because nobody looks at it again.

scripts/check-gates-ran.sh asserts each gate ran, with two lists: the ones that
must run everywhere, and the ones needing files publication deliberately leaves
behind - required here, allowed to be absent there. A `gates` job runs it in CI.
Proven in both directions: green in each checkout, and red when the file a gate
reads is removed.

Also purge instance/plugins.json from the publication tree. It was never
published - it is untracked there - but an old copy had sat in that tree for
months, protected from --delete by its own exclude, and it made the published
repo fail its own suite locally: 13 plugins enabled where the code expects 16.
A publication tree that cannot pass its own tests cannot be verified before
shipping. That is the exact trap the comment at the top of the script warns
about, which had claimed a second victim.

Also: concurrency on the workflow, so a second push supersedes the first rather
than queueing behind it holding a self-hosted runner.
2026-08-28 14:15:58 -04:00
cproudlock
dd3db50460 Secure Send hands over a message, not a bare URL
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 3m52s
A link on its own invites being pasted onto text that is already in the chat
box, and every chat client turns the selection into the link's caption. The
secret then travels as the visible message with the secure link wrapped around
it, protecting nothing.

This is not hypothetical. Six LAPS passwords went out that way in production -
machine names and passwords as the caption, the correct /s/<token>#<key> URL
underneath, doing nothing at all.

Copy message puts a whole sentence on the clipboard: what the link is, how many
opens it has, when it expires, and then the URL. A sentence has nowhere to
attach itself - pasted into an empty box it is a message, pasted over a
selection it replaces it rather than decorating it. Plain text on purpose; a
rich-text flavour is exactly what would let a secret ride along as markup.

The success card now says to delete the password from the chat box before
pasting. The existing hint explains that the key cannot be recovered, which is
true and does not stop anybody pasting the password itself.

Nothing about the link was wrong and nothing about it changed: the URL carries
the token, the key rides in the fragment the browser never sends, the plaintext
is dropped the moment it is sealed, and redeeming stays a POST so a preview
cannot spend it.
2026-08-28 11:39:21 -04:00
cproudlock
5e7e39a24c Share a code by link instead of by instructions
Sending somebody a label meant sending a screenshot and a list of settings to
retype, and they would get one of them wrong. Copy share link hands them a URL
that reopens the generator with the content, the label, the copy count and
every layout value that was actually changed.

The whole payload rides in the fragment. A fragment is never transmitted, so it
does not reach Flask, it is not in the IIS log, and the proxy between them never
sees it - which matters because the content is usually an asset number or a
serial, and those have no business scattered through logs. It is NOT secrecy:
the link sits in a chat message and in browser history, and anyone holding it
can read the content. Secure Send exists for the case where that matters, and
the note under the box says so.

Only values that differ from a first-time visitor's defaults are carried, so
the link stays short enough to paste into a chat box; a shared link then wins
over the recipient's own saved settings, because somebody following one is
asking to see THAT code rather than their own last one. An unknown key is
ignored rather than refused, so a link made by a newer version still opens on
an older one. The picture is never carried - a data URL runs to megabytes.

Values are coerced back to the type the field holds. Everything arrives from a
URL as a string, and a nudge of 0 plus '2' is '02'.

Proven end to end: a link made in one browser, opened in a clean context with
no saved settings, rendering the same code.
2026-08-28 11:28:06 -04:00
cproudlock
8953ce8210 Ticking a section off crosses out the section
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 3m48s
It struck the heading and left everything underneath looking untouched, which
is the opposite of what somebody ticking "this section is done" means. The
strike now covers every block under that heading, down to the next heading of
the same or higher rank - so a top-level section takes its sub-sections with
it.

That needed the rank, and the HTML importer was throwing it away: h2, h3 and h4
all arrived as a flat 'heading', so a parent heading had no children to cross
out. It was the visible half of a real loss - a converted article kept its
words and lost its shape. Headings now carry their level and render at it, and
a guide written before this reads as flat, exactly as it did.

Code blocks, tables and images inside a finished section fade but are not
struck. A line through a command makes it unreadable, and re-reading the
command is usually the reason to look back at a step already done.

The five imported guides were re-imported so they carry their structure.
2026-08-27 15:42:46 -04:00
cproudlock
03a1151a5a Tech Tools is reachable by the people it was built for
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m50s
Every tool on that page is usable without an account, on purpose - the routes
carry no requiresAuth and say so - and Secure Send is the one write this
product offers a guest. The sidebar entry still never appeared for them: it
named no permission, so the filter that decides what a signed-out visitor may
see had nothing to go on and dropped it.

The plugin had no read permission to name, only tools.secrets, so it gains
tools.view: reaching the page at all. The tools themselves run in the browser
and touch no data, which is why this is about whether the entry appears rather
than about what anybody can read.

The item names either right. Somebody granted only Secure Send needs the entry
that leads to it, and a manager who holds neither still does not see it.
2026-08-27 15:12:07 -04:00
cproudlock
4e59fe30b2 A nav item says which permission its page needs
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m43s
The sidebar hides what the caller cannot reach - that is what makes it useful
to a guest, and what stops a member with a sparse role being offered links that
can only answer 403. It reads the permission off the nav item to decide.

Only four items ever declared one. Machines, PCs, Printers, Network, USB,
Knowledge Base, Notifications, Calendar, Dashboard, Map, Applications and
Reports declared nothing, so the filter had nothing to go on: granting the
guest role machines.view opened the API and left the sidebar empty, which reads
as the grant not working.

Every item now names its read permission - in the plugins, in the core
navigation endpoint, and in the frontend's fallback list, which is what renders
when the endpoint is unreachable and would otherwise disagree with it.
2026-08-27 14:50:52 -04:00
cproudlock
afce24379c Reading anything needs a permission
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m42s
A sweep of the URL map found 165 GET routes that answered anybody, with no
token at all: the whole asset inventory, every machine, PC, printer and network
device, the USB checkout history and who is holding which drive, the staff
directory with its photographs, every core report. Guest access had been argued
about one feature-flag at a time while the front door stood open.

Each of those now carries the read permission it belongs to. That also makes
the guest role mean something: a site can open exactly the reads it chooses,
and nothing follows by accident.

Thirty-three endpoints stay public, and each is named with its reason rather
than left to be inferred: the app shell and its bootstrap, the settings
allowlist (the handler already filters an anonymous caller down to branding and
a few keys), images fetched by <img src> which carries no Authorization header,
what a display kiosk renders before anybody signs in, the parts and USB kiosks,
the client scripts that run as SYSTEM before a user session exists, and a
secure-send link whose unguessable token IS the authorisation.

tests/test_core/test_public_read_surface.py is the standing gate, the companion
to the mutating-route sweep that has guarded writes for months. A new public
read fails CI until somebody adds it to that list in front of a reviewer. Four
further reads need a login but no permission, because the handler decides from
who you are - your own user record, your own API tokens, the role catalogue the
grid renders - and the same test proves each still refuses an anonymous caller.
Verified not vacuous by ungating one route and watching the sweep name it.

The employees plugin gained employees.view; the directory is names, SSOs,
photographs and reporting lines, and it had no permission of any kind.

Around ninety tests asserted payload shapes against endpoints that answered
anybody. They are about what the answer says, not about who may ask, so they
are signed in now rather than the gate being loosened. The ones that assert an
anonymous caller is refused were left exactly as they were.
2026-08-27 14:26:06 -04:00
cproudlock
2331c03664 Tick a guide's sections off as you work through it
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m38s
Only checklist items could be ticked, which answers "what have I done" but not
the question a long procedure actually raises: where was I. Somebody looks away
to do the thing the guide asked for and looks back at a wall of text.

Every step and every heading now carries a checkbox while reading. Ticking one
strikes it through and fades it rather than hiding it, because somebody
scanning back up the page has to still be able to read what they did. The
number is struck through with it, but the step's own "Step 3" label is not -
that is the address of the thing, not part of what was done.

Headings count, not just steps. A guide converted from an HTML article often
has fifty headings and no steps at all, and those are exactly the guides long
enough to lose your place in. That also means the card's "N steps to tick"
stops reading as a broken feature on a guide that is entirely tickable.

Reading only, as the checklists already were: in the editor a tick would be an
edit, and an author clicking around their own draft would quietly change what
the next reader sees as done. Progress stays per browser and per version -
shopfloor PCs share a login, so one technician's ticks must not greet the next
person on that bay, and step 4 of version 3 is not step 4 of version 2.
2026-08-27 13:02:24 -04:00
cproudlock
151d7c4f44 Anyone not signed in holds the guest role
Whether somebody with no account may see something was being answered one
feature at a time: a securesend_allow_guests setting, an enable flag per kiosk,
and very nearly a third for guides - each with its own settings page, its own
vocabulary and its own hand-written branch inside a route. Every one of them
answered a permissions question, and this app already administers permissions
per role.

So a caller with no token is not nobody: they hold the seeded `guest` role.
require_permission consults it when there is no identity, which is the entire
mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a
site opens one by ticking a box in the same grid it uses for every other role,
and closes it the same way. Revocation is read on every request rather than
cached, because taking guest access away is what somebody does the moment they
realise what they opened, and it has to bite immediately.

The redundant @jwt_required() above require_permission is gone from 184 routes.
It was belt-and-braces before; now it is actively wrong, because it refuses a
request with no token before the permission check can have an opinion, so a
grant would appear to do nothing.

What a guest may hold is bounded. Any .view or .read permission is grantable.
A WRITE has to be blessed by whoever owns it, through the new
get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the
only case that exists: sending a password to a vendor is the one write this
product means to offer somebody who never signs in. Everything else is refused
by the API and greyed out in the grid rather than warned about - a checkbox
that quietly makes deleting an asset anonymous is not a warning anybody reads
twice. A guest write records no creator: the trail says when and from where,
never who, and that is the reason for the bar.

GE-Enforce and Slides gained read permissions so they can be opened to guests
without handing out the manage right. Their read routes accept either through
require_any_permission, so nothing an existing manager could reach has moved.

The navigation endpoint now returns only what the caller can actually reach.
Without that a signed-out visitor gets a sidebar of the whole app where every
link bounces off the login page, which reads as a broken site rather than as a
closed door. A nav item may name several permissions; holding any one is
enough, which is what GE-Enforce and Slides need now that a viewer and a
manager hold different strings.

Two things this found on the way: current_user is a LocalProxy, so
`current_user is None` is False for a request with nobody behind it - harmless
under a required JWT, an AttributeError the moment verification became
optional. And an image pasted into a guide before its first save was never
attached to it, so it was visible to editors and invisible to every reader;
saving now attaches it, and `flask guides fix-images` repairs what is stored.

The guides guest setting written earlier today is gone. Granting guides.view to
the guest role replaces it, which is the point.
2026-08-27 12:51:58 -04:00
cproudlock
8531559f22 A guide can be carried to another site
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 3m32s
Guides are written on one site and needed on all of them, and the sites that
matter are air-gapped from wherever the writing happens. Until now a procedure
was stranded in the database it was typed into: the HTML importer could rebuild
one from its original article, but anything authored in the editor had nowhere
to go.

flask guides export writes one .guide.json per guide, with every image the
blocks reference carried inside it as base64. One file on purpose - a bundle
that needs its images copied alongside it is a bundle that will eventually
arrive without them. flask guides import reads them back, a directory at a
time, and refuses anything that is not a bundle before it writes a row rather
than half-importing a stray .json sitting in the same folder.

What travels is the content: title, summary, tags, blocks, images. What does
not is ids, revision history, authorship and publication state, because those
belong to whichever database issued them - an imported guide starts as a draft
at revision 1, owned by whoever ran the import. The SLUG does travel: it is the
address somebody pastes into a work order, and the same guide should answer to
the same name on every site.

--replace updates the guide already carrying that slug instead of creating a
second one, which is how a corrected guide is re-shipped. It drops the stored
images first: the incoming bundle is the whole truth for that guide, and a
guide re-shipped monthly would otherwise accumulate orphans nothing points at.

Bundles refuse a version they are too old to read rather than importing the
parts they recognise. A newer site's guide is not a guide this one can be
trusted to render.

Proven by round-tripping a five-guide export back into the same site, including
one carrying two PNG screenshots, and reading it in a browser.
2026-08-27 11:46:50 -04:00
cproudlock
c8efadefba An HTML article can become a guide
Some checks failed
CI / naming (push) Successful in 1m52s
CI / frontend (push) Successful in 2m12s
CI / migrations-mysql (push) Failing after 4m13s
CI / backend (push) Has been cancelled
A site adopting this app arrives holding procedures already written: styled
HTML pages that get mailed around, opened off a share, and edited by whoever
still has the file. Those pages are exactly what a guide is for, and retyping
one into the editor is how a good procedure stays on a share forever.

flask guides import-html reads the page and produces blocks. It recognises the
vocabulary those pages actually use - a step box with its own number, an alert
in one of three colours, a code block with a copy button, a checkbox list, a
table with a header row - and maps each to the block that already renders it,
lifting embedded images into guideimages so a document stops carrying its
screenshots as base64. Page furniture goes: the header, the footer, and the
table of contents a guide builds for itself from its own headings.

The conversion is lossy on purpose. Colour, fonts, width and nested layout are
dropped, because storing blocks rather than markup is what makes dark mode,
printing and tickable steps work at all. So an import is always a DRAFT and the
default is a dry run that reports what it would produce and writes nothing.
Blocks go through the same validator the API uses: an import must not be able
to store something the editor could not have produced.

The parser is tolerant, because real articles are not well-formed. An unclosed
<p> is normal HTML and the browser closes it at the next block element; without
the same rule one unclosed tag swallows the rest of the article into a single
paragraph, which is what it did before the test caught it.

Proven end to end against four real articles, including one carrying two PNG
screenshots, read back in a browser.
2026-08-27 11:27:19 -04:00
cproudlock
de3ca70592 Every report offers to email itself, and a long drive name stays in its card
Some checks failed
CI / naming (push) Successful in 1m58s
CI / frontend (push) Successful in 1m49s
CI / migrations-mysql (push) Failing after 9m0s
CI / backend (push) Has been cancelled
POST /api/reports/email has always taken any {columns, rows} from any caller,
but the Email report button existed on the toner and warranty pages only - not
because those reports are special, but because they happen to be their own
routed pages and the reports page had no button at all. It is now beside Export
CSV for every report the page can build a table from, which is all of them:
plugin reports through their declared columns, core reports through the flat
list they return, and the asset inventory summary by flattening its three
tables into one with the grouping named in a column. Presentation-only fields -
a status colour, a link id - are left out; a column of hex codes is worse than
no column. The shapes live in a module with tests rather than in the view.

The button is now hidden without reports.export on all three pages. Offering
somebody a button that can only answer 403 is worse than not offering it.

Separately, on the USB kiosk a long drive name pushed Take or Return out
through the side of its card, leaving a drive nobody could press. The name is a
flex item and so defaulted to min-width:auto, keeping its full intrinsic width;
it now yields, wraps mid-word for the serial-style names that carry no spaces,
and clamps its description to two lines so one long name cannot make every card
in the row tall. The locker line is never clipped - it is how somebody finds
the drive.
2026-08-27 10:55:33 -04:00
cproudlock
605dd6cb1b Kiosk: find a free drive by typing, and use the width of the panel
Some checks failed
CI / naming (push) Successful in 2m13s
CI / frontend (push) Successful in 1m19s
CI / migrations-mysql (push) Failing after 6m11s
CI / backend (push) Has been cancelled
A cabinet of thirty drives was one long column to read through. The free list
now filters from a search box and lays out in as many columns as the panel is
wide - four on a wall display, two on a tablet, one on a phone - while the
steps that hold a single control stay narrow, because stretching one input
across a 24 inch screen reads as a broken page.

A drive is found by its id, by what is written on it, or by where it lives. The
words can arrive in any order, and "locker" is part of what is searched, so
"locker engineer" finds the engineering locker - that is how people say it out
loud. Filtering is client-side: every free drive is already on the screen, and a
round trip per keystroke on a wall panel would be worse than no filter at all.
The matcher lives in its own module with tests rather than inside the view.

The box takes focus as the picker opens so somebody can simply start typing,
and it is cleared when the kiosk resets - the next person at the cabinet must
not inherit a filter and conclude there are no drives left.

Also: both this view and the guides blocks coloured muted text with a
`--textmuted` variable this app has never defined. The browser dropped the
declaration, so every hint, subtitle and locker line was rendering at full
strength. The variable is `--text-light`.
2026-08-27 10:35:11 -04:00
cproudlock
11f39253c2 A plugin report renders as a report, not as a JSON dump
Some checks failed
CI / naming (push) Successful in 1m39s
CI / frontend (push) Successful in 2m28s
CI / migrations-mysql (push) Failing after 6m58s
CI / backend (push) Has been cancelled
The reports page ran a hardcoded switch over five core report ids and fell
through to `default: console.error('Unknown report')`, and its results panel was
a matching chain of per-id templates ending in a `JSON.stringify`. So a plugin
could register a report, watch it appear in the catalogue, and get a wall of
JSON when it was opened - which is where the three printedparts reports have
been since they were added.

Any report that declares an endpoint is now fetched from it, and any report
returning {columns, rows} renders as a table. That shape is already the
convention on the server side, so a plugin needs no core code at all to have a
working report. The per-id templates stay for the core reports whose payloads
are not tabular.

Export CSV goes through the api client rather than window.open. A new window
carries no Authorization header, so every report behind a real jwt_required -
which is every plugin one - answered the export button with a 401. The core
reports are jwt-optional, which is the only reason the old way ever appeared to
work. It also followed /api/reports/<id>, which does not exist for a plugin
report, instead of the endpoint the report itself came from.

A report that fails now says so instead of leaving an empty panel, and the page
offers the time window the endpoints already accepted - printedparts-consumption
was permanently stuck on its 30-day default because nothing could send one.

Verified in a browser against both plugin report families and the core reports.
2026-08-27 10:20:38 -04:00
cproudlock
f59aca5a1b USB check-in / check-out audit, as a report rather than a paged list
CMMC removable-media tracking is an audit obligation, and answering "who had
this drive, and when" meant paging through the check-in/out list by hand.
GET /api/usb/reports/audit returns the events - a take and a return are separate
rows, because a count cannot answer the question an audit actually asks - with
summary figures riding along for the people who only want the shape of it, and
a CSV for the people who want it in a spreadsheet.

The window is named rather than a day count: today, last month, six months, a
year, all time. Nobody should have to work out that a year is 365.

Both directory modes answer in one voice. The external path reads the
cmmc_usb checkinoutlog; self-hosted expands one usbcheckouts row into its two
ends, so a returned drive is distinguishable from one still out, and the window
filter runs in SQL against either end of the loan - a drive taken before the
window and returned inside it is a check-in the window has to show. Rows come
back from the mode-specific half; the CSV, the summary and the response shape
are built once in the route.

Ordering is total in both modes. Timestamp alone ties for two events in the same
second, and the same query then returns them in a different order each run,
which reads as the log being unstable.
2026-08-27 10:20:21 -04:00
cproudlock
add310e4e9 USB kiosk: drive lists in two columns, in the order the labels read
The kiosk was a single column of full-width rows top-aligned on the page, so a
cabinet of a dozen drives meant scrolling a wall panel, and the page itself
scrolled - which takes the "Your drives" heading off screen and leaves somebody
looking at a list with no idea whose drives they are. The lists now sit in a
grid that fills the width and scroll INSIDE themselves against a bounded
height, with the panel centred as one block the way the parts kiosk beside it
already is. Two kiosks on the same floor that sit differently on their screens
read as two different products.

Drive ids sorted as text, which puts USB-10 ahead of USB-3. Compared with a
numeric collator so the list runs in the order the labels on the drives read.
2026-08-27 10:20:03 -04:00
cproudlock
3c9b400db4 Granting somebody a permission now takes effect without a re-login
Some checks failed
CI / naming (push) Successful in 2m32s
CI / frontend (push) Successful in 4m14s
CI / migrations-mysql (push) Failing after 10m46s
CI / backend (push) Has been cancelled
fetchUser() existed and was called from nowhere. The signed-in user - including
the permission list every v-if in the app reads - was written once at login and
cached in localStorage, and nothing ever refreshed it. So an admin granted
somebody guides.create, the person was told they had it, and the New guide
button stayed hidden until they happened to log out and back in. Nothing on
screen explained why, and the natural conclusion is that the permission system
is broken.

Called on app boot, so a permission change lands on the next page load.
Reproduced before and after: button hidden, permission granted mid-session,
plain reload, button there.

Only a 401 logs out now. The old handler logged out on ANY failure, so an API
restart or a network blip would have thrown people out of sessions they still
held - a cached user is stale, not invalid.
2026-08-27 09:25:54 -04:00
cproudlock
37a82c2650 Somewhere to actually turn the USB kiosk on
The kiosk page tells an administrator to enable it in Settings under USB. That
place did not exist - the setting was reachable only through the API or a shell,
which is the same trap the backups plugin shipped with. A settings page and a
catalogue card, so the instruction leads somewhere.
2026-08-27 09:25:46 -04:00
cproudlock
21e66c6749 Network devices: manage buttons where every other asset keeps them
Machines, PCs, printers and measuring tools all carry Print Label / Edit /
Retire / Back to List in a page header. A network device had no header at all:
Edit was tucked inside the hero title row, and Print Label and Delete sat in a
bar at the very bottom of the page, past every card. Managing one meant looking
somewhere different from every other asset type.

They move to the standard header. The bottom bar is removed rather than left as
a second copy, and the two delete handlers collapse into one - the route is a
soft delete, so it is called Retire here as it is everywhere else, and the
confirm says what is kept.
2026-08-27 09:25:45 -04:00
cproudlock
933740aae4 Guide editor: real arrows, drag to reorder, add a section where it goes
Four things people kept tripping over.

The move controls were a literal ^ and v character. They are arrows now.

Sections can be dragged. The handle is a grip in the block bar, not the block
itself: making the whole container draggable swallows text selection inside its
own textarea, so you could no longer select a word to bold it.

The type palette lived once, at the bottom of the entire guide. Adding a step in
the middle meant adding it at the end and walking it up one press at a time.
There is now an insert point in every gap - and at the end, so there is one way
to add a section rather than two.

Linking to another guide or a KB article meant knowing its slug or id by heart.
A picker searches both by title and writes the markdown link at the cursor. It
offers published guides only - never send a reader to a draft - and it stores an
in-app path rather than an absolute URL, so the link survives the site being
reached by another hostname or mounted under a subpath. The knowledge base is a
separate plugin that a site may not have installed; its absence costs nothing
but its results.
2026-08-27 09:25:45 -04:00
cproudlock
2ad2bd1b90 Sidebar sections are DECLARED, not guessed from a position number
The section headings were derived from position ranges: 10-29 became Assets,
30-49 became Information. A number is for ORDER; it was never a statement about
meaning, and using it as one meant a plugin landed in whichever bucket its
number happened to fall in.

That is how USB Devices - an asset - came to sit under Information beside
GE-Enforce and Tech Tools, and 3D Parts with it. Nobody chose that. It also
meant two plugins picking the same number silently reordered each other:
Warranties and Guides both held 8, the tie broke on plugin load order, and
Guides rendered under Warranties rather than under Slides where it was asked to
go. Warranties moves to 9.

Each nav item now names its section and the renderer groups by what was
declared. An item with no section stays in the unlabelled group at the top,
which is the safe default: it appears, it is simply not filed.

Assets gains USB Devices and 3D Parts. GE-Enforce and Tech Tools move to a new
Fleet section - they are neither assets nor reference material, and calling them
Information was only ever an artefact of their position numbers.

Also: sorting used `position || 99`, and 0 is falsy in JavaScript, so Dashboard -
the only item that uses 0 - sorted to the end of its own group.
2026-08-27 09:25:27 -04:00
cproudlock
ab5b48e3f4 Put the USB kiosk in the sidebar next to the parts kiosk
Some checks failed
CI / naming (push) Successful in 3m12s
CI / frontend (push) Successful in 1m55s
CI / migrations-mysql (push) Failing after 7m27s
CI / backend (push) Has been cancelled
The kiosk route lives outside AppLayout, so nothing linked to it - a full-screen
page with no way in from the app is a page nobody finds. It joins the Displays
section beside Parts Kiosk, TV Slideshow and the Shopfloor Dashboard, gated on
the route existing so a build without the USB plugin does not advertise it.
2026-08-27 08:56:05 -04:00
cproudlock
95ad4f1ef3 Docs: the USB kiosk, its endpoints and why the list costs a badge
Some checks failed
CI / naming (push) Successful in 1m7s
CI / frontend (push) Successful in 1m7s
CI / migrations-mysql (push) Failing after 3m22s
CI / backend (push) Has been cancelled
Changelog, the plugin table, the three kiosk endpoints in the API inventory,
and regenerated openapi.json / PROJECT-MAP.
2026-08-27 08:14:02 -04:00
cproudlock
7c47ae1f7c USB kiosk: take and return a drive without a ShopDB login
The people who actually take USB drives are standing at the cabinet, and most
of them have no account here. Check-out and check-in were gated behind a login
that was not doing much anyway: both modes already attribute the action to a
scanned BADGE rather than to the signed-in user, so the login was gating the
screen, not the record.

/usb-kiosk is a full-screen touch page modelled on the parts kiosk, off unless
a site sets usb_kiosk_enabled - every route 404s until then, rather than 403,
so a disabled kiosk does not advertise that there is a door here. It works
against the self-hosted tables or an existing cmmc_usb database.

THE LIST IS THE TRADE. These drives carry no barcode, so unlike the parts kiosk
there is nothing to present and the screen has to offer a list - which exposes
more than a scan-first design, where you had to already hold a drive to learn
anything. So the list is not anonymous: an SSO comes first and must resolve to
a real person before any drive is named, which is the same bar taking a drive
already had to clear. It is a POST so the badge stays out of proxy access logs.
The main screen shows the caller's own drives, what is free, and a COUNT of
everything else; holders of other people's drives appear only behind a second
deliberate tap, so nobody reads the whole cabinet off a wall panel in passing.

A badge that resolves to no employee is REFUSED, unlike the signed-in route
which creates the user row. A checkout nobody can trace to a person defeats the
tracking, and at a kiosk there is no operator standing there to vouch for it.

Returning somebody else's drive asks first and then records the badge that was
SCANNED, so the log says who actually handed it back. That is the honest record
and it needs no change to a schema this app does not own. Refusing a mismatch
outright is a setting but not the default: a drive left behind by somebody on
leave would otherwise be unreturnable at the kiosk, which just moves the problem
to a person with a login.

The authz sweep caught these writes, twice - once for the two mutations and
again for the two list endpoints. Both are exempted with the reasoning written
out beside the parts-kiosk entry rather than silenced. The list endpoints are
POSTs that change nothing.

Verified by driving the real page: SSO on the keypad, tap Take, database moves.
2026-08-27 08:14:02 -04:00
e48505dcc7 ci: drop the git-crypt .env before running flask
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 3m43s
.env is committed git-crypt encrypted. Without the key the checked out
file is the raw encrypted blob, and python-dotenv fails reading it as
UTF-8 with "invalid start byte". The migrations job already provides
DATABASE_URL, SECRET_KEY and JWT_SECRET_KEY through env:, so removing
the file is enough. Previously masked by the job dying earlier on a
missing mysql client.
2026-08-27 01:47:24 +00:00
bb3e482bc3 ci: allow manual dispatch
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Failing after 15s
CI / backend (push) Has been cancelled
Gitea exposes no rerun endpoint in its API, so retrying a run after a
runner-side change otherwise requires pushing a commit that changes
nothing.
2026-08-27 01:45:24 +00:00
e72fe1ec7a ci: reach the mysql service by name, not through localhost
Some checks failed
CI / backend (push) Waiting to run
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Failing after 15s
A service container is reachable on 127.0.0.1 only when the job runs
directly on the runner host, which is how GitHub-hosted runners work and
how the old host-executor runner worked. Under a container-mode runner
the job is itself a container on the services network, published ports
land on the runner host rather than the job's own localhost, and the
service answers to its name.

Fixes: pymysql.err.OperationalError (2003, "Can't connect to MySQL
server on '127.0.0.1' ([Errno 111] Connection refused)").
2026-08-27 01:40:22 +00:00
702861f1f9 ci: pin Python 3.14 and stop depending on the runner's environment
Some checks failed
CI / naming (push) Successful in 28s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Failing after 15s
CI / backend (push) Successful in 4m7s
Three failures, all from the workflow inheriting whatever the runner
happened to have rather than declaring what it needs.

- backend ran pytest without installing it. pytest is in
  requirements-dev.txt, which nothing installed, so the step died with
  "No module named pytest".
- Both Python jobs used the runner's system interpreter. The old host
  executor had a matching Python by accident; a container image has
  3.10, where alembic additionally requires tomli. requirements.txt is
  hash-pinned and compiled on 3.14, so tomli has no hash and pip refuses
  the whole install under --require-hashes. 3.14 matches the Dockerfile,
  the README and the .github workflow.
- The utf8mb4 step shelled out to the mysql client, which is not in
  every runner image. pymysql is already a runtime dependency.
2026-08-27 01:37:39 +00:00
cproudlock
024751896e Keep the site-literal patterns out of the published copy
Some checks failed
CI / naming (push) Successful in 27s
CI / backend (push) Failing after 35s
CI / migrations-mysql (push) Failing after 54s
CI / frontend (push) Successful in 1m5s
The comment explaining the concatenation fix spelled the example hostname out
in full, which tripped the publication scrub - the same trap the surrounding
comment warns about, since the scrub greps this file for the very strings the
patterns are assembled from. Reworded with a neutral example.

Re-verified after the reword by planting a violation and watching the gate fail.
2026-08-26 15:08:37 -04:00
cproudlock
90c3c3102b Docs: guides in the indexes, the stalest-kind rule, and the changelog
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
Guides was shipped without an entry in PLUGINS.md or START-HERE, so a site
adopting the product had no way to find out it exists.

BACKUP-KINDS.md described a host's badge as coming from the most recently
confirmed kind. It has not worked that way since the fleet report was reworked:
the freshest revision per (host, kind) is taken, and then the STALEST of those
kinds decides, because reporting the freshest would let a healthy NTLARS backup
mask a part-marker capture that stopped a month ago. Stalest among kinds that
can be JUDGED - a kind never confirmed is unmeasurable, not stale, and treating
it as the worst value made a host with a working backup show no badge at all.

The backups row also still advertised UDC and CMM kinds that do not exist.

api-inventory, openapi.json and PROJECT-MAP regenerated.
2026-08-26 15:06:20 -04:00
cproudlock
a9289ee670 printedparts 0003 was an unguarded ALTER, and no test could see it
Unlike its twenty siblings it ran a bare add_column plus create_index, so
`flask plugin upgrade-all` failed with "duplicate column name: gagelabtag" on
any database whose tables were built by create_all and stamped nowhere - and a
chain stops at its first error, so 0004 never applied either.

The index is guarded by COLUMN SET rather than by name: create_all names indexes
by SQLAlchemy's convention and the migration by this repo's, so a name-keyed
guard would have added a second index over the same column instead of skipping.

The test that should have caught it was restricted to plugins whose whole chain
is a single baseline, on the reasoning that a longer chain meets a second
problem - an ALTER whose column is already present - that guarding a create_table
does not fix. True, but the conclusion was wrong: the answer is to guard the
ALTERs too, not to stop testing the chains that have them. It now covers every
post-cutover chain, and was confirmed to fail against the unguarded migration
before the guard went in.
2026-08-26 15:06:11 -04:00
cproudlock
dffa2082be Backups: stop storing credentials, and fix a dedup chain that never matched
The NTLARS/DNC registry carries an FTP password in cleartext and it was stored
verbatim. That put it in the backuprevisions table, on the revision panel which
renders values, and in the download route which hands back a ready-to-import
.reg - so anyone holding backups.view could read it off a web page.

Redaction happens at PARSE, so the secret never reaches the database at all.
Redacting on display would have left it at rest and one query away.

What is deliberately NOT done is dropping the value silently. This codec already
refuses to discard an unparseable value, precisely because a backup that looks
complete and restores a machine with a setting missing is the worst outcome. A
redacted value keeps its name, is listed in the projection, and renders as a
COMMENT naming what must be set by hand. Emitting a placeholder as the password
would produce a machine that looks restored and cannot connect, and the
technician would go debugging the network.

scripts/redact_backup_secrets.py scrubs revisions recorded before this, dry run
by default. It leaves contenthash alone: the hash identifies the configuration a
bay reported, so rewriting it would make every bay's next post look like a
change and insert a duplicate revision across the fleet.

Separately, sourcehostname is now normalized at ingest. samesource() maps '' to
IS NULL for the query while the insert stored the raw '', so rows written by a
client posting an empty hostname could never match their own next post: every
post inserted, the table grew without bound, and the history filled with
identical entries nobody could read. Whitespace did the same thing more quietly.
2026-08-26 15:06:04 -04:00
cproudlock
e60d6b74d8 Search: a domain that FAILED no longer looks like one that found nothing
Every domain catches broadly and returns no rows so one broken table cannot take
the whole search box down. That part is right. On its own, though, it makes a
DOWN domain indistinguishable from an EMPTY one: the technician sees no results,
is offered a spelling correction, and concludes the record does not exist. It
does - the search just could not look.

Failed domains are now named in the response and stated above the results.

The suggestion is deliberately still offered on a degraded run. Suppressing it
was tried first and is worse: a chronically unreachable domain, an external HR
directory say, would silently disable spelling help for every search for ever
with nothing saying why. Naming the failure is the honest fix; withholding a
second useful signal is not.

Guides search also moves to the published revision. It correctly limited itself
to published GUIDES, but matched and displayed title/summary/tags/blocksjson
from the Guide row - which is the WORKING copy. A published guide with
unpublished edits matched on wording that only exists in the draft, and the
reader opened it to find the word absent.
2026-08-26 15:05:54 -04:00
cproudlock
9d96006eae Audit log retention: a way to purge, off by default
The audit log is append-only and nothing ever removed a row, so it grows for as
long as a site runs. This site reached tens of thousands of pages, which is past
the point of being useful: nobody pages through that to find anything.

Retention is OPT-IN and the default keeps everything. An upgrade that silently
started deleting a site's audit history would be indefensible, so
audit_retentiondays defaults to 0 and nothing is removed until somebody sets a
number. The Site & Facility card shows the log's size and exactly how many
entries a purge would delete BEFORE anything is pressed.

Deleting happens in committed batches. That is not an optimisation: one DELETE
over a million rows builds a single enormous transaction and holds locks across
the table while it runs, which on this server means blocking the collectors
posting inventory. The API caps its batch count so the request cannot outlive a
proxy timeout, and a run that stops early reports how much is left rather than
claiming it finished - a purge reporting success over 900k remaining rows would
be the same silent half-failure this codebase keeps getting bitten by.

`flask audit stats` and `flask audit purge` for the server, the latter reporting
by default and deleting only with --apply, with no batch cap because it is not
behind a proxy and a purge that stops halfway leaves the operator guessing.

audit.purge is a separate permission from audit.view. Reading the log and
destroying it are different acts, and plenty of people need only the first.

Chunking selects a page of ids and deletes those rather than using
DELETE ... LIMIT, which is MySQL-only while the suite runs on SQLite.
2026-08-26 15:05:43 -04:00
cproudlock
3c68ac2850 ADR-015: the site-literal check was matching nothing, and had been all along
The patterns are assembled from fragments so the published script does not
contain the strings it greps for. They used the apostrophe-escape form
('a'\''b'), which EMBEDS a literal apostrophe, instead of the concatenation form
('a''b'), which joins them. Every pattern therefore carried a stray quote in the
middle and could never match a line of real code, so the check passed everything
from the day it was written.

Verified the only way that means anything: plant a violation, watch it fail,
revert. Fixed, it immediately found 25 real hits.

Among them, in seven places, the default ServiceNow URLs - one organisation's QA
tenant, shipped with the integration ENABLED. Any site that had not configured
ServiceNow still rendered ticket links, pointing its technicians at a tenant
they cannot reach; an adopting site would have been pointed into somebody else's
instance entirely. There is no sensible cross-site default for "which ServiceNow
is yours", so the URLs ship blank and the integration ships off, matching the
Zabbix block that got this right.

A named SaaS tenant is as site-specific as a hostname, so it joins the patterns.
Colocated .spec.js files are excluded the same way tests/ already is: sample data
naming a facility is not a shipped literal.
2026-08-26 15:05:30 -04:00
cproudlock
cebb3e9e44 Guides: make images render, and stop drafts leaking to readers
Images were a broken icon for every user. The stored URL was bound straight
to <img src>, and a plain image request carries no Authorization header while
this app keeps its JWT in one, so every fetch was a 401. They are now fetched
through the API client, which also fixes the stored '/api/...' path under a
subpath mount where the API is not at /api at all. A failed image says so in
words rather than rendering as a broken icon, and the editor's own thumbnails
go through the same path - an author needs to see the screenshot they pasted.

Revision and image routes gated on their parent guide. Both were gated on
guides.view alone, and revision ids are sequential, so being able to read one
guide was enough to walk the ids and read every unpublished draft on the site -
the opposite of what docs/GUIDES.md promises. An image in a PUBLISHED guide
still serves to any reader; one that only appears in a draft does not.

The guide list, the tag chips and search now match the PUBLISHED text rather
than the working copy. Matching the working copy handed readers a hit on
wording that is not in the page when they open it.

The slug no longer follows the title. It was regenerated on every save that
carried a title and the editor autosaves every three seconds, so fixing a typo
in a heading silently moved the URL and broke exactly the pasted links the
stored slug exists to protect.

publish() now aborts when the pre-publish save failed. It ran regardless, so
the author saw "Published version N" while readers got the previous revision.

Link rendering: text is escaped before formatting, so a captured URL arrives
carrying entities and was escaped a second time - every multi-parameter
ServiceNow and SharePoint link opened with &amp;amp; in it. Emphasis could also
reach inside an href, and '//host' passed as an in-app link when the browser
reads it as off-site.

Tests are now HTTP-level. The draft-invisibility rule lived entirely in routes
that no test executed, which is how three of them shipped serving drafts.
2026-08-26 15:04:09 -04:00
cproudlock
00773a77af Record the retire button in the changelog 2026-08-26 13:51:24 -04:00
cproudlock
dba8fc6071 Let somebody retire an asset without an API call
Five of the seven asset types had no way to take a record out of ShopDB from
the interface. Network devices and 3D printed parts had a button; machines,
PCs, printers and measuring tools did not, so a scrapped tool stayed in every
list and report until somebody went to the API.

The endpoints were already there and already correct. Every one of these
DELETEs sets asset.isactive = False and nothing more, so the record leaves the
lists while its calibration history, backups and relationships stay exactly
where they were.

Called "Retire" rather than "Delete" for that reason: the two existing buttons
say Delete, which reads as destruction of a record that is in fact kept and can
be restored by setting isactive back. The confirm says so too, because the
question somebody needs answered before clicking is whether the history goes
with it.

The button is guarded on the record having loaded, on the two pages whose
action row renders before the fetch resolves - measuring tools showed Retire
beside a hidden Edit while the tool was still loading, which would have called
delete with an undefined id.

printersApi had no delete method at all; added.

USB is deliberately left out. It retires through a status column in the
external CMMC database rather than isactive, has its own /retire endpoint, and
needs its own decision about what retiring means there.
2026-08-26 13:51:16 -04:00
cproudlock
580c0d0c80 Record the tab title and Guides packaging fixes in the changelog
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
2026-08-26 13:13:41 -04:00
cproudlock
ed5d64fa3e Name the record in the browser tab, not just the section
Every detail page read "West Jefferson ShopDB - Machine". Two tabs on two
different machines were indistinguishable, a bookmark said nothing about what
it pointed at, and a link shared into Teams previewed as the section rather
than the thing.

The router cannot fix this alone: afterEach runs before the view has fetched
anything, so it only ever knows the KIND of page. So a detail view names its
record once the record arrives, and both writers go through
composables/pageTitle.js.

THE ORDERING IS THE WHOLE PROBLEM. The router awaits the site settings to build
the "<facility> ShopDB" prefix, so on a cold load it finishes AFTER a view that
fetched quickly and would overwrite the good title with the generic one. The
route's fullPath is claimed before that await, and the generic write stands
down when a record title already belongs to the route being landed on. The
reverse case is handled too: a view that names its record BEFORE the facility
name arrives composed against the placeholder prefix, so the late resolve
rewrites it rather than leaving "ShopDB - CSF16" on a site called West
Jefferson. Both are pinned by tests, because both fail only under timing.

Covers machines, PCs, printers, network devices, subnets, measuring tools, USB
devices, printed parts, applications, employees, knowledge base articles and
guides. Printers reuse the page's own displayTitle, which already discards the
literal name "NONE" a discovered queue can carry, so the tab and the heading
agree.

A record with no usable name, or one that failed to load, keeps the section
name: "Machine" beats "Machine 4192" for somebody scanning a row of tabs.
Long names are cut at a word boundary - knowledge base articles are named with
a whole sentence of description, which a browser otherwise truncates mid-word.

Reports gained meta.title, which the router already supported and nothing used.
That also fixes "Pc Relationships", which the name-derived spelling produced.
2026-08-26 13:13:30 -04:00
cproudlock
e2a226e8b0 Let Guides reach a site, and reach core the way every plugin must
Two faults the guides plugin shipped with, both caught by gates that were
already there.

deploy/site-profile-universal.json never listed `guides`. That file is the
profile the released Windows installer is built from, so the wizard would not
have offered Guides at all - and worse, `flask plugin prune-schema` drops the
tables of any plugin absent from the profile at provisioning (ADR-014). A site
installing the next release would have had its guides table dropped.

plugins/guides/models/guide.py imported shopdb.core.models.base directly.
Plugins reach core only through shopdb.api or shopdb.plugins.base (ADR-001),
and shopdb.api already re-exports BaseModel, so this is the same symbol by the
supported path.

The suite was red on main because of these two; it passes again.
2026-08-26 13:13:16 -04:00
cproudlock
03094e93b2 Let Guides be switched off in Settings > Search like every other domain
Global search has per-domain toggles (search_<type>_enabled), enforced
server-side and edited from Settings. Guides answered the search box but had no
toggle, so it was the one domain nobody could turn off - and the omission would
only have surfaced when somebody went looking for the switch and could not find
it.

Missing key still means enabled, so nothing changes until somebody decides
otherwise.
2026-08-26 12:42:23 -04:00
cproudlock
bd0e9695e5 Document the guides API, and move Guides beside Slides
The repo already enforces that every served route appears in
docs/api-inventory.json, and the guides endpoints did not - so the parity test
was failing. They are now listed with auth, params and a working example each,
beside knowledgebase because the two get read for the same reason.

docs/GUIDES.md covers what the inventory cannot: the block schema and why it is
blocks rather than HTML, the id rule that makes a tick rememberable, what the
server refuses and why validation happens at the door, the working-copy /
revision / published-pointer split that stops a live procedure changing under
somebody following it, and a worked example of creating and publishing a guide
from a script.

It also states plainly what does not exist yet - no importer, orphaned images
never pruned - and what converting existing documents will actually be like:
structured HTML and Markdown map onto block types nearly one for one, while a
plain-text runbook lands as paragraphs and needs marking up by hand afterwards.
Generated ids have to stay stable across re-imports, or every re-import resets
readers' progress.

Guides moves to position 8 in the sidebar, beside Slides, rather than down among
the reference lists in Information. A guide is something somebody goes and does
something with, not a catalogue they look things up in.
2026-08-26 12:39:42 -04:00
cproudlock
2c016de59e Guides answer the search box, and proofing catches what spellcheck cannot
A guide was invisible to global search. Core search has a hardcoded block per
domain rather than a hook, so a new plugin stays unreachable until someone adds
one - which is the same shape as the gap this test file already documents for
USB devices and printed parts: records with a list page, labels in the world,
and no way to find them from the search box.

Guides are searched by title, summary, tags AND body. The body matters more here
than elsewhere: people look for a procedure by something written halfway down it,
not by a title they never read. PUBLISHED ONLY - a draft is deliberately
invisible to readers, and search is exactly where that rule leaks if nobody
writes it down. The test proves it with a draft sharing a tag and a body word
with a published guide, so a missing filter would show up as a ride-along.

PROOFING catches what a spellchecker cannot, because none of these are spelling:
a smart quote in a command so it will not run, a checklist item with no text so
a step is silently missing, a heading left with nothing under it after a
reorganise, an image with no alt text, a TODO published by accident. Warnings,
never blocks - somebody publishing at 14:40 with one empty bullet should be
told, not stopped, and a procedure that exists imperfectly beats one nobody
wrote.

The smart-character issue reports once per block with a one-click fix, rather
than once per offending character: an opening and a closing quote are one
problem, and listing both makes the panel look like it is nagging.

The character table itself is written as escapes. The style gate forbids
non-ASCII in source, and a file whose job is detecting smart quotes cannot
contain any - which the gate caught, correctly.
2026-08-26 11:27:10 -04:00
cproudlock
a52437b40b Make writing a guide less like operating an editor
Seven changes, all aimed at the same thing: think about the procedure, not
about blocks.

Live preview beside the editor, rendered with the SAME component readers get,
so what an author sees is what will be published - a separate preview
implementation would drift within a month.

Formatting buttons wrap the selection, so nobody needs to know the markup is
Markdown. New blocks and pasted images land AFTER the block being worked on
rather than at the bottom of the document, which removes the scroll-back that
made adding a screenshot mid-guide annoying.

Enter in a list makes the next item and focuses it, the way every other list in
every other tool behaves; backspace on an already-empty item removes it and
steps back up. Typing a ten-item checklist no longer needs the mouse.

Templates, because the blank page is the real barrier to somebody writing a
procedure down at all. The shapes are lifted from the KB articles the team
already writes, so guides written here read like the ones written before them.

Autosave on a three-second debounce with an honest status line, safe precisely
because the server dedups on content - an autosave that changed nothing writes
no version. Leaving with unsaved work now asks first.

CLIPBOARD: the code block no longer writes to the clipboard. A page that
programmatically copies a shell command is the exact shape of a ClickFix attack,
where malware plants a command in your clipboard and talks you into pasting it
into Run - and uBlock Origin blocked ours, correctly, because it was writing
PowerShell on click. The button now SELECTS the code and says to press Ctrl+C:
same convenience, decision back with the person, and nothing for security
tooling to object to.

AUTOCORRECT is on for prose and off for code and table cells. A code block that
autocorrects produces a guide telling somebody to run a command that does not
work - smart quotes replacing a straight one, an autocapitalised cmdlet - and it
still LOOKS right to the author. The person at the bay gets the syntax error.
2026-08-26 11:16:19 -04:00
cproudlock
cc1f8ff9fd Print a guide light, whatever the screen theme is
Printing a dark-mode page lays a full page of toner down for the background and
knocks the text out of it in white. On a shopfloor printer that is a cartridge
for a handful of guides, and the result is harder to read than the plain
version. Somebody would have found this the expensive way.

The theme tokens are redefined for print only, in an unscoped block because
overriding them has to reach :root. Every guide element already renders through
those variables rather than literal colours, so this is a dozen lines instead of
a rewrite - the same discipline that made dark mode work without dark-mode code.

Backgrounds are dropped rather than printed grey: a callout is identified by its
rule and its heading, which cost nothing. Checkboxes print EMPTY even where they
are ticked on screen, because a printed guide is a worksheet somebody carries to
the bay, and one person's progress is not part of the procedure. The footer
gains the print date beside the version, so a stale copy pinned to a noticeboard
can be spotted.

Also corrects the model and migration found by running this against a real
database rather than SQLite: BaseModel contributes createddate, modifieddate and
isactive to every table and the migration never created them, so the first read
failed on unknown columns; the model had also declared duplicate timestamps on
top, now removed with the API field names kept stable. And the slug is 191
characters, not 300 - it is UNIQUE-indexed, MySQL caps an index key at 767
bytes, and utf8mb4 is 4 bytes per character.
2026-08-26 11:07:06 -04:00
cproudlock
38d1e367fc Guides: write a procedure in ShopDB, not beside it
The knowledgebase plugin is a link registry - a description, a URL and some
keywords pointing at a document held somewhere else. That leaves the documents
themselves as HTML files on a share: no search, no history, no way to know
whether the copy pinned to a noticeboard is current, and no way for a technician
to tick off where they are.

A guide is stored as an ordered list of typed blocks rather than a blob of
HTML, because three of the four things this feature exists for depend on it. A
tickable step needs a stable id to be remembered; dark mode needs semantic
markup rather than pasted colours; and print needs to know where a step begins
so it does not split one across a page. Free HTML gives none of that and lets
pasted styling fight the theme.

Inline text is a restricted Markdown subset - bold, italic, inline code, links,
line breaks - escaped BEFORE formatting, so nothing an author pasted can reach
the DOM as markup. No fonts, sizes or colours: emphasis is semantic and the
theme decides how it looks. That is what makes a guide written next year match
one written today, and dark mode work without an author thinking about it.

DRAFTS AND VERSIONS. The guide row is the working copy; every save that changes
something writes an immutable revision; and a separate pointer says which
revision READERS get. Without that pointer, editing a live procedure changes it
under a technician who is halfway through following it. Publishing promotes an
existing revision rather than snapshotting, so a publish with unsaved edits
cannot ship a version nobody reviewed. Restoring writes a new revision with old
content, so the record of what people were told in between survives.

Saves dedup on a content hash covering title, summary, tags and blocks, so an
autosave that changed nothing writes no version - otherwise the history fills
with identical entries and hides the three edits that mattered.

Progress lives in the browser, not the database: shopfloor PCs share a login, so
per-user ticks would show one technician's progress to the next person on that
bay, which reads as "already done". Keyed by guide and version, because step 4
of version 3 is not step 4 of version 2.

Five permissions, and the split between edit and publish is the point: a
technician can write a procedure up while somebody else decides it is fit for
the floor. create-without-edit works too - an author may edit their own guide
until it is published, after which other people are following it.
2026-08-26 10:24:26 -04:00
cproudlock
28af92aaec An unmeasurable backup must not hide a working one
CI caught this: judging a host by its stalest kind treated a never-confirmed
revision (lastseenat NULL) as the worst case, so it won. The verdict for a NULL
confirmation is tri-state None, which renders as NO BADGE - so a host with a
healthy ntlars backup and one backfilled row showed nothing at all. Hiding a
working backup is the one thing that badge must never do.

A NULL confirmation is not stale, it is unmeasurable. The stalest kind is now
chosen among kinds that can actually be judged, which is the rule staleness.py
already applies: a chain becomes measurable the first time its PC posts and says
nothing until then. If every kind is unmeasurable the host still reports which
kind it has, with a None verdict and no badge.

The test that caught it pinned the old newest-wins behaviour, which part markers
made wrong; it now pins stalest-measurable-wins and keeps its NULL clause, which
was right all along and is what found the bug.

Also worth recording how it was missed: the change was verified against
test_geenforce_reporting.py and test_backups.py, and this assertion lives in
test_geenforce_publish_gate.py, which was never run.
2026-08-26 10:07:51 -04:00
cproudlock
52b9fa1ec1 Keep the site hostname out of the repair script test
The publication scrub gate rejects internal references, and a real site
hostname in a test is still one in the published repo. The rewrite does not
care which server it is.
2026-08-25 15:00:56 -04:00
cproudlock
315d4f46ce Repair backup revisions that recorded a mapped drive
Snapshots written before the client was fixed stored "W:\backup\partmarker\..."
as their location. That mapping exists only inside the SYSTEM logon session,
only during an enforcement cycle, and only on the bay that wrote it, so the path
recorded against a marker resolved to nothing for the person most likely to open
it.

The script rewrites backuprevisions.sharepath and nothing else - no revision is
created, deleted, re-filed or re-hashed, and no file on the share moves, since
the bytes are already where the UNC will point. Dry run by default.

It matches on the drive letter alone and leaves everything else untouched,
including paths that are already UNC and paths on another drive. This repairs a
specific known mistake rather than normalising whatever it can reach, which is
the version of this script that would eventually rewrite something it should
not have.

rewrite() is kept pure so the decision of WHICH paths to touch is testable
without an app context; the tests cover the already-UNC, other-drive, empty,
case-difference and trailing-separator cases.
2026-08-25 15:00:40 -04:00
cproudlock
500f7d6ad6 Give the backups plugin a settings page, and show where a snapshot is
The plugin shipped five settings and no page for any of them. The core settings
catalog is a hardcoded list a plugin cannot extend, so backups_shareroot and the
collection interval were reachable only through the API or a flask shell on the
server - which is not a configurable setting in any useful sense, and it is how
today went: the setting simply was not there to set.

The page is contributed the ADR-010 way rather than by editing core - a card
from get_settings_cards plus a route in the plugin's own routes.js, both of
which disappear when the plugin is disabled. Filed under Machines, next to
Machine Types, because that is where the assets these back up live.

It also states the two things the settings do not say for themselves: that
health is judged on when a backup was last CONFIRMED rather than when it last
changed, and that retention prunes database revisions only - files on the share
stay, so enabling it on a share-backed kind leaves orphaned snapshot
directories.

Snapshot paths are now shown as full UNC text in both places: a Snapshot row on
the Marker Contents card, and the existing path on the history page, which
gains a copy button. Deliberately NOT a link - browsers refuse to navigate from
an https page to file://, so a clickable path looks actionable and silently
does nothing, which is worse than no link. Copy, then paste into Explorer.

The path comes off the revision, never composed from the configured share root:
a snapshot written before the layout changed still lives where it was put.
2026-08-25 14:53:04 -04:00
cproudlock
ee208808f4 Judge a PC's backup by its stalest kind, not its newest
Until part markers, a PC reported exactly one backup kind and "newest revision
wins" was the same thing as the truth. A marker bay reports two: ntlars for the
machine's DNC settings, partmarker for the controller's patterns.

With newest-wins, the daily ntlars post keeps the badge green while the marker
backup has been failing for a month. The failure shows only on that marker's
asset page, which is not where anyone looks to find out something stopped - and
"is this still being checked" is the one question this badge exists to answer.

So a host is green only when everything it backs up is current, and the badge
names the kind that is not. Freshest revision per kind first, because
lastseenat only ever moves on the newest revision of a chain; judging every row
would report a healthy, frequently changing config as permanently stale.

Known consequence, and the right direction to err: a device retired from a PC
leaves its last revision behind, so that host stays stale until the revision is
pruned. It complains about a backup that genuinely is not running rather than
staying quiet about one.

The dashboard card needed no change - it already lists every stale chain
independently, so a stale partmarker chain was never hidden there.
2026-08-25 13:56:52 -04:00
cproudlock
512cc9be76 Let a share-backed backup say what is in it
A 'share' kind keeps its bytes on the file share and gives ShopDB only a hash
and a UNC path. That is right for an opaque vendor file, but it means the app
cannot answer the question a tech actually arrives with - what does this part
marker hold, and which pattern disappeared last month. Reading the share from
the app server is not an option: the download routes refuse to mount it on
purpose, because a permissions slip there becomes an unexplained empty panel.

So the collector now accepts an optional manifest alongside the hash: the
index, not the content. Names, sizes, per-file hashes, and the marking system
each pattern was authored for. Advisory throughout - a client that sends none
behaves exactly as before, and dedup still keys on contenthash.

The marker asset gains a Marker Contents card built from it: counts by type,
the controller family, and a diff against the previous snapshot in the same
source chain. Removed files are named rather than counted, because "1 removed"
only tells someone to go and look. Files edited in place are named too - the
file set can be identical while the content hash moved, and reporting no
change there would contradict the reason the revision exists.

The manifest is deliberately NOT covered by contenthash, which attests the
bytes on the share. A client that starts describing itself therefore posts an
unchanged hash, so a no-op backfills a manifest the row does not yet have -
otherwise the card would appear only on markers that had recently changed, and
stay hidden on every healthy one. The same no-op refreshes sharepath, so a
snapshot that moved on the share stops being pointed at where it used to be.

Manifests are validated at ingest rather than at render: a malformed one would
otherwise become the latest revision and fail every view of the asset page
until the content next changed, outliving the client bug that caused it.

sharedir() for the marker kind is overridden to the layout the site actually
uses, so what ShopDB displays and validates matches what the collector writes.
2026-08-25 13:06:49 -04:00
cproudlock
9d61e52ca7 Record the vendor naming cleanup in the changelog
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
2026-08-25 07:47:44 -04:00
cproudlock
23784b8199 Ship one name per vendor, and stop shipping a test row
Canonical names chosen by the site: Dell Inc., HP, Zebra. The rule is the same
in each case - keep the spelling that holds the entries - it just lands on a
different form per company. Dell Inc. carries 310 references and 33 models
against 1 apiece for Dell and DellInc.; HP carries 51 and 24 while HP Inc. has
none; Zebra carries 7 and 2 while Zebra Technologies has none.

So HP Inc. and Zebra Technologies are gone from the shipped catalog. Both were
empty rows that only ever offered a second way to spell a vendor already
present, and neither exists in this site's database at all - they would have
arrived as duplicates at a NEW site and nowhere else.

Also removed UnscopedLiveVendor, which is a test artifact. It reached the
catalog because export_catalog.py reads the live development database, so a row
left behind by a lab run was published as reference data and has shipped to
every site seeded since.

Vendors 51 to 48. Verified nothing referenced any of the three before removing
them, and the catalog still has no duplicates by any spelling.
2026-08-25 07:47:33 -04:00
cproudlock
790ff3de38 Correct the foreign-key count in the catalog changelog entry 2026-08-24 15:15:45 -04:00
cproudlock
8bf2b8515f Record the catalog dedupe commands and the seed cleanup in the changelog
Some checks failed
CI / backend (push) Failing after 11s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 8s
2026-08-24 14:49:48 -04:00
cproudlock
6106f4c5f4 Stop shipping duplicate vendors, models and operating systems
The seed catalog is what a new site inherits, so its duplicates propagate.
Removed the four space-stripped twins it carried: vendor 'DellInc.', model
'OptiPlex7080', and operating systems 'MicrosoftWindows10Enterprise' and
'Windows10', each of which had a correctly spaced original already present.

'OptiPlex5000' was NOT a duplicate - no spaced twin existed - so it was
renamed to 'OptiPlex 5000' rather than dropped, matching every sibling in the
list. Its vendor moved to 'Dell Inc.', which this site treats as the one
canonical Dell.

Vendors 53 to 51, models 128 to 127, operating systems 14 to 12. Verified
afterwards: no exact duplicate rows, no near-duplicates, no orphaned
model-to-vendor or supply-to-model reference.
2026-08-24 14:49:31 -04:00
cproudlock
d51127841d Merge catalog rows that are the same name typed two ways
The legacy data carried space-stripped twins: 'DellInc.' beside 'Dell Inc.',
'Windows10' beside 'Windows 10', 'OptiPlex7080' beside 'OptiPlex 7080'. They
rode into the shipped seed catalog, so every site that ran `seed catalog`
inherited them, and they stay invisible until somebody opens a vendor picker
and finds the same company listed twice.

Not a code fault. The collector strips only leading and trailing whitespace,
matches with ilike, and treats the OS list as lookup-only, so it can neither
have created these nor be creating more. They arrived with the classic-ASP
import, which holds the same three pairs today.

`flask catalog dedupe` merges only whitespace-and-punctuation twins, because
those are one name typed two ways. 'Dell' and 'Dell Inc.' normalise apart and
are left alone: that is a naming choice and only a person can make it. The
surviving row is the most referenced, tie-broken toward the spaced spelling,
which is the one somebody pressed the space bar for.

`flask catalog merge` makes that judgment call explicitly, and refuses to
invent a target that does not exist, so a typo cannot move rows into a new
vendor. `flask catalog rename` handles the case neither can reach: a stripped
name with NO twin, where there is nothing to merge into.

All three report and change nothing unless --apply is passed, and dedupe is
idempotent so it is safe in a deploy script.

References are discovered from the LIVE schema rather than listed in code.
There are 15 foreign keys into these three tables across 7 tables, and
machines.controllervendorid and printerdrivers.modelnumberid are not ones
anybody would enumerate by hand. Every reference is repointed before the row
is deleted, so nothing is orphaned, and a plugin adding a table is covered
without editing this command.
2026-08-24 14:49:24 -04:00
cproudlock
7fb6bdba07 Regenerate the project map for the new script tests
Some checks failed
CI / backend (push) Failing after 10s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 8s
2026-08-24 14:32:31 -04:00
cproudlock
13d951ea77 Record the maintenance-script .env fix in the changelog
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / backend (push) Has been cancelled
2026-08-24 14:32:18 -04:00
cproudlock
7d7caba56b Let a maintenance script find the database the site actually uses
Running `python scripts/export_catalog.py` on a deployed site died with
1045 "Access denied for user 'root'@'localhost'", naming a user that site
does not have.

Nothing loads .env for a plain script run. wsgi.py calls load_dotenv() for the
served app and the flask CLI loads .env for itself, so both reach the real
database; a bare `python scripts/...` gets neither. DATABASE_URL then stays
unset, FLASK_ENV defaults to development, and config.py falls back to a
hardcoded root@localhost URL.

The read-only failure above is the harmless version. Six of these seven
scripts WRITE - backfill, reclassify, retype, collapse, refile, import - and
on any box where those fallback root credentials happen to resolve, they would
have silently rewritten the wrong database with no error at all. That is also
why this survived: the dev .env points at root, so the fallback matched what
the developer expected and nothing ever looked wrong here.

All seven now load .env from the repo root, resolved from __file__ rather than
the working directory, since these are run by hand during a deployment and
rarely from the repo root.

tests/test_maintenance_scripts.py pins it for any future script: every script
that calls create_app must call load_dotenv, before create_app rather than
after it, and with an explicit path rather than a cwd-relative search.
2026-08-24 14:32:11 -04:00
cproudlock
5f915310c9 Point the lab's reference table at main too
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 header stopped claiming the deleted feat/printedparts-plugin branch; its
"where to look" table at the bottom still did.
2026-08-24 14:07:09 -04:00
cproudlock
e90176d639 Record the search-result and scaffolder fixes in the changelog
Some checks failed
CI / backend (push) Failing after 11s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 11s
CI / migrations-mysql (push) Failing after 7s
2026-08-24 14:05:48 -04:00
cproudlock
90da0a1c09 Write down the plugin nobody documented, and stop pointing at a deleted branch
PLUGINS.md listed 14 of the 15 bundled plugins. `backups` had no row: the
per-asset configuration backups the collector takes off shopfloor machines,
kind-pluggable, with view and download as separate permissions because a .reg
is a restorable artifact. Added, with the pointer that separates it from
BACKUP-RESTORE.md, which is the database and a different subject.

PLUGIN-LAB-PRINTEDPARTS said the finished implementation lives on branch
feat/printedparts-plugin. That branch was deleted in the v0.8.0 cleanup; only
the 17 lab-stage tags survived, and they are enough. Says so now, and warns
that the tags are a record of contract 0.11-0.13 rather than today's
conventions. Its manifest example carried core_version >=0.12.0 against a
shipped >=0.16.0.

The printer-assignment proposal still said the client script is not built. It
shipped and released, across v0.11.3 and v0.12.0, and the collector half that
contradicted the assigned-vs-observed split was retired in adc7ef4.

PROJECT-MAP regenerated for the test count.
2026-08-24 14:05:24 -04:00
cproudlock
7b79a444b8 Scaffold a plugin's frontend into the plugin, so it can be pruned
`flask plugin new` wrote views to frontend/src/views/<name>/ and a route file
to frontend/src/router/routes/<name>.js. Every one of the 16 bundled plugins
keeps its frontend in plugins/<name>/frontend/ instead, staged into
.plugins-staged by stage-frontend.mjs and aggregated into routes.gen.js.

It was not visibly broken, which is why it lasted: the router still globs
./routes/*.js during the Phase 4 dual-location transition, so a scaffolded
plugin's pages loaded. What a new plugin author silently lost was ADR-013 lean
builds. Pruning works off the staged tree, so a frontend in the core tree
cannot be excluded from a site build, and deleting the plugin strands its
views behind.

The templates for the correct layout already existed; only the output paths
were wrong. Also fixed the imports they carry: core imports now use the @
alias (@/api, @/components/...), which resolves both before and after staging,
where the previous ../../api was correct only in the old location. Sibling
views stay relative and travel with the route file.

frontend_dir is gone from scaffold_plugin. There is no second output tree to
point at, and a plugin developed in its own repo now gets the same frontend as
a bundled one rather than being skipped.

Proven end to end: scaffolded a throwaway plugin, confirmed it stages, builds
real chunks, and that SITE_PLUGINS=network prunes it, which is what the old
layout could not do.
2026-08-24 14:05:15 -04:00
cproudlock
eecb0595dd Open the record a search result found, not the list it sits in
A NOTICE result linked to /notifications, so clicking the row search had
already identified dropped the reader on the list page to find it again by
hand. The id was in the payload the whole time; only the URL ignored it.
`/notifications/<id>` is the existing notification-detail route.

The subnet searcher had the same defect two functions away: an IP search
resolves which subnet the address falls inside, then linked to /network, the
hub. It now links to /networks/<id>, the subnet detail route.

Every other search result type already carried its id. These were the only
two that did not.
2026-08-24 14:05:05 -04:00
cproudlock
adc7ef43f9 Stop the collector being able to set a printer assignment
Some checks failed
CI / backend (push) Failing after 11s
CI / naming (push) Successful in 9s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
The printer feature rests on one rule: a collector report is OBSERVED state and
never ASSIGNED state. If a drifted bay's own state can become what it is told to
install, enforcement stops meaning anything, because a bay that installed the
wrong printer makes itself right simply by reporting it.

_sync_printer_links broke that rule. It ran on every collector PC push and, when
the payload carried defaultprinter or printers, wrote defaultprinter and
connectedto rows from the report - defaultprinter being one of the two types
_reconcile_edges is meant to own alone, from authenticated routes a human calls.

It was recorded as dormant. It was not. The collector schema ADVERTISED both
keys and the example client in COLLECTOR-INTEGRATION.md set
$payload['defaultprinter'], so the documentation was recruiting callers for it -
while the same document, under "Observed is not assigned", promised that nothing
on this path writes a usesprinter or defaultprinter row. The document
contradicted itself and the code followed the wrong half. Nothing in the fleet
armed it, because the first-party reporter never sent those fields, but any
third-party client written from the docs would have.

Retired the function, its _sync_one helper and the now-unused
PRINTER_LINK_ORIGIN label, and removed both keys from the schema.

IGNORED, NOT REJECTED. Unknown payload keys are not validated, so a client
written against the old docs keeps pushing successfully and only its printer
keys stop doing anything. The response warns, naming the keys and pointing at
POST /api/collector/printers for reporting queues and seed-from-observed for
adopting them. Dropping them in silence would leave an integrator believing
assignments were still being written, which is a worse failure than the one
being fixed.

The docs were the larger half of this. The schema table rows are gone, the
"PC -> printer relationship sync" section is replaced by a note saying what
happens now to anyone still sending the keys, and the example client no longer
enumerates Win32_Printer at all - it points at Report-PrintersToShopDB.ps1,
which posts to the observed endpoint and posts NOTHING on a failed enumeration,
since an empty list would erase the host's rows.

test_collector_printers.py pinned the old behaviour, so it now pins the
opposite: the keys create no relationships, the push still succeeds and says
why, a payload without them stays quiet, a human-made default survives a report
naming a different printer, and the schema no longer offers the keys.
2026-08-24 11:25:02 -04:00
cproudlock
24435e6359 Let the warranty search find a machine number
Reported: the warranty page cannot be searched by machine number.

It was there to be found. _related_machine puts the machine's number in the
payload, and it does so for a stated reason - a shopfloor PC is bought,
warranted and replaced as a PC, but it is FOUND by the machine it drives, and
nobody walks the floor looking for an asset number. The table renders it as a
chip. The filter looked at vendor, service level, service tag and asset number,
and never at the machine. So the one identifier the feature was designed around
was the one thing the search box could not match.

This is how a field list goes wrong: a column was added to the table and not to
the filter, and nothing connects the two. Moved the field list into
warrantySearch.js so a test can see it, following controllingPc.js and
labelPages.js. Machine name is searched as well - somebody who knows the bay by
"the Makino" should not have to look up its number to find its warranty.

The specs also pin the case that made the old inline version fragile: a covered
asset whose machine is null, which is every printer and any PC nobody has linked
yet, must not throw. And a missing field must not match: String(undefined) is
'undefined', so a naive coercion makes a search for "und" hit every row with an
empty vendor.

Not changed, worth knowing: the covered asset's NAME is still not searched, only
its number. That looks like the same omission, but it is a wider change than the
report - the placeholder has always said "asset" without saying which half - so
it is left for a deliberate decision rather than folded in here.
2026-08-24 09:52:05 -04:00
cproudlock
b96637e1a1 Let a baseline meet a table that is already there
Some checks failed
CI / backend (push) Failing after 11s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 11s
CI / migrations-mysql (push) Failing after 7s
Reported: `flask plugin upgrade-all` failed the tools plugin with "table
securesendsecrets already exists".

Every post-cutover baseline emitted a plain op.create_table. That is correct
only while a table's existence and its plugin's Alembic stamp can never
disagree, and they can. Anything that builds tables from the models -
db.create_all(), which `flask db-utils create-all` calls - creates them and
stamps nothing. A run that creates a table and then fails before its stamp
commits leaves the same split, and re-running is the obvious thing to try. In
both cases upgrade-all meets existing tables with an unstamped chain and the
create is fatal.

Nothing caught it because the migration test DROPS the post-cutover tables
db.create_all() built before running upgrade-all. That arranges the one state
where an unguarded create works, and never the state the report came from. The
new test is the same sequence with the drop removed for the plugin under test.

create_table_if_missing and create_index_if_missing are in alembic_template.py
next to create_plugin_tables, which had the idempotency rule right and turns out
to be called by nobody - its docstring claimed every 0001 used it. Fixed the
docstring rather than rewiring the baselines onto it, because the reason they
hand-write their DDL still stands: the helper's per-plugin MetaData cannot
resolve a foreign key to a core table at CreateTable-compile time.

INDEXES ARE MATCHED ON COLUMNS, NOT NAME, and that distinction matters. The two
creators disagree about names - db.create_all() takes SQLAlchemy's default
convention (ix_securesendsecrets_token) and a baseline spells it the way this
repo's naming rules require (ixsecuresendsecretstoken). A name-keyed guard would
have called the index missing and added a second one over the same column, which
MySQL permits with a warning, so every affected site would have quietly grown
duplicates. The test asserts no table ends up with two indexes over the same
columns.

Applied to all nine table-creating plugin migrations, not just the tools
baseline that was reported: the four later migrations that create tables have
the same exposure, and geenforce, measuringtools, printedparts and backups are
the same defect waiting for a different site.

WHAT THIS DOES NOT FIX. It makes a baseline idempotent, not a whole chain. A
longer chain can still meet an ALTER whose column is already present -
printedparts adding gagelabtag to a table create_all built at head is the case
that showed up while testing. Guarding every op one at a time is the wrong
answer to that; a database built from the models is already AT head and its
chains want stamping rather than replaying, and there is no `flask plugin stamp`
today. Left alone deliberately rather than half-built, and the new test is
scoped to single-baseline plugins so it does not pretend otherwise.
2026-08-24 09:28:37 -04:00
cproudlock
de3c5aa562 Stop re-creating a controlling-PC link that was already there
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
Reported: moving machine 3016 on the map and saving answered "this
relationship already exists" and refused the save.

The map had nothing to do with it. Every save of the machine form re-POSTed the
controlling-PC link, and the server was right to refuse it - the row was already
active, and assetrelationships is unique on (source, target, type). Any edit to
a machine that has a controlling PC hit this; a position change is just the one
somebody happened to be making.

The form asked the wrong question. It used "do I know the existing link's id" as
a stand-in for "is there an existing link", and read that id from
rel.assetrelationshipid - a key the API has never emitted. to_dict serialises
column names and the column is relationshipid, so the read was undefined on
every load while controllingPcId was populated from the same relationship. The
two answers disagreed, and the disagreement was invisible: undefined is not an
error, it is just a falsy id.

That single conflation caused both failures. Saving anything re-created the
link, and CLEARING the controlling PC did nothing at all, because "no selection
and no known id" reads as "nothing to do" - so removing a machine's controlling
PC silently kept it. Only the first of those was reported, and the second is the
one somebody would have trusted.

Fixed by making the LOADED PC the authority on whether a link exists, with the
relationship id used only to delete a row. If that id ever goes missing again
the worst case is a link that cannot be removed, never a duplicate create.

The decision moved to controllingPc.js as a pure function so it could be pinned
by tests, following labelPages.js. Unchanged is now explicitly no-op rather than
delete-then-create: re-linking a machine to the same PC opens a window where it
has no controlling PC at all, and resolve_asset_position walks that edge, so a
failure between the two calls would drop the machine's inherited position along
with its link. The specs also cover a select handing back '42' against an API
that gave 42, which would make an untouched link look like a swap and reach the
same duplicate POST by another route.

Not the printer assignment, which was the first guess: PrinterAssignmentPicker
makes no relationship calls, and its save is wrapped in a catch that only logs,
so it cannot surface an error from this form at all.
2026-08-24 08:42:50 -04:00
cproudlock
ad9210b84a Write down the tool that was built, not the one that was tabled
Secure Send shipped on 21 August and nothing outside the code caught up. The
roadmap still listed it under Deferred as "tabled", and gave as the reason that
the tools plugin is browser-only by design with get_blueprint and get_models
both empty - which stopped being true in the same day's commits that gave it a
blueprint, a table, a migration chain, a permission and a setting. A reader
following the roadmap would have concluded the feature does not exist.

The generated API docs were fine: api-inventory.json and openapi.json both
carry /api/tools/secrets, so the contract gates did their job. What was missing
was every word written for a person, and that is the half where the security
properties live.

docs/SECURE-SEND.md is the new page. The parts worth having in prose rather
than inferring from the code: that a lost secret is lost to everybody including
an administrator, which is the same property as "a compromised database yields
nothing" seen from the other side; that the 4096-character limit is the entire
server-side enforcement, because a server that cannot read what it holds cannot
have an opinion about content, so raising it turns a password tool into an
unaudited file transfer; that redeeming is a POST because preview prefetch in
chat and mail clients would otherwise spend a one-view secret before the
recipient ever sees it; and that somebody should send a link to themselves
through their own mail or chat client and confirm the key survives after the #
before a real credential goes through it, because a product that rewrites links
can strip the fragment and nothing on this side can help.

securesend_allow_guests was in the database, on a settings page, and in no
document. It is now in CONFIG.md with what it actually does - an unauthenticated
write endpoint, rate limited per address, failing closed - rather than only its
one-line description.

Also corrected along the way: site_base_url was documented as scheme + host
while the code deliberately tolerates a subpath in it, and the bundled plugin
table in PLUGINS.md had no row for tools at all.

Still missing after this: PLUGINS.md has no row for backups either.
2026-08-24 08:37:25 -04:00
cproudlock
81575e46ca Give the guest setting somewhere to be changed
Some checks failed
CI / backend (push) Failing after 7m13s
CI / naming (push) Failing after 7m9s
CI / frontend (push) Failing after 7m18s
CI / migrations-mysql (push) Failing after 7m14s
Reported: no Tools section in Settings. The setting existed in the database and
had no page, which from a person's side is indistinguishable from the feature
not existing.

get_settings_defaults SEEDS a setting. get_settings_cards (ADR-010) is what puts
it in the rail. Declaring only the first is a silent half-feature: nothing fails,
no test notices, and the switch is unreachable.

The page states the cost where the DECISION is made rather than in a document
nobody opens - turn it on and it says a guest-created link records no creator,
so the audit trail can say when and from where but never who.

Admin-only, like every other settings page, because this one decides whether an
unauthenticated caller may write to the site.

Also caught while wiring it: the card asked for a 'shield' icon, which is not in
the settings iconMap, so it would have rendered the fallback puzzle piece - the
glyph that reads as "nobody chose this". Uses wrench, matching Tech Tools.

The row still comes from `flask plugin upgrade-all`; the card appears either
way, and without the row the page loads empty.
2026-08-21 16:07:28 -04:00
cproudlock
92cf1d71f0 Stop making people sign in to reach tools that need no account
Some checks failed
CI / backend (push) Failing after 7m9s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
/tools, /tools/codes and /tools/secure-send were all behind requiresAuth. None
of them needed to be, and one of them actively contradicted a setting added an
hour earlier: securesend_allow_guests lets somebody with no account create a
link, and the page they would create it on was behind a login wall.

The label generator runs entirely in the browser - no API, no data, nothing to
protect - so gating it only ever cost a guest the use of it.

THE SERVER STILL DECIDES who may create a secret. A signed-in user needs
tools.secrets; a guest needs the site to have opened guest creation. Making the
page reachable is not the same as making the action allowed, and the two were
conflated.

The page now reads the setting on load, so a guest at a site that has NOT opened
guest creation is told to sign in instead of being handed a form that was always
going to be refused - with a line saying that receiving a link never needs an
account, because somebody who lands there confused usually has a link they
should be opening instead.

A signed-in user always sees the form, even without the permission: the server's
"you do not have permission" is a different answer from "sign in", and
collapsing them would send somebody to a login page they are already past.
2026-08-21 16:00:18 -04:00
cproudlock
8750ba2e74 Let a site open secure send to guests, and choose how many times a link opens
Some checks failed
CI / backend (push) Failing after 7m12s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
Two asks, and the first is a real security decision so it is a SETTING that
defaults to the safe answer rather than a code change somebody inherits.

securesend_allow_guests, OFF by default. With it off the model is unchanged: a
login plus tools.secrets. With it on, somebody with no account - at a shared bay
terminal, a contractor - can create a link too.

The cost is named in the setting's own description and pinned by a test: a guest
secret has NO CREATOR, so the audit trail can say when and from where but never
who. That is the whole reason it is a switch a person turns on deliberately.

IT FAILS CLOSED. A missing row, a half-migrated site, an unhappy database - the
answer is no. An error must never be the thing that opens an unauthenticated
write endpoint.

A SIGNED-IN USER STILL NEEDS THE PERMISSION even when guests are allowed, which
is surprising enough that a test pins it: opening the door to guests is a
decision about the door, not about revoking what a role holds.

Guest creation is rate limited per address, same shape as the login limiter. Not
a security boundary - a determined person has more addresses - but an open
endpoint should not be an easy place to park data or fill a disk.

VIEWS ARE NOW A NUMBER, 1 to 100, instead of a four-item dropdown. How many
people need a password is the sender's business: a shift handover is not one
colleague on a call. The floor is what matters, not the ceiling - unlimited
views would just be a password on a web page.

Also folded in, from the retention question: nothing removed a secret that
EXPIRED WITHOUT BEING OPENED. The recipient goes on holiday, the link is never
clicked, and the ciphertext sat there for good - not readable, but it made
"nothing lingers" untrue and grew the table in exactly the case where the
feature failed. Any create now sweeps expired rows and a late click tidies its
own, so it stays bounded without a scheduler an air-gapped site would never add.

Two gates caught mistakes on the way: current_user is a LocalProxy and is never
None, so `current_user is None` silently took the signed-in branch for a guest;
and cache was imported straight from shopdb.extensions when it is already on the
shopdb.api contract surface.

1836 tests green.
2026-08-21 15:53:30 -04:00
cproudlock
fc46bab72d Say that the subpath belongs in site_base_url, because it does
Some checks failed
CI / backend (push) Failing after 7m10s
CI / naming (push) Failing after 7m18s
CI / frontend (push) Failing after 7m14s
CI / migrations-mysql (push) Has been cancelled
Answering "did I set it wrong": no. The setting was right and the description
was misleading, which is what sent the last two commits chasing the wrong fix.

It said "Public base URL of this site (scheme + host), e.g.
https://shopdb.example.net". That is true only of a site mounted at the root.
Both existing consumers append the in-app route to this value and add nothing
themselves:

    `${await getSiteBaseUrl()}${config.detailPath(detailId)}`   asset labels
    `${baseUrl}${defaultPath}`                                  QR codes

so a site served at /shopdb must carry that here or every QR code it prints
404s. The email copy of the same setting has always documented it correctly, as
https://host/ops.

The description now says so, and the comment above the defaults records why -
including that a subpath left out produces links that fail rather than anything
that looks like a configuration error.

Existing rows keep the old text until a site reseeds; the value is unaffected.
2026-08-21 15:26:32 -04:00
cproudlock
e8920b2cf3 Do not add the mount to a site URL that already has it
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / backend (push) Has been cancelled
a381174 fixed a link missing its subpath and produced one carrying it twice:
https://<host>/shopdb/shopdb/s/<token>.

site_base_url is DOCUMENTED as scheme + host, and sites really do set the full
base including the subpath. Trusting the description rather than tolerating what
is configured is the same mistake as the last one, pointed the other way.

The mount is now appended only when the configured URL does not already end with
it, which covers every combination a site can have: base with the subpath, base
without it, a trailing slash on either, and a site mounted at the root where
there is no subpath at all.

Checked all five by evaluating the expression rather than reasoning about it.
2026-08-21 15:22:55 -04:00
cproudlock
a3811740bb Build a secret's link from the site's URL, not the browser's
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / backend (push) Has been cancelled
Reported from production: clicking a secret link gave an IIS 404, and the link
read /s/<token> where the site lives at /shopdb/s/<token>.

The link was assembled from window.location.origin, which is scheme and host
only. site_base_url already exists for this and says so in its own description -
"used for QR codes and any absolute link the app emits" - with getSiteBaseUrl()
as the helper the QR pages use. This reached past all of it.

Both halves were wrong, and the second is worse because it fails silently:

  MOUNT PATH. A site served under /shopdb or /ops carries that in front of
  every route. Leaving it off is the reported 404.

  HOST. Whoever creates a secret may have reached the app by an IP, a short
  hostname, or a name that resolves in one VLAN, and the recipient opens the
  link from somewhere else entirely. The configured site URL is the one address
  known to work for both of them.

The shape of this failure is what makes it worth spelling out: the secret was
created SUCCESSFULLY and the sender was handed a link that could never work, so
the failure landed on the recipient, the sender had no reason to doubt it, and
the secret expired unread. A 500 would have been kinder.

Falls back to the browsing origin when a site has not set site_base_url, which
is what every other absolute link here does.

BASE_URL is injected at build time, so the subpath half of this cannot show up
in dev and only appears on a subpath deployment.
2026-08-21 15:19:31 -04:00
cproudlock
7fc109d91a Give secure send a card, a form, and a page for the person receiving it
Some checks failed
CI / backend (push) Failing after 7m16s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
The backend has been there since d4b49b9 with nothing to click. This is the
half a person actually touches.

A card under a new "Secrets & Access" heading on Tech Tools, which is where
somebody looks for a utility - the whole reason the plugin moved there.

THE CREATE PAGE encrypts before anything leaves the browser and shows the link
once. The plaintext is dropped from memory as soon as it is sealed, and the page
says plainly that the key is in the link and exists nowhere else, because
somebody who does not know that will paste the link somewhere durable.

THE READ PAGE IS PUBLIC, at /s/<token>, outside the app shell: the recipient
usually has no account here, so it carries no navigation and asks for no login.
Short path because the whole link gets pasted into a chat message.

IT NEVER REVEALS ON LOAD. It asks whether the link is still live, then says
"opening this destroys it" and waits for a click. A page that revealed
automatically would be burned by the first scanner that fetched it - which is
the same reason redeeming is a POST.

A MISSING FRAGMENT GETS ITS OWN MESSAGE. If a link rewriter strips the part
after the #, the recipient is told the link is missing its key and what to ask
for, rather than being shown a generic failure. That is the one failure mode
this design cannot recover from, so it is worth naming to the person looking at
it.

The client-side guards live in securesend.js and are advisory by nature - the
server cannot see plaintext, so its size limit is what enforces. They refuse a
pasted FILE outright, which removes the easiest wrong path without anyone
reading a rule, and they warn on something document-shaped: over the length a
credential needs, more than twenty lines, or starting with a PDF/zip/image
signature.

Web Crypto only, no dependency, AES-GCM so a tampered ciphertext fails to
decrypt rather than producing plausible rubbish. The key is base64url for the
fragment, which survives copy-paste and link rewriting better than the standard
alphabet.

VERIFIED THAT THE PAGES RENDER, headlessly, against the dev server: the card
appears under Secrets & Access and the create form draws with its counter and
options. NOT VERIFIED END TO END - no secret has been created, sent, opened or
decrypted, because that needs the dev API restarted and the table migrated.
2026-08-21 15:10:52 -04:00
cproudlock
d4b49b91b4 Move secure send into Tech Tools, where somebody would look for it
Some checks failed
CI / backend (push) Failing after 7m10s
CI / naming (push) Failing after 7m14s
CI / frontend (push) Failing after 7m10s
CI / migrations-mysql (push) Failing after 7m10s
9845867 gave it its own plugin because Tech Tools is browser-only and this needs
storage. That was the right reading of the code and the wrong answer for the
person using it: Tech Tools is where you go for a utility, and a tool nobody can
find is a tool nobody uses.

So Tech Tools now has a blueprint, a table and a migration chain where it had
none. Its docstring and manifest say why, because "every tool here runs in the
browser" was load-bearing documentation and is no longer true.

WHAT MOVED
  /api/securesend       -> /api/tools/secrets
  securesend.create     -> tools.secrets
  securesend0001baseline -> tools0001securesend
  PLUGIN_TABLE_OWNERS, EXPECTED_HEAD_REVISION, EXEMPT_ENDPOINTS, the universal
  profile and the api-inventory surface all follow it.

THE OLD "NO BACKEND" TEST IS NARROWED, NOT DELETED. It asserted
get_blueprint() is None, which is the sort of guard that gets removed when it
becomes inconvenient. It now asserts the blueprint serves EXACTLY the three
secret routes, with their methods - so a future tool that quietly grows an
endpoint or a table has to edit this test, and therefore make the decision on
purpose rather than by drift.

Asserting methods rather than paths also pins the detail this design turns on:
redeeming is POST-ONLY. Chat and mail clients prefetch GET urls to build
previews, and a preview that could redeem would consume a one-view secret before
its recipient opened the link. Previously that was a comment; now it fails a
test.

UNINSTALLING THE OLD PLUGIN COULD NOT BE DONE WITH THE PLUGIN TOOLING. `flask
plugin uninstall securesend` loads a plugin before uninstalling it, so a plugin
whose directory has been deleted cannot be removed by the command that manages
plugins - the registry entry had to be edited by hand. Worth fixing separately;
it is the same registry-versus-code divergence the device work hit from the
other side.

MIGRATING A CHAIN THAT HAS ALREADY RUN needs the old table and its version row
dropped first, because the relocated chain has no stamp and tries to create a
table that exists. Only relevant to anyone who installed securesend during the
hour it existed - it did reach the publication repo in 4242028 - and the fix is
DROP TABLE securesendsecrets plus DROP TABLE alembic_version_securesend before
running plugin upgrade-all.

1829 tests green. Still backend only: there are no pages yet, so nothing here is
reachable by a person.
2026-08-21 14:17:57 -04:00
cproudlock
98458672eb Hand somebody a password without leaving it in a chat window
Some checks failed
CI / backend (push) Failing after 7m16s
CI / naming (push) Failing after 7m5s
CI / frontend (push) Failing after 7m18s
CI / migrations-mysql (push) Failing after 7m10s
The habit this replaces is pasting a credential into Teams, where it stays until
someone deletes the conversation. Tabled on 2026-08-21 pending a decision about
scope; the decision was text only, so this is the half that survives it.

BACKEND ONLY. There is no page yet, so nothing here is reachable by a person.

TEXT, NOT DOCUMENTS, and that shapes everything. Dropping files removed the
export-control question entirely - a password is not technical data - along with
blob storage, size and MIME handling, and the need to authenticate the recipient
so a document fetch could be pinned to a name.

TWO DOORS. Creating needs a login AND securesend.create: anonymous creation
would be an untraceable way to move data out of a system that otherwise records
who did what, and the creator is the only person these rows can ever name.
Reading needs neither, because the recipient usually has no account here and the
link is handed over on a call - the unguessable token IS the authorisation,
which is why it is 128 bits of randomness and why the row is spent on first read.

REDEEMING IS A POST, and that is the detail that would have broken this in
practice. Teams, Outlook and Safe Links prefetch GET urls to build previews and
scan for malware, so a GET that redeemed would let a preview window consume a
secret before its recipient ever saw it. The GET route that remains reports only
whether a link is still live, so the read page can say "opening this will
destroy it" without spending the only view to find out.

THE SIZE LIMIT IS THE WHOLE ENFORCEMENT. The key lives in the URL fragment,
which browsers never transmit, so the server holds ciphertext it cannot read and
cannot tell a password from a page of a drawing. MAX_CIPHERTEXT is therefore not
a tuning knob: raising it quietly turns this into the document transfer it was
scoped not to be. The refusal says what the limit is FOR, because somebody
hitting it is usually trying to send a file and the answer is a different tool.
Line counts, file signatures and refusing a pasted file belong in the browser,
guide an honest person, and are bypassable by anyone willing to POST directly.

NOT ENUMERABLE. Missing, expired and spent all answer identically - telling them
apart would confirm to anyone holding a guessed token whether it ever named
anything. A spent or expired row is deleted on the way past rather than waiting
for a sweeper, because it has no reason to keep its ciphertext.

Its own plugin rather than a Tech Tool: that plugin is browser-only by design,
and this needs storage, expiry and a view counter. Not an asset extension
either, so no assetid and no asset type - the scaffold assumes one and it was
removed.

FOUND BY ITS OWN TESTS: expiryhours=0 returned 201. `int(x or DEFAULT)` treats
an explicit zero as absent, so "expire immediately" silently became "expire in a
day". A caller who sends a number is now told when it is not allowed rather than
quietly given a different one.

BEFORE ANYONE RELIES ON THIS, test that your link rewriter preserves URL
fragments: create a secret, send the link to yourself in Teams, click it from the
message, and check the key is still after the #. If Safe Links strips it the
secret is unrecoverable and the server cannot help, which is the point of the
design. The fallback is passphrase mode, worth building only if that test says so.

Three product gates caught what a new plugin has to declare, and each was right
to: the route-parity gate wanted the endpoints described in api-inventory.json,
the lean-build guard wanted the plugin listed in the universal profile the
released installer builds from, and the authz sweep wanted the public redeem
route named in EXEMPT_ENDPOINTS with a reason. That last one matters most - a
mutating route that answers an unauthenticated caller is a decision somebody has
to write down, not a default, and reading a secret is a mutation because it
spends it.
2026-08-21 13:44:13 -04:00
cproudlock
f09d14d28c Refuse a measurement that makes the label stop existing
Some checks failed
CI / backend (push) Failing after 7m11s
CI / naming (push) Failing after 7m10s
CI / frontend (push) Failing after 7m18s
CI / migrations-mysql (push) Failing after 7m5s
Reported as a barcode rendering 176 by 0. A bar height of zero is what does it:
the number went straight into a CSS length, height: 0in is perfectly valid, and
an image sized height: 100% of a zero-height box is zero pixels tall. The label
comes out full width with nothing on it, which reads as a rendering failure
rather than as a field doing exactly what it was told.

A cleared field was the same bug wearing a different hat. An emptied number
input holds '', not 0, so the page emitted `NaNin`; the browser dropped the
declaration and the box fell back to auto height. That one merely looked wrong
instead of vanishing, which is arguably worse for finding it.

Measurements now go through labelVars(), which will not emit NaN, an empty
length, or a zero for anything whose zero means the box stops existing. Padding,
the quiet zone and the gap still take zero, because there zero is a real answer,
and offsets stay signed. It is a separate module because this is exactly the
kind of arithmetic that needs tests rather than a preview: every case in
labelVars.spec.js is a value a number input can actually hold.

The stylesheet now carries fallbacks on its var() reads as well, but they are a
net, not the fix: a custom property set to garbage is substituted and then
dropped at computed value time, and the fallback does not apply - it only covers
a property that is absent entirely.
2026-08-21 12:09:26 -04:00
cproudlock
3703412baf Only zero is empty, and the last day is worth reading
Some checks failed
CI / backend (push) Failing after 7m18s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
Two separate floors were collapsing a live cartridge into a spent one, and
removing either alone changes nothing.

EMPTY_LEVEL was 5, so everything at or below 5% was assigned daysleft 0
outright. The reasoning was that a printer at 1% is out of toner as far as
anyone standing at it is concerned. But the row exists to say how long is
left, and 2% draining a point a day has two days in it. Flooring put that
cartridge beside ones that genuinely are empty with no way back to the
difference. It is 0 now: empty means empty.

int(level / rate) then truncated the division, so anything under a full day
arrived as 0 whatever the floor did - a cartridge with six hours in it was
indistinguishable from one with nothing, and the band read it as empty.
daysleft is fractional now, rounded to two places, which is about a quarter of
an hour: finer than the estimate deserves, but it costs nothing and keeps the
ordering of two nearly-spent cartridges meaningful.

daysText reads in whatever unit carries meaning: "6 hours", "1 hour", "29 min",
"4 days". Below an hour it goes to minutes with a floor of one, because
rounding hours would land back on "empty" - the same bug one rung down.

BEHAVIOUR CHANGE worth knowing: a cartridge at 1-5% with NO history used to get
daysleft 0 from the floor and land on the order list. A rate needs two
readings; with none there is nothing to divide, and the old answer was right by
accident - it said "empty" about a level nobody had watched move. It now
reports 'no history' and shows under "No estimate yet". This reaches only
printers newly added to Zabbix; anything that got to 3% the ordinary way has
the history to forecast from.

Five existing tests pinned the old rule. They recorded a real decision, so they
are rewritten to the new one rather than deleted. One of them was passing for
the wrong reason: its series (2.0, 1.7, 1.4, 1.1) drops 0.9, under
MIN_DROP_FOR_ESTIMATE, so it never had a rate at all and only passed because
the floor short-circuited ahead of the rate check. It now uses a real 20-day
drop at a tenth of a point a day and asserts the ten days its docstring always
described.

The forecast fixture's black cartridge moves from 1% to 0% so the empty band
keeps its API-level coverage, and a magenta at 3% covers hours-left end to end.
2026-08-21 11:57:02 -04:00
cproudlock
e9fcd1e598 Stop a caption from eating the barcode, and put the labels beside the settings
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / backend (push) Has been cancelled
Reported as the CODE128 not appearing in the preview. It was appearing, in 44
of the 45 preset and layout combinations swept - as a 36 pixel stub. The code
box and the caption were both allowed to grow, so a caption of any length took
half the label and then kept taking, and on 1.00 x 0.50in stock a few words of
caption left a barcode too narrow to scan. Present, useless, and indisinguishable
from a failed render. The caption now takes the room it needs and no more, and a
CODE128 holds at least 40% of the label, or all of it when stacked: past that
there are too few narrow bars left to read, and a caption that wraps or clips is
the better trade.

So that a real failure can never again look like this one, an empty code box now
says so in red in the preview. It is screen-only - the marker must never reach a
printed label.

The settings were a narrow column with the preview trailing below them, which
put the labels being adjusted off the bottom of the screen. Settings on the
left, labels on the right in a column that stays in view and scrolls on its own,
folding back to one column on a narrow window.

That scrolling column is a print hazard worth naming: an ancestor with its own
overflow clips a multi-page print to whatever happened to be on screen. Print
resets it, and the check is a six-page two-sided run coming out as six pages
with the label as the only clipping box in the chain.
2026-08-21 11:55:36 -04:00
cproudlock
b7cfd2b198 A supply nobody could read is unknown, not empty
Some checks failed
CI / backend (push) Failing after 7m21s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
Reported as "CSF04 now reports all 0%", on the printer's own page as well as
the reports. Every supply on one host reading zero at the same instant is not
what a set of cartridges does; it is what a host that stopped answering looks
like.

getsuppliesbyip turned every unreadable item into 0:

    try:
        level = int(float(item.get("lastvalue", 0)))
    except (ValueError, TypeError):
        level = 0

An item that has never collected returns lastvalue as an EMPTY STRING, so
float('') raised and the handler substituted zero. lastclock was fetched in the
same request and read by nothing at all, so a reading from three weeks ago was
presented as current.

Zero is a legitimate level meaning spent, which is what made the substitution
dangerous rather than merely wrong. Downstream it reached `level <=
EMPTY_LEVEL`, which set daysleft to 0, which put the printer on the order list:
the report asked purchasing to buy a full set of cartridges for a printer
nobody had measured. On a waste container it was worse - waste is scored as
100 - level, so an unreadable one read as 100% full, the alarm state.

_readlevel now returns (level, lastseen) with level None for no usable reading:
empty, missing or unparseable lastvalue, or a lastclock older than 24h. A
lastclock that is absent is not treated as stale - a reading that cannot be
aged is not thereby wrong, and discarding it would lose real levels. A genuine
"0" is still 0, which is the half that keeps an actually-empty cartridge on the
order list.

classifysupply gains an 'unknown' status with remaining None. It is not
critical: critical is a claim someone acts on, and it may not be made about a
supply nobody measured.

The low-supplies report skips unknown rather than listing it as needing
replacement, and counts it in summary.unknown - a printer that has gone quiet
should be visible as that, not absent and not masquerading as empty.
_annotate_supply reads .get('level') with no 0 default, which would have put
the whole bug back.

PrinterDetail already rendered `level !== null ? ... : 'N/A'`, so it starts
telling the truth as soon as the backend sends null; the UI had been written
for a case the service never produced. It gains the unknown style - muted, not
a severity colour - and a tooltip separating "no reading at all" from "no
reading since <date>", which are different faults with different fixes.

Alerts needed no change: float(None) raises and that loop already continues,
so unknown supplies stop firing low-toner emails as a consequence.

12 of the 16 new tests fail on the old code. The 4 that pass either way pin
that real zeros still behave, which is the regression this fix could cause.

Not verified against CSF04's actual Zabbix data - dev has no reachable Zabbix.
This fixes the mechanism that turns unknown into 0%; /api/printers/<id>/supplies
now distinguishes them, with null for no reading and 0 for a measured zero.
2026-08-21 11:43:56 -04:00
cproudlock
f9d298c7f0 Name the toner on the forecast, keep the number on the hover
Some checks failed
CI / backend (push) Failing after 7m12s
CI / naming (push) Failing after 7m14s
CI / migrations-mysql (push) Has been cancelled
CI / frontend (push) Has been cancelled
The forecast identified a cartridge by its part number alone. 'W2020X' only
tells you which toner it is once you already know, and both places that name a
supply - the Order now list and the Part column - showed nothing else, so the
report read as a list of codes.

Both now show marketingname, which is what the box says: '414X Black'. The
part number moves to the title attribute, one hover away, because it is still
the thing an order is placed against. A supply with no name on file falls back
to the number rather than showing an empty cell; marketingname is nullable.

'Copy list' is deliberately untouched. It writes the part number verbatim,
which is what purchasing pastes into an order, and a spec pins that the name
does NOT reach that text.

firstPart() now returns the whole part row rather than its number, since the
cell needs both halves. It has one caller.

Dropped the .partnumber style with its .unmapped variant: nothing renders that
class any more, and the variant was already dead - the unmapped block lists no
part number by definition.

Verified by mounting the component against a fixture. The dev box has no
Zabbix, so the page itself has no rows to look at there.
2026-08-21 11:22:58 -04:00
cproudlock
5001aedcf9 Offer a spelling on every list, not just the global search
Some checks failed
CI / backend (push) Failing after 7m15s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
Correction only helped on the global search page, and people search from the
list they are already on. Extending it turned out to be a shape question rather
than a volume one: thirty routes take a search parameter across a dozen files,
so carrying a suggestion in each of their responses is a large change today and
one more thing every future plugin author has to remember.

So the suggestion moved to ITS OWN ENDPOINT, /api/search/suggest, which any page
can call after rendering no rows. A page that has not adopted it shows nothing,
which is exactly what it showed before - nothing breaks by omission.

The plumbing lives in useListQuery, which already owned the search term, so a
list needs three lines: take `suggestion` and `reportCount` from the composable,
call reportCount(rows.length) after a fetch, and drop SearchSuggestion into the
empty state it already has. A list that never calls reportCount never offers a
suggestion.

Wired: global search, machines, printers, PCs, network devices, measuring tools,
knowledge base, vendors. NOT wired, deliberately: the type and reference lists
(machine types, PC types, VLANs, subnets, operating systems and the rest), which
are small controlled vocabularies nobody typo-searches, and USB, whose empty
state has a different shape and wants doing by hand rather than by pattern.

TRAP FOUND WHILE WIRING IT, and left commented in every page: applying a
suggestion by calling setSearch alone updates the box and the URL and does NOT
reload the list. setSearch only syncs the URL, and the watcher that would reload
is suppressed because search.value already holds the new term - the same trap
the global search page documents in performSearch. Each page calls its own load
function directly.

The composable guards a stale answer arriving after a newer search was typed,
never offers back the word that was typed, and swallows its own errors: a search
that found nothing is already the answer, and failing to improve on it is not
worth an error in front of anyone.

The route-parity gate caught the new endpoint being served without an entry in
docs/api-inventory.json, which is hand-written on purpose; added, and the spec
regenerated from it (283 paths, 418 operations). That regeneration also carries
the openapi version to 0.12.0, left over from the release.
2026-08-21 11:14:56 -04:00
cproudlock
1abb6430f5 Put a code on the back of a card, a picture on the front, and the page in the app
Some checks failed
CI / backend (push) Failing after 7m17s
CI / naming (push) Failing after 7m14s
CI / migrations-mysql (push) Has been cancelled
CI / frontend (push) Has been cancelled
The back side landed last commit carrying text by default, and a back laid out
as text-only renders no code box at all. So a two-sided run came out with a
blank reverse, which reads as the code having failed rather than as a setting
doing exactly what it said. The back now defaults to carrying a code, takes its
own content per row, and chooses its own symbology - a QR on the front that
opens the record, a CODE128 on the back that the bench scanner already reads.
Text-only is refused while the back carries a code, and the contradiction is
reconciled on load, so a saved setting from the broken combination repairs
itself rather than printing another blank box of cards.

A picture can go on a label: above the code, beside it, or behind everything as
a watermark, on either side or both. The file is read in the browser and never
sent anywhere, which is what makes it usable at an air-gapped site, and it is
deliberately not saved between visits - a data URL runs to megabytes and would
take every sizing value with it when the quota gave out. The watermark says
plainly that it costs the scanner contrast.

The generator was a full-screen route with no navigation and a narrow column of
controls against the left edge. That shape exists so a print cannot put the
sidebar on label stock, which is a real hazard and the reason core's label pages
are built that way - but it is a hazard the print stylesheet can answer, and
style.css now hides the shell at print time. A tool someone spends ten minutes
in gets the navigation and the full width; the stock still gets nothing but
labels, confirmed against a print PDF rather than a preview.

Two smaller things found while checking the above. A test print paired each page
with a rendered code by loop position, while the codes are keyed to position in
the whole run, so a two-sided test card could carry the wrong code; the index
now travels with the page. And the back's dotted preview outline outspecified
the print reset, drawing a dashed rule down the edge of every second card.
2026-08-21 10:59:28 -04:00
cproudlock
1593ee8204 Offer a spelling when a search finds nothing
The corrector landed in fa0f6d7 unwired, pending a week of zero-result logs to
say what it should be aimed at. Attaching it now instead: the guards that make
it safe here are principled rather than tuned to any particular data, so the
logs would confirm the shape of the problem without changing the answer.

ONLY ON ZERO RESULTS. A search that finds something pays for none of this - no
vocabulary build, no scoring, no extra query.

The vocabulary is harvested from columns people actually search - asset numbers
and names, vendors, models, applications, asset types - cached ten minutes and
capped per column. Long enough that someone mistyping twice does not rebuild it,
short enough that a vendor entered this morning is correctable before lunch. The
cap is there because this exists to catch a typo against the names in use, not
to mirror the database into memory to answer one query.

SUGGESTED, NEVER APPLIED. The response carries the better spelling and the
caller decides. Searching for something else on a person's behalf is how
somebody orders the wrong cartridge.

FAILS SOFT. A missing column on a lean install, or anything else, returns no
suggestion and the search answers normally. No vocabulary is worse than the
alternative; it is not fatal.

Three tests through the endpoint rather than only the unit: a misspelling gets a
suggestion, a search that succeeds gets none, and CSF18 against an existing
CSF17 returns no results AND no suggestion - the digit guard holding across the
whole stack, which is the one that would cost somebody a wrong bay.

The zero-result logging stays. It answers "what can people not find", which the
corrector does not: a typo is only one of the reasons a search comes back empty,
and the others are vocabulary nobody has entered and records that do not exist.

NOT DONE: the search page does not render the suggestion yet, so this is
invisible to a person until that Vue change lands.
2026-08-21 10:41:14 -04:00
cproudlock
fa0f6d7ebc Record what nobody can find, and a spelling corrector that refuses to guess
Two halves of one question - how to handle a misspelled search - deliberately
kept separate, because only one of them should be turned on today.

WHAT NOBODY CAN FIND IS NOT RECORDED ANYWHERE. A search returning zero results
is the only evidence of the gap between what people look for and what is there,
and it vanished. Logged now at INFO with a stable prefix, so a week of it greps
into a list. That list is what should decide whether correction is worth wiring
in: multi-word matching and cross-field matching both changed in the last day,
so a good share of what used to fail may already be found. The failures left
over might be typos, or vocabulary nobody has entered, or records that genuinely
do not exist - and each wants a different answer.

THE CORRECTOR IS BUILT AND NOT WIRED IN. shopdb/core/services/spellfix.py, with
tests, ready to attach in about ten lines once there is evidence about what to
attach it to.

NOT SOUNDEX, which was the obvious candidate. MySQL has it and SQLite does not,
and the suite runs on SQLite while production runs MySQL - so a SOUNDEX() in a
query is either an error in every test or a production path no test executes.
It is also wrong for this data: soundex is English-name phonetics, four
characters wide, and it DISCARDS DIGITS, so CSF16 and CSF17 hash identically.
Half of what people search here is an identifier.

So: character distance in Python, same behaviour on both dialects.

THE VOCABULARY IS THE DATA. No dictionary holds Genspect, Telesis, Keyence or
wax-trace. Terms come from the columns being searched, which also means a vendor
added this morning is correctable this morning.

TWO GUARDS, and they are the point rather than a detail. Digits must match
EXACTLY: CSF16 to CSF17 is one edit and a different bay, so anything carrying
digits is either right or not correctable - while cfs16 to csf16 still works,
because the guard is on the digits and not on identifiers wholesale. And the
first character must match, since typos land mid-word far more often than on the
first key, which costs almost no recall.

Distance is Damerau-Levenshtein so a transposition costs one edit rather than
two - Keyecne for Keyence is the commonest error there is, and plain Levenshtein
pushes it past the threshold on short words. Allowed distance scales with
length. A tie returns NOTHING: two equally good candidates means there is no
answer, and offering either implies a confidence that is not there.

It suggests; it never rewrites. Silently searching for something else is how
somebody orders the wrong cartridge.

13 tests, weighted toward the refusals, because those are the cases where being
wrong costs something.
2026-08-21 10:36:09 -04:00
cproudlock
41d875da4e Put the hover marker back in the box's coordinate space, letterbox and all
Some checks failed
CI / backend (push) Failing after 7m12s
CI / naming (push) Failing after 7m18s
CI / frontend (push) Failing after 7m22s
CI / migrations-mysql (push) Failing after 7m10s
2bf1531 fixed the reported symptom and introduced the opposite one: markers that
had been landing left came right, and then sat well ABOVE where they belonged.

It was the right diagnosis and the wrong remedy. The drawing is painted with
object-fit: contain inside a fixed 390x300 preview, so it is letterboxed on one
axis and a percentage of the BOX is not a percentage of the DRAWING. That part
was true. But the fix resized the frame to shrink-wrap the image, and the
centring transform above it translates by percentages - which resolve against
that same element. Making the frame image-sized silently broke the transform's
assumption that the frame is the box. Width still matched at 390, height no
longer did, which is exactly x-right and y-wrong.

The frame fills the box again and object-fit: contain is back. Where the bars
fall is now computed instead: paintedBox works out the fraction of the box the
drawing covers and the gap before it, per axis, and the marker maths folds that
in. The transform's assumption stays true because the element it translates
never changes size.

Checked per axis rather than asserted. A tall drawing pillarboxes, so x
compresses and offsets inward - a left edge at 2 percent of the drawing lands at
13.1 percent of the box, which is where the drawing actually starts - and y is
untouched. A wide drawing does the reverse. A level shaped like the box is a
no-op, so nothing that already looked right moves. Centre maps to centre on
every shape.

NEW COUPLING, deliberately noted at both ends: PREVIEW_WIDTH and PREVIEW_HEIGHT
must stay in step with .map-preview in the styles. Resize the preview without
touching them and this drifts back.

Still not confirmed against a live instance, and this is the second attempt at
this bug, so it wants eyes on three cases: one that was landing left, one that
was landing high, and one that was right all along.
2026-08-21 10:19:56 -04:00
cproudlock
2bf1531d5b Measure a hover marker against the drawing, not the box around it
Some checks failed
CI / backend (push) Failing after 7m14s
CI / naming (push) Failing after 7m22s
CI / frontend (push) Failing after 7m14s
CI / migrations-mysql (push) Failing after 7m14s
Reported from the floor: some assets showed their hover mini-map marker way off
to the left, outside the map, while the SAME asset sat correctly on the main
map. The data was right; the preview was drawing it wrong.

The preview is a fixed 390x300 box and the drawing was painted into it with
object-fit: contain, which letterboxes or pillarboxes to keep the drawing's own
aspect ratio. The marker, though, was positioned at a percentage of the BOX. So
the marker and the drawing were measured in two different coordinate spaces, and
they only agreed when the level's aspect ratio happened to be 390:300.

A level near that ratio looked perfect - 3300x2550 is 1.294 against the box's
1.300 - which is why this read as "some devices" rather than "the feature is
broken". A taller drawing pillarboxes, every marker's x shifts, and one near the
left edge lands in the grey gutter beside the map.

The image now SIZES the frame instead of filling it: the transform box
shrink-wraps the image and the image scales down to fit. A percentage of the
frame is therefore a percentage of the drawing by construction, at any aspect
ratio, and the letterboxing moves out to the preview around it where it does no
harm. dimensionsFor still converts the stored pixels to percentages - that part
was always right.

NOT visually confirmed against a live instance. The reasoning is traced and the
build is clean, but the shape of this bug is that it looks correct on any level
close to the old box ratio, so it wants a look at an asset that was landing left
and one that was already fine.

Related and NOT addressed: the wheel zoom applies translate percentages that
resolve against the element and are then multiplied by scale, so a zoomed
preview may still drift. At rest, which is the reported case, it is exact.
2026-08-21 09:50:46 -04:00
cproudlock
d0eeaa08d5 Find a knowledge base article by words from different fields
Some checks failed
CI / backend (push) Failing after 7m15s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
An article's searchable text lives in three places: its title, its keywords,
and the name of its topic. Nobody typing a search knows or cares which word
came from which, so 'CMM Community' means "the article tagged community, under
the CMM topic". Both search paths returned nothing for it.

The plugin's own listing, which is what the KB page calls, built a single
ilike('%CMM Community%'). That needs the whole phrase contiguous in ONE field,
and no article has it: 'CMM' is only in the topic's name, 'Community' only in
the keywords. Global search already split the query into words but only ever
looked at title and keywords, so a word that only the topic could satisfy
failed there too - the same bug wearing a different face.

Every word must now be found somewhere across all three fields, in any order.
_word_match gains an `extra` hook for a word that a RELATED row satisfies
rather than a column of this table, which is how the topic joins in without
colliding with the sort=='topic' join.

The retired-topic rule is unchanged and pinned on both paths: matching more
words is not a way past it.

Verified against the 235-article dev library, where all three of these
returned nothing before: 'Fieldglass Jefferson' (title word plus topic word),
'outage notification' (two keywords, not adjacent), 'compucom Jefferson'.

The same phrase-only pattern is repeated in about ten other list endpoints
(computers, machines, printers, network, printedparts, measuringtools, usb,
notifications). Left alone here - fixing them properly means promoting the
word-match helper onto the shopdb.api contract surface rather than copying it
per plugin, which is a contract bump.
2026-08-21 09:39:46 -04:00
cproudlock
5de3fe4b40 Note secret sharing as a tabled tool, with the reasoning attached
Some checks failed
CI / backend (push) Failing after 7m11s
CI / naming (push) Failing after 7m6s
CI / migrations-mysql (push) Has been cancelled
CI / frontend (push) Has been cancelled
Evaluated a Password-Pusher-style service as a Tech Tool and tabled it. The note
carries the decisions rather than just the idea, because those are what cost the
time: it is NOT a Tools-plugin candidate as it stands (that plugin is browser
only by design, with get_blueprint and get_models both empty), the sender must
always be authenticated, a FILE recipient should be logged in too - which costs
nothing on an intranet-only server where anyone who can resolve the link already
has an account - and zero-knowledge encryption is right for a credential but
questionable for a controlled document, where a server that cannot say what was
shared is a liability rather than a feature.

Intranet-only hosting is what makes the idea viable at all: a link mailed
outside is dead and no ciphertext reaches a third party. Pointing staff at the
public hosted instance instead would reinstate every problem this avoids.
2026-08-21 09:18:51 -04:00
cproudlock
39ede87d55 Give a label its own gap, its own position, and a back side
Some checks failed
CI / backend (push) Failing after 7m10s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
Three things the code generator could not do, each of which had someone
working around it with a control that meant something else.

The gap between a code and its caption was the quiet zone. That is the
clear space a scanner needs around the code, so closing up a caption meant
shaving the thing that makes the code readable, and the only way to get a
tight label to scan was to push the caption further away than it looked
like it should be. Quiet zone now means the code's own margin and nothing
else; gap means the distance to the caption, and adds to the quiet zone
rather than eating into it. Each stock preset carries the gap that suits it.

Position had one pair of controls and they moved the label box on the
stock. That is the fix for a printer whose origin is off, and it is not the
fix for a code sitting in the wrong place on a label - but it was the only
lever there, so it got used for both. The label div is now the media and
holds still; everything printed sits in a content wrapper that Align,
Vertical and Content X/Y move within it. The old pair kept its behaviour
and is labelled Media X/Y.

A badge card has two sides and the generator only ever emitted one page per
card. The back can now carry the same code again, a fixed block of text,
its own per-row content, or nothing. Page order is a choice because the two
kinds of printer want opposite things: interleaved for a duplex card
printer, which takes pages two at a time, and grouped for a single-sided
one whose stack gets flipped and reloaded. A row that leaves its back
column empty still gets a blank back page - a page count that varies per
row shifts every later back onto the wrong front, which prints a box of
perfectly readable cards that are all wrong.

CSV parsing and page building moved to plugins/tools/frontend/labelPages.js
(committed in 875fde9), where the column contract and both page orders are
covered by tests rather than by looking at the preview.
2026-08-21 09:08:37 -04:00
cproudlock
875fde9f48 Fix what the last round of device fixes broke, and two dips it missed
Some checks failed
CI / backend (push) Failing after 7m15s
CI / naming (push) Failing after 7m14s
CI / migrations-mysql (push) Has been cancelled
CI / frontend (push) Has been cancelled
A review of d60ed60 and 8b9b936 found five things. Three were introduced by
those commits.

REFUSING A NAMED DEVICE MADE A MARKER PC CLAIM ITS OPERATION. _sync_partmarker
returned [] both for "not a marker PC" and for "a marker PC that linked
nothing", and the caller reads [] as the first - so a bay whose asset-id.txt
named something unresolvable fell through to the ordinary machine link, and for
a marker PC the machine number IS the operation. It took an active link to a
record that can only have one holder while several markers share it, and the
warning said "not linked". The previous behaviour minted a twin; this traded
that for a contested operation. None now means "not a marker PC" and is the only
answer that lets the machine link run; the response normalises it away so the
API shape is unchanged.

A DORMANT CHALLENGER WAS PROMOTED BY DELETING A FILE. Recording a challenger
dormant leaves a row that the next cycle finds as `reuse` and reactivated with
no incumbent check - so a second PC took a live device by its enrollment file
becoming unreadable. Both reuse branches re-check incumbency now, which is what
_sync_machine_link always did.

AN INCUMBENT UNDER ANOTHER COLLECTOR LABEL WAS INVISIBLE. Incumbency was queried
on our own label, but on a CMM the instrument IS the reported bay, so the
incumbent's link is the machine sync's row. A second PC naming that instrument
found no incumbent and linked actively: two live holders of one instrument, each
invisible to the other. Incumbency now counts any collector-owned label. A row
made BY HAND carries none of them and is still excluded - a person's link is not
the collector's to archive.

THE NETWORK FORM LOCKED OUT THE ROWS IT NEEDED TO FIX. Asset number is disabled
while editing, correctly, but the payload is built in script so the blank was
still sent - and the new server-side guard rejects it. A device with no asset
number could not be saved at all, and the field could not be typed into. It now
unlocks only for a record that loaded without one, with a hint saying why.

"2 in 4.0d" WAS THE LABEL LYING. Replacements are counted across the whole
history window; basisdays is only how long the current cartridge has been in.
Joining them with "in" claimed two changes inside four days - the exact shape
reported as unbelievable, except here the data was right. Now "2, this one 4.0d".

Two toner dips the same review found:

A MULTI-POLL OUTAGE STILL MINTED A PHANTOM SWAP. Only single readings were
dropped, so 90, 0, 0, 90 survived and 0 -> 90 scored as a change. Dips of any
length are handled now. One bad sample stays a candidate whatever the polling
cadence, because a reading is an instant; several consecutive low ones only
count as one outage when they are close together, since days at zero is a real
empty period. That time bound also separates an outage from a swap, ordinary
consumption, and a second swap, which have the same shape in levels alone.

THE DIP FILTER ATE REAL SWAPS OF NEARLY-FULL CARTRIDGES. Recovery was tested
with an absolute difference, so 95 then 5 then 100 read as a recovery because
100 and 95 are close, and the swap evidence was deleted. Toner only falls: a
recovery comes back at or BELOW where it left, a new cartridge comes back
higher.

The review also proved by reverting each feature that the previous tests did not
pin the median burn rate or the near-full rule - both passed with the bug
restored. Verified by the same method that all four toner behaviours now fail
when reverted, and the burst assertion is tight enough to tell 0.2 from 0.88.
2026-08-21 08:49:38 -04:00
cproudlock
0c0c7be439 A toner rate needs days behind it, not just readings
Some checks failed
CI / backend (push) Failing after 7m13s
CI / naming (push) Failing after 7m10s
CI / frontend (push) Failing after 7m14s
CI / migrations-mysql (push) Failing after 7m14s
Reported from the floor: two printers showing 81 and 83 percent, both forecast
to run out in a fortnight. That is a rate near 5.8 percent a day on a cartridge
barely touched.

burn_rate required four readings and a 2 point drop, and checked only that some
time had passed - not how much. Supply items are commonly polled every few
minutes, so four readings can span a quarter of an hour, and a 2 point drop
across fifteen minutes extrapolates to nearly 200 percent a day. The report then
sorted that confident wrong number into "soon", next to cartridges that really
are about to run out. It did not look broken; it looked urgent.

A rate now needs two days behind it. Two days is the smallest span that survives
a printer's daily rhythm, so one heavy morning does not become the whole picture.
Below that the answer is "not enough history yet", which the report already has
a home for: band() returns None and the row lands in the "No estimate yet"
section with its reason shown, rather than competing for attention with real
urgency.

This is the third face of the same fault. A phantom replacement truncated the
run - "2 replacements in 2.3 days" was the same printer saying so - and the rate
was then fitted to whatever short stub remained. The near-full rule and the dip
filter stop the truncation; this stops a stub from producing a number at all.
2026-08-20 17:23:34 -04:00
cproudlock
8b9b9363ee Toner: count cartridge changes that happened, and rate a burst as one burst
Some checks failed
CI / backend (push) Failing after 7m10s
CI / naming (push) Failing after 7m10s
CI / migrations-mysql (push) Has been cancelled
CI / frontend (push) Has been cancelled
Two things reported from the floor, one cause each.

"5 CHANGES IN 90 DAYS, THAT'S HARD TO BELIEVE." It was. A replacement was any
+10 rise between readings, with no check on where it landed, so two shapes that
are not swaps scored as swaps: a supply reading 0 or near-0 while it was out of
the machine and then reading normally again, and a coarse gauge ticking back up
after a reseat or a power cycle.

A swap must now also LAND near full, because that is what a new cartridge reads,
and a single dip that RECOVERS to roughly where it came from is dropped before
anything looks at it. The dip filter keys on shape rather than cause, which is
why it holds for all of them - a supply pulled out to be shaken, a door open
mid-poll, or a site whose preprocessing maps the Printer MIB's unknown
sentinels onto 0. It is NOT a Zabbix timeout: an item that does not answer
records nothing rather than writing a zero. A genuine near-empty reading before
a real swap does not recover, it jumps to full, so it survives and its swap
still counts.

find_replacements and current_run now read one predicate. When they disagreed, a
phantom rise reset the run and threw away the history the estimate needed - so
the bad count was quietly damaging the rate as well, which is why both were
wrong at once. Expect replacement counts to FALL and per-cartridge history to
lengthen.

A BURST BIASED THE RATE FOR THE LIFE OF THE CARTRIDGE. The rate was the slope
between the first and last reading of the run, and two endpoints cannot tell
"steady" from "burst then stopped". A cartridge that lost 20 percent in two days
and then barely moved for a month read as 0.83 percent/day forever after, so the
report kept promising it would run out long after printing slowed. It is now the
median of the per-interval rates: the burst is one interval among many rather
than one of two points. Rising intervals are dropped as noise; flat ones stay in
at zero, because a cartridge that did not move is real information. If every
interval is flat or rising yet the run dropped overall, it falls back to the
whole-run slope rather than reporting nothing.

Where the intervals disagree by 5x or more the rate carries a marker and an
explanation on hover. The number is still the best estimate available; the flag
stops it reading as a measurement.

The "Changed" column is "Replacements", and its cell says "2 in 90d" rather than
"2 / 90d", which was read as a date, a ratio and a version number.
2026-08-20 16:11:27 -04:00
cproudlock
d60ed602a1 Stop three ways the collector and the forms wrote things nobody asked for
A review of last week's device-identity work found these; two were writing bad
data and one was reproduced against a live server before being fixed.

AN UPDATE COULD BLANK AN ASSET NUMBER, on all six asset update paths. Create
validates it and the column is NOT NULL, but the conflict check only runs when
the value DIFFERS, and '' collides with nothing - so an empty assetnumber went
straight through to a required column. This is the likely source of the assets
found with no number: a form that loaded blank and was then saved.

MACHINEFORM COULD LOAD BLANK AND LET YOU SAVE IT. One try/catch wrapped eight
reference loads AND the machine fetch, so a single transient failure among them
- one page of listAll() timing out during a collector cycle is enough - rejected
the whole block and rendered a fully editable EDIT form with every field empty,
the error banner far below next to Save. Typing an asset number and saving then
wrote the blanks over a real machine. The record now loads in its own try, and a
failure shows the reason INSTEAD of the form: an empty edit form is
indistinguishable from a record whose fields are genuinely empty.

NAMING A DEVICE THAT DID NOT RESOLVE STILL MINTED A TWIN. Both device paths
warned "not linked" and then fell through to mint <HOST>-PARTMARKER or
<HOST>-CMM - the hostname-derived twin the resolution order exists to prevent.
The warning was true about the typo'd number and false about the twin. Naming a
device is a commitment: if the name does not resolve, or resolves to the wrong
kind of thing, link nothing and say so. Silence still means "work it out", so a
bay with no file keeps the reuse-then-mint behaviour it always had.

TWO PCS COULD BOTH HOLD ONE DEVICE, ACTIVELY, WITH NO WARNING. Verified against
a live server: report as one host, then as another naming the same marker, and
both controls rows stayed active. Neither device path had ever looked at who
else held the target - only at links whose source was THIS PC - so a replaced PC
kept its link forever and an asset-id.txt copied to a second bay claimed the
device silently. It now reuses the machine link's rule rather than inventing a
second one: an incumbent that has gone quiet past the claim window or been moved
off In Use has yielded and is archived, never deleted; a live incumbent keeps
the device and the challenger is recorded dormant.

The swap test asserted the old behaviour and now asserts the new one, split in
two: a live incumbent keeps it, and handover completes once the incumbent
yields. Two other tests were passing while their names lied - the unknown-device
one checked only that the typo'd asset was not created, not that nothing was
linked, and it passed while a twin was minted beside it.
2026-08-20 16:11:08 -04:00
cproudlock
85931db0fa Network devices: filter models by vendor, and style the hero like every other page
Some checks failed
CI / backend (push) Failing after 7m9s
CI / naming (push) Failing after 7m10s
CI / frontend (push) Failing after 7m18s
CI / migrations-mysql (push) Failing after 7m18s
Three fixes, all of them a page not doing what its siblings already do.

MODELS WERE NOT FILTERED BY VENDOR. PCForm, MachineForm and PrinterForm each
narrow the model list once a vendor is chosen; NetworkDeviceForm bound the whole
catalogue, so picking Palo Alto still offered every Dell and Zebra model. Same
computed as the others, including the same rule that no vendor selected shows
everything - an empty dropdown reads as "no models exist" when it means "pick a
vendor first". Audited the rest: this was the only gap. The other views holding
a modelnumberid have no vendor picker to filter against, and settings ModelsList
is where a model's vendor is ASSIGNED, where filtering would be circular.

THE HERO RAN THE LABEL INTO THE VALUE - "Asset #FW-OAV..." as one string. The
page used detail-item / label / value, which match nothing in the stylesheet, so
the two spans got no layout at all. Every other detail page uses hero-detail /
hero-detail-label / hero-detail-value, which stacks a small uppercase label above
the value. Renamed to those; no CSS added, because the styles already existed and
this page simply was not using them. It was the last page using the unstyled
names.

MACHINES LIST LINKED BY THE WRONG ID on its fallback path. `/machines/:id` keys
on machineid, the plugin extension id, and the row click and View button fell
back to `item.assetid` - which lands on whichever machine happens to carry that
number: a wrong page that looks right, which is worse than a 404. That defect has
been fixed twice before in other views (AssetRelationships, then the GE-Enforce
reports table) and BackupHistory carries a comment warning about it; this was the
fourth copy. The list endpoint always sets item.machine, so the fallback could
not actually fire here - it is removed as a latent trap rather than a live bug,
and with no machineid the cell now shows plain text rather than a link that
misleads.
2026-08-20 15:10:01 -04:00
cproudlock
c73b53f613 Make phase 3 a correctness fix, not a tidy-up
Phase 3 was written as "the collector reads a registry value". It is all three
components moving together: imaging writes DeviceId, GE-Enforce resolves device
identity registry-first, and the collector reads the registry ahead of the file.

The argument for it is already in the fleet's own code. Install-FromManifest
resolves TargetMachineNumbers gating from the eDNC registry BEFORE
machine-number.txt, and says why: the file is written ONCE by startnet.cmd at
the PXE menu and is not updated on reassignment, so it goes stale.

asset-id.txt inherits that defect exactly. Nothing rewrites it when a device is
swapped, moved between bays or replaced, and phase 1 put it at the TOP of the
resolution order - so a stale value is adopted with no warning, because it still
resolves. A file only imaging writes cannot be the top of a resolution order for
something that changes during a PC's life.

Also records the consequence: phase 3 needs a reassignment path the way
Set-MachineNumber exists for the bay number, or it moves the staleness from a
file into a registry value instead of curing it.
2026-08-20 10:51:39 -04:00
cproudlock
30834cd794 Say plainly that one registry value covers every device family
The phase 3 section named the key and left the scope to be inferred from the
fact that deviceid is one field. Spelling it out: DeviceId is a single value,
not a key per device type, and the pc-type decides which sync consumes it - so
CMM, Keyence, Genspect, wax-trace and part markers all read the same value, and
a family declared later through subordinatedevice_<pctype> is covered the day
it is declared.

Also records what the value REPLACES per family, which differs: for a CMM it
replaces cmmid.txt as identity only, for a part marker it replaces minting from
the hostname, and for the other three it replaces nothing because they never
had an identity at all.
2026-08-20 10:50:38 -04:00
cproudlock
89ab9706d2 Write down where device identity is going, in three phases
Phase 1 shipped without a plan around it, and the next two steps both touch a
different repository and the live boot image, so the reasoning needs to outlive
this week rather than being rediscovered from the code.

The finding that shapes the whole thing: cmmid.txt CANNOT simply be retired. It
is a PATH KEY, not just an identity - Verify-And-Heal-Staging stages
installers-post\cmm\backups\<cmmid>, 09-Setup-CMM locates the staged set by it,
and Restore-CMM restores from <BackupRoot>\<cmmid> as a same-bay operation. Only
one of its five consumers is an identity use. That one is also the one doing the
job badly, reporting a CMM's INSTRUMENT id in the machinenumber channel so the
server can resolve it back to a measuring tool at step 4 of its own fallback
chain.

So phase 2 has imaging write asset-id.txt ALONGSIDE cmmid.txt - two files, two
questions - and demotes the identity use while every path use stays exactly
where it is. cmmid.txt survives phase 3 too; it is not on any deletion list.

Phase 3 is the registry, HKLM:\SOFTWARE\GE\ShopDB\DeviceId. No new key and no
new read path: the collector already opens that key for BaseUrl and ApiToken and
Install-GEEnforce already writes it. The proposal argues for doing phase 3
BEFORE phase 2 rather than after, because then imaging writes the registry for
new bays and skips the file era entirely - the file survives only for bays
staged between phases, and the cleanup is deleting a branch instead of sweeping
a fleet.

Also recorded: the rail that must not be crossed (machinenumber is what
TargetMachineNumbers gates on, so a device id in that channel stops every
bay-gated entry from matching, silently, forever), what each phase does NOT
change, per-phase rollback, and a note that phase 1 stops new duplicates without
merging the ones a PC swap has already made - so the reconciliation backlog
should be read BEFORE imaging starts writing identities over the top of it.
2026-08-20 10:43:42 -04:00
cproudlock
68b86d459e One enrollment file names the device, for every bay without a registry
A bay with an NTLARS/eDNC MachineNo registry is identified by it, and a CMM by
cmmid.txt. Everything else - Keyence, Genspect, wax-trace, part markers, and
whatever a site declares next - had no stable identity at all, and the two
mechanisms that stood in for one both key off the PC: reuse looks for a prior
link from THIS PC asset, and minting builds `<PC number>-<SUFFIX>` where a PC's
number is its hostname.

Both survive a re-image. NEITHER survives a PC SWAP. A new hostname is a new PC
asset with no prior link and a predicted number that has never existed, so the
same physical device gets a second record while the first keeps its config and
its backup history under a dead PC's name. That is how 43 legacy MT-#### tools
ended up shadowed by minted twins, three records deep in places. The metrology
path learned this and gained an explicit id file; the part-marker path was
modelled on the metrology path as it stood BEFORE that fix, so it inherited the
defect - and its own docstring said so, describing minting "the same way it
already mints a CMM".

C:\Enrollment\asset-id.txt is now that identity for both, and for anything
declared later through subordinatedevice_<pctype>. It holds one line, the
device's assetnumber, and deliberately does NOT record what kind of device it
is: the pc-type already does, so a new device type needs no new file and no
client change. Resolution puts it first, ahead of everything PC-derived.

NOT machine-number.txt. machinenumber answers "which bay is this" and is what
GE-Enforce TargetMachineNumbers gates on, so naming a device there would
silently stop every bay-gated manifest entry from matching. A part marker still
files partof the operation from machinenumber; asset-id.txt changes which marker
the PC controls, not which operation that marker belongs to.

The wire field is `deviceid` rather than `assetid`, because assetid is already
this contract's RESPONSE field for a PC's integer primary key - the two would
have sat side by side meaning different things. measuringtoolid, shipped in
0.12.0, is accepted as an alias and the client still reads the older file, so a
bay staged in the last day keeps reporting; deviceid wins when both arrive.

Two guards, shared by both device families: a value that resolves to nothing
warns instead of minting a phantom, and a value that resolves to the WRONG KIND
of asset is refused with the asset named, so a machine number pasted into the
file cannot be filed under a device label the collector also owns.

Row creation is now one helper instead of a copy per path, because the named and
minted branches both need get-or-create on the extension row and the control
link.

1744 tests green, including a new file that pins the swap case both ways - with
the file one device, without it two. Five of its tests failed first time because
the fixtures built bare assets with no extension row and the type guard refused
them, which is the guard working.

VERIFIED ON WINDOWS 11 (build 26200), five cases: the new file alone; both files
present, new winning; the legacy file alone; neither, sending no field; and a
padded value with a trailing line.
2026-08-20 10:41:00 -04:00
cproudlock
21afa0b56e Let a metrology bay name the instrument it drives
The server has accepted `measuringtoolid` since the adoption work landed, and it
is the FIRST entry in the resolution order precisely because it is the identity
that survives a PC swap. Nothing ever sent it. The reporter read the eDNC
registry, cmmid.txt, machine-number.txt and pc-type.txt, and its own comment
said metrology bays have no per-bay id and therefore send nothing - so a Keyence
or Genspect bay fell through all four steps to minting, which the server's own
docstring calls the last resort.

Minting derives the asset number from the HOSTNAME, so a permanent instrument
inherits the identity of whichever PC drove it that week: replace the PC and
either the number lies or a second tool appears for the same physical unit. That
is how 43 legacy MT-#### tools ended up shadowed by minted twins. The half that
prevents it was built, tested and undeliverable.

The reporter now reads C:\Enrollment\measuringtool-id.txt and sends it when
present. Its own file, NOT machine-number.txt: machinenumber answers "which bay
is this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a tool
there would silently stop every bay-gated manifest entry from matching.

The paste-ready reporter in COLLECTOR-INTEGRATION.md is a second implementation
of the same payload, so it gets the same resolver rather than being left to
drift. The field was also missing from the payload table and from the classic
api.asp mapping, and there was no prose anywhere describing how a tool is
resolved - added, including why minting is last and what the two guards refuse.

VERIFIED ON WINDOWS 11 (build 26200), four cases: a named instrument is read and
sent; no file sends no field and exits 0; a whitespace-only file behaves as
absent rather than sending an empty string; and a padded value with a second
line yields the first line trimmed.
2026-08-20 10:28:24 -04:00
cproudlock
adb30c8875 Release 0.12.0
Some checks failed
CI / backend (push) Failing after 7m17s
CI / naming (push) Failing after 7m18s
CI / frontend (push) Failing after 7m14s
CI / migrations-mysql (push) Failing after 7m18s
Printers become a property of the bay, and the container stops throwing away the
site's own files every time it is updated.

Printer assignment is now end to end. The assignment belongs to the MACHINE and
reaches whichever PC controls it, so a reimaged or swapped bay comes back with
its printers and nothing had to be saved off the old box. One picker serves both
forms rather than two implementations of the same override. A relationship type
can declare itself singular, so setting a second default REPLACES the first
instead of silently losing to the older row. Alongside the assigned half there is
now an observed half: a bay reports what it actually has, kept strictly apart
from what it is told to have, because a drifted bay's own state becoming its
desired state would make every configuration error permanent. The client script
corrects a drifted queue in place rather than only installing a missing one, and
a driver rollout can be spread across waves so 300 bays do not pull 30 GB through
one five-minute window.

DOCKER SITES SHOULD READ THIS PARAGRAPH. The compose stack never persisted the
instance directory, so `docker compose build api && up -d api` - the update path
the docs themselves gave - discarded plugins.json and every upload with it. It is
a volume now, and DEPLOY.md carries the one-time rescue for a stack that predates
it. The image also could not be built at all: the frontend stage never copied the
plugin staging script its own prebuild hook runs, so every build since that
script landed failed. Air-gapped sites need a fresh offline bundle, because the
stack moves to MySQL 8.4 LTS and an existing tarball carries only the 8.0 image.

The Windows installer already bundled MySQL 8.4, so this closes a gap between the
two halves rather than moving anyone.

Adds a topology migration guide: IIS to Docker, Docker to a new host, and back.
The data moves cleanly; what costs time is that the server address is baked into
GE-Enforce manifests, the generated collector script, the printer client scripts
and printed QR codes.

1736 tests green. Contract stays at 0.20.0 - nothing under shopdb/api changed.
2026-08-20 08:45:56 -04:00
cproudlock
c6c806667e Run the database version the rest of the product already recommends
Some checks failed
CI / backend (push) Failing after 7m18s
CI / naming (push) Failing after 7m14s
CI / frontend (push) Failing after 7m13s
CI / migrations-mysql (push) Failing after 7m10s
INSTALL-WINDOWS-IIS.md has said MySQL 8.4 LTS is standard for new installs since
8.0 reached end of life in April 2026, while both compose files and the offline
bundler still pinned 8.0. A site reading the Windows runbook and a site reading
the Docker one were being told to run different servers, and the migration page
written this week sent people onto the dead one.

Verified against a real server rather than by editing a tag: 8.4.11, core chain
plus five plugin chains applied clean, 66 tables at a single utf8mb4_unicode_ci
collation, six alembic version tables. The image's PyMySQL authenticates against
8.4's caching_sha2_password, which is what requirements.in already pins
cryptography for.

Existing servers need one thing done FIRST: 8.4 removes mysql_native_password,
so an account created on 5.6 or 5.7 must be moved to caching_sha2_password
before the upgrade or it cannot authenticate afterwards. In-place also has no
downgrade path, and 5.7 cannot reach 8.4 in one hop. For databases this size a
dump into a fresh 8.4 server is the better trade: same outage, and the old
server stays as the rollback.

Air-gapped sites need a fresh offline bundle, because the tarball carries the
MySQL image alongside the app image.

Also here, found by having it bite during that verification: the db healthcheck
pinged over the unix socket, and the entrypoint's init pass answers on the
socket while running the server with --skip-networking. The probe therefore
reported healthy DURING init, which is what `depends_on: service_healthy` gates
api and migrate on. A ping passed at 8 seconds and the next query failed because
the server was mid-restart. Probing 127.0.0.1 keeps it red until the real server
is listening.
2026-08-19 19:57:40 -04:00
cproudlock
a7f5d2d0bf Say how a site moves between stacks, in one place
The pieces existed across three pages and nothing connected them, so "can we go
from IIS to Docker" had no answer to point at. It is a fair question with a
short answer: the application keeps state in exactly two places, the database
and the instance directory, and nothing is encrypted at rest with SECRET_KEY or
JWT_SECRET_KEY, so a move is a dump plus a directory copy. The schema is
identical across topologies.

What the page spends its length on is the part that is NOT the data, because
that is where the time goes. The server address is baked into things that are
not the server: GE-Enforce manifests, the generated collector script, the
printer client scripts, and printed QR codes, which cannot be swept at all.
Keeping the hostname and repointing DNS makes the migration invisible to the
fleet; changing it does not.

Three other traps, each of which has a symptom that shows up later rather than
at cutover: an aliased IIS site needs MOUNT_PATH and a dist built for that
subpath, while the image builds for the root; an older dump can carry latin1 or
3-byte utf8 table definitions that load quietly into a utf8mb4 server and only
misbehave on the first accented name; and a restored instance directory needs
chown, because docker cp writes under the copying user's uid and the container
runs as shopdb.

Covers both directions plus Docker to a new Docker host, and ends with a cutover
checklist that leaves the old stack stopped rather than removed until a bay has
checked in on a working day.

Also fixes two links in START-HERE that pointed at files which are not there:
the ADR index needed its adr/ prefix, and LLM-GUIDE.md is llms.txt.
2026-08-19 19:51:02 -04:00
cproudlock
417f8a3dd4 Keep a site's own files when its container is replaced
`db_data` was a volume and the instance directory was not, so the documented
update path - `docker compose build api && up -d api` - recreated the container
and discarded everything the site had written. `plugins.json` is only the loud
part: maps, branding, model and application images, employee photos, warranty
proofs, slides, printed-part files and the Dell OAuth token all live under
instance_path too. MySQL rows survive and point at files that are gone, so the
second symptom is images 404ing rather than an error anybody sees.

Reported by an adopting site, which read it as having updated too fast. It had
not; nothing it could have done differently would have kept those files.

DEPLOY.md had been telling sites to back up `instance/` since it was written.
The template never gave them anything to back up.

The air-gap `migrate` service mounts the volume too, because
`flask plugin upgrade-all` rewrites plugins.json and that service exits
immediately after.

The image now creates instance/ ITSELF, owned by the app user. Docker seeds an
empty named volume from image content at the mountpoint, ownership included;
with no such directory in the image the mountpoint is created root-owned 0755
and the container, which runs as shopdb, cannot write into its own instance
directory. Caught by running the built image rather than by reading it: the
volume mounted clean and `touch` came back Permission denied. Verified fixed the
same way.

A stack that predates the volume needs its files moved across ONCE, while the
old container still exists - the volume is seeded from image content, and the
image ships instance/ empty, so it comes up empty rather than inheriting the old
container's writable layer. DEPLOY.md carries the procedure, including the chown
after `docker compose cp`, which writes files under the copying user's numeric
uid rather than the app user's.

Also here, found while checking what an upgrade actually runs: the connected
update steps ran `flask db upgrade` and stopped. Per-plugin Alembic chains
(ADR-008) are not part of that, so a connected site taking an image with a
bumped plugin migration ran the core chain and silently skipped every plugin
chain. The air-gap stack had it right all along. Both commands are in Step 9
now, plus a `db current` check against `db heads`.
2026-08-19 19:34:28 -04:00
cproudlock
375dd3fb9d Copy the plugin staging script into the build that needs it
`npm run build` fires a `prebuild` hook that runs
`node ../scripts/stage-frontend.mjs`, the script that copies each plugin's
frontend into the Vite tree and codegens routes.gen.js. The Dockerfile's
frontend stage copied `frontend/` alone and flattened it to the stage root, so
`../scripts` resolved to `/scripts`, which does not exist. Every image build
since the staging script landed has died there on `Cannot find module`.

The stage now keeps the repo-relative layout - frontend/, scripts/ and plugins/
as siblings under /build - because the script resolves its repo root as its own
directory's parent. The final stage copies from /build/frontend/dist to match.

Verified with a real build, not by inspection: 16 plugin frontends staged, vite
produced dist, and the finished image carries frontend/dist with 241 assets.

This also unblocks SITE_PLUGINS lean image builds, which until now only
scripts/build-site.sh could do.
2026-08-19 19:22:20 -04:00
cproudlock
3f7cc37b00 Spread a driver rollout across waves so the fleet cannot stampede its own share
Some checks failed
CI / backend (push) Failing after 7m16s
CI / naming (push) Failing after 7m10s
CI / frontend (push) Failing after 7m10s
CI / migrations-mysql (push) Failing after 7m14s
GE-Enforce gives each PC a start offset of SHA256(hostname) % 5 MINUTES and then
repeats every five minutes. That was sized for reading a few KB of manifest
JSON. A driver set is 100 MB for the two universals and 226 MB for a full site,
so the day a driver entry lands, every bay pulls it inside one five-minute
window: roughly 30 GB across 300 bays, at something like 800 Mbps, on the same
share the whole floor needs for everything else. The failure mode is not slow
printers, it is a floor that stops converging.

-WaveStart with -Waves spreads that out. Each PC derives its own wave from its
hostname, so there is no central coordination, no per-bay configuration, and no
list of who has had it yet. The hash is the same idiom Register-GEEnforce.ps1
already uses for its start offset, SHA-256 rather than MD5 because FIPS-enforced
bays disable MD5 outright and would throw.

Measured over 300 hostnames at 10 waves: 23 to 44 bays per wave against a mean of
30, so the peak wave moves about 4.3 GB rather than the 3 GB an average implies.
Hash bucketing is uneven and the peak is what sizes a link, so do not quote the
mean.

THE GATE RUNS BEFORE THE MANIFEST IS READ, because the manifest is on the share
too. A bay that is not due must not touch the share at all - one read is cheap,
300 bays deciding to read in the same five minutes is the entire problem.

It FAILS CLOSED on an unparseable date. Failing open would restore exactly the
stampede this exists to prevent, and 30 GB cannot be un-sent, whereas a typo that
installs nothing says so in the log every cycle and is fixed in a minute.

A bay powered off during its wave installs on its next cycle instead. The wave is
an earliest-time, not a deadline, so nothing needs chasing afterwards.

-TestOnly reports a bay whose wave has not opened as COMPLIANT, because not
installed is genuinely its desired state today; DSC would otherwise call
SetScript every pass to be told to wait. -IgnoreWave is for proving a pilot bay
before opening anything.

Verified on Windows 11 build 26200, six paths: not-due installs nothing and exits
0; TestOnly while not due exits 0; a garbage date exits 1 having installed
nothing; -IgnoreWave installs against a future start; an opened wave installs;
and no wave arguments at all installs, which is what imaging needs.
2026-08-19 17:16:58 -04:00
cproudlock
2d09fa3201 Collect what bays actually have, separately from what they are told to have
Some checks failed
CI / backend (push) Failing after 7m15s
CI / naming (push) Failing after 7m22s
CI / frontend (push) Failing after 7m14s
CI / migrations-mysql (push) Failing after 7m14s
ShopDB knew what a bay SHOULD have and nothing about what it DOES. Adding the
observed half makes a rollout a review instead of a typing exercise: the floor
reports itself in, you look, and you adopt.

The collection uses the mechanism that already exists rather than a new one.
POST /api/collector/printers dispatches to the printers plugin's
apply_collector_payload, the same ADR-006 hook the computers and backups plugins
implement. New client script, new plugin-owned table, no new transport and no new
credential.

OBSERVED AND ASSIGNED STAY APART, and that is the point rather than a detail. A
collector report can never write an assignment row: _reconcile_edges is the only
function that writes usesprinter/defaultprinter, it has two call sites, and both
are authenticated routes a human calls. If a drifted bay's own state were allowed
to become what it is told to install, every configuration error would become
permanent the next time that PC checked in.

Seeding an assignment from observed state is explicit -
POST /assignments/seed-from-observed - because a rollout adopts many machines at
once. It routes through the same _reconcile_edges as the editor, so there is one
write path with two doors, and a queue matching no known printer is REFUSED
rather than guessed into an assignment. That last rule is the lesson from the
measuring tools: adopting on a weak key produced 43 duplicate instruments.

Two fixes on top of what the agents built. The replace deleted a host's previous
rows by exact case-folded name while the read path treats a short name and its
FQDN as one machine, so a PC that changed spelling appeared to hold every queue
twice - which reads as drift that is not there. And the client sent 'reportedat'
where the declared schema said 'observedat'.

Also here: the legacy loader now imports machines.printerid, the classic system's
record of each machine's default printer, which it silently dropped - the
production import would have lost every one. And Set-ShopdbPrinters.ps1 finally
registers the per-user logon task, staging Apply-ShopdbDefaultPrinter.ps1 to
C:\ProgramData first because the share it lives on is mounted only during the
enforcement cycle and the task runs at logon when it is gone.

VALIDATED ON WINDOWS 11 (build 26200), not just on Linux pwsh, which parses these
scripts happily and executes none of the spooler branches.

The reporter: posts a correct payload with the X-API-Key header; resolves BaseUrl
and CollectorKey from HKLM when given no arguments; suppresses the virtual queues
by port; resolves port addresses; and reads the CONSOLE USER's default out of
HKU rather than SYSTEM's own, which is a different and usually wrong answer.

Two results matter more than the rest. With the spooler stopped, both the cmdlet
and the CIM path fail and the script posts NOTHING - verified against a capture
server that recorded zero requests, where an empty list would instead have
erased that host's observed rows and read as a bay that lost its printers. A
genuinely empty host still posts [], because that is a real and different fact.

The logon task registers as the Users group at Limited, and falls back to the
well-known SID S-1-5-32-545 when the group name will not resolve, as it will not
on localised Windows. It was then run with the source directory RENAMED AWAY, to
stand in for the share being unmounted, and it still moved the user's default -
which is the whole reason the script is staged to C:\ProgramData rather than run
from where it lives.

The guarantees against damage were re-checked rather than assumed: an empty
assignment changes nothing, an unreachable server changes nothing, -WhatIfOnly
leaves no queue, no task, no staged file and no registry value behind, and a
drifted queue is repointed IN PLACE with Set-Printer so whoever has it as their
default keeps it.

Not covered by any of this: the driver-staging path, which needs a real vendor
package rather than the class drivers a VM ships with.
2026-08-19 15:32:18 -04:00
cproudlock
1a5a1cd43d Correct a drifted print queue instead of leaving it wrong
A queue was matched by NAME alone, so a bay whose printer had moved, or whose
queue was built on a driver the site has since replaced, looked converged and
printed to the wrong device. Absence was fixed; drift was not.

Set-ShopdbPrinters.ps1 now repoints a queue whose port does not match the
address ShopDB holds, and swaps a queue built on the wrong driver. Both are
things ShopDB is authoritative about: where the printer IS, and what drives it.

CORRECTED IN PLACE with Set-Printer, never removed and recreated. The queue keeps
its name, its sharing, its permissions, and whoever has it as their default keeps
it - which is what makes this safe to run every cycle on a live floor. There is
still no removal code path in this script at all.

Two guards, because a repair that breaks a working printer is worse than drift:
the driver is only swapped when the wanted one is actually staged, and
-WhatIfOnly reports both kinds of correction without making either.

Verified on Windows against a queue that had the right name, the wrong port, the
wrong driver AND was the logged-on user's default: both fields were corrected and
the queue was still the default afterwards. The earlier no-op guarantees were
re-run and still hold - nothing assigned changes nothing, and an assignment with
no default leaves the user's own default alone.
2026-08-19 13:07:51 -04:00
cproudlock
e2c45d33bc One printer picker for machines and PCs, and one default per asset
The assignment belongs to the MACHINE, and until now there was no way to set it
except the generic relationships card or the API - the form for the thing the
feature is about did not exist. MachineForm now carries the picker, and PCForm
uses the SAME component rather than its own copy: the PC's set overrides the
machine's, and two implementations of that would drift, with the two ends of an
override disagreeing being exactly the bug nobody would spot.

The shared picker also fixes what PCForm did on save. It wrote row at a time
through the generic relationship endpoints, which is a non-atomic reconcile: an
HTTP failure part way left a PC half-assigned with nothing recording what was
meant. It now calls the reconcile endpoint, which validates the default before
writing anything.

A relationship type can now say it allows one active row per asset
(relationshiptypes.issingular, migration 7d34), and defaultprinter says it.
Cardinality belongs to the type rather than the printers plugin: core's create
path is where every hand-made link passes, and the next type meaning "exactly
one" gets the rule for free. Setting a second default REPLACES the first instead
of refusing, because "make this the default" means that - and a card answering
409 would leave the user hunting for the old row.

Without it the schema was happy to hold two defaults: the unique constraint is
(source, target, type), so two different targets are two valid rows, and the
resolver takes the OLDEST - the new default silently lost. Proven by disabling
the new rule and watching the tests fail.

FOUND WHILE TESTING IN A BROWSER, and it was not mine: MachineForm read
.data.data off computersApi.listAll(), which resolves to the ARRAY - fetchAllPages
has already unwrapped every page. The whole parallel load threw into the catch,
so every dropdown on the machine edit form came up empty and the machine's own
values never loaded. A build cannot see this; only opening the page can.

GET /api/printers/assignments/for-asset/<id> returns an asset's OWN assignment,
without inheritance, because the editor must show what this asset's rows say -
otherwise a machine's printers appear ticked on the PC that inherits them and
unticking one silently creates an override.
2026-08-19 11:22:48 -04:00
cproudlock
72b3904f71 Release 0.11.3
Some checks failed
CI / backend (push) Failing after 7m14s
CI / naming (push) Failing after 7m18s
CI / frontend (push) Failing after 7m9s
CI / migrations-mysql (push) Failing after 7m18s
The last of the buildings-and-levels bugs, and the shop-floor clients brought
into the product.

Anyone on 0.11.0 through 0.11.2 should take this. Every hover mini-map in the
product reported "this asset has a position but no level" - the levelid prop
added in 0.11.0 was passed by none of its seven call sites - and the map PDF
printed markers from every floor onto one sheet, which nobody can correct once it
is carried onto the floor. The legacy import loader, still to run against
production, created markers with no level at all.

The gate that should have caught all three asked whether a FILE mentions levelid
rather than whether each position does. It now checks per occurrence.

Also: printers can be assigned to a MACHINE and reach whichever PC controls it,
so a reimaged bay reinstalls its own printers with nothing saved off the old PC;
printerdrivers can name a vendor, so two rows cover 41 of 44 printers instead of
twenty-one near-duplicates; and the collector reporter and EventSaver now live in
the repository with no site baked into either, the reporter generated per site on
request.

The client scripts were validated on Windows 11 against a live ShopDB, not only
by the suite: a bay with no rows of its own created both queues from its
machine's assignment, bound them to the right universal drivers, and set the
per-user default.

The version and the changelog are the release; the detail is in the entry.
2026-08-19 10:56:52 -04:00
cproudlock
8cedf674fb Resolve a driver by vendor, and converge a bay's printers from ShopDB
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / backend (push) Has been cancelled
Two rows now cover 41 of 44 printers. printerdrivers could only bind a driver to
ONE modelnumberid, so the HP and Xerox universal drivers - which between them
cover almost the whole floor - would have needed 21 near-duplicate rows pointing
at the same package. That is a table nobody keeps true, and it is why 42 of 44
printers resolved no driver at all.

printerdrivers gains vendorid, and resolution runs most-specific-first: the
printer's model, then its vendor, then the pre-vendorid convention of matching
the vendor word in the driver's name so a site that populated the table before
the column existed does not silently lose every driver on upgrade. A row that
names a vendor is never matched by its text, because a mis-set vendor resolving
to the wrong package is worse than resolving to none.

Six rows now resolve 44 of 44 printers at the reference site, and the DesignJet
correctly takes its own driver over the HP universal one.

Set-ShopdbPrinters.ps1 is the client half: ask for-host, create the queues that
are missing, record the desired default. It NEVER removes a queue - a bad minute
from the API must not take printers away from a working bay - and it never
fetches a driver, because downloading 48 MB while somebody waits to print is the
wrong moment. The common scope stages those.

Apply-ShopdbDefaultPrinter.ps1 applies the default in the USER's context, which
is the only context that can: SYSTEM cannot set a per-user default for somebody
else. It also turns off "Let Windows manage my default printer", without which
Windows silently overwrites the choice the next time anyone prints elsewhere -
a fix that undoes itself within a day.

VALIDATED ON WINDOWS 11 AGAINST A LIVE SHOPDB, not only by tests. Printers were
assigned to a MACHINE; a PC controlling it, holding no rows of its own, created
both queues bound to the right universal drivers, recorded the default and set
it, and a second run changed nothing. The first attempt failed with
"Relationship types are not seeded - run: flask seed reference-data", which is
the deployment trap the plan predicted, caught by an explicit error rather than
silently resolving nothing.
2026-08-19 10:24:53 -04:00
cproudlock
0dc0ac13c8 Assign printers to a machine, and let the PC that drives it inherit them
Printers belong to the bay, not to the box currently driving it. The assignment
goes on the MACHINE asset and reaches whichever PC controls it, so a reimaged or
swapped PC comes back with the right printers and nothing had to be saved off the
old one. The asset register is the backup.

New relationship type usesprinter ("this printer is installed here"), beside the
existing defaultprinter ("which of them is the default"), both seeded and both
given a propagation rail through controls. The rails are consumed at READ time
only: the create-time fan-out skips directional through-types, and controls is
directional, so assigning a printer to a machine does not copy rows onto its PC.
That is what keeps own-beats-inherited possible.

Resolution for a PC is its OWN rows if it has any, otherwise one hop out along
controls to the machines it drives. Whole set at a time, not merged: a PC with
its own assignment is overriding the bay deliberately, and the UI has to say so
or a tech "fixing" a bay by editing the PC will shadow the machine's record and
wonder why they keep disagreeing.

GET /api/printers/for-host/<hostname> is what the convergence client asks every
cycle. Resolved by hostname because the collector upserts PCs by hostname and an
office PC has no machine number. An unknown host, a site without the computers
plugin, and nothing assigned all return an empty set - that is the client's
designed no-op and it must stay indistinguishable from "assigned nothing".

PUT /api/printers/assignments/for-asset/<id> reconciles the whole set in one
call. The endpoint was specified, documented and asserted by three tests, and
never written - the verification pass caught that, with four failures. It
validates the default BEFORE any write, so a rejected request changes nothing;
soft-deletes rows that went away; and REACTIVATES soft-deleted rows rather than
inserting, because the unique constraint spans inactive rows and a blind insert
after an unassign raises IntegrityError on MySQL while passing on SQLite.

One default per asset, enforced here because the schema cannot: the constraint is
(source, target, type), which accepts two different defaults quite happily. Two
active defaults are still reachable through the generic relationships endpoint,
where the oldest silently wins - recorded in the proposal as the next thing to
close.

printerdrivers gains drivername: the exact string the INF declares, which
Add-PrinterDriver matches on and nothing else. Deriving it by parsing INFs on
hundreds of bays is fragile; a human confirming it once is not.
2026-08-19 09:33:22 -04:00
cproudlock
03d0754fdc Stage printer drivers as a deployable set, for the common scope
Assigning a printer to a bay is useless if the bay cannot install it, and the
fleet data says why that mattered: 42 of 44 printers could not resolve a driver.
This is the delivery half - the drivers themselves, staged once per bay, so that
creating a queue never waits on a download.

Install-ShopdbPrinterDriver.ps1 does one driver: trust the package's signer, then
pnputil /add-driver, then Add-PrinterDriver. Install-ShopdbPrinterDrivers.ps1
does a site's whole set from drivers.json, and answers a compliance question with
-TestOnly, which is what makes it a clean DSC Script resource rather than a
fire-and-forget install.

Deliberately SEPARATE from assignment. Drivers are large, near-identical across a
fleet and change rarely; assignments are small, per-bay and change often. Staging
the set in the GE-Enforce common scope means the assignment client only ever
creates a queue - it never fetches a 48 MB package while somebody is waiting to
print, or discovers the share is unmounted at the worst moment.

THE SIGNER TRUST STEP IS THE WHOLE TRICK, and it took a real driver to find it.
certutil -addstore on the .cat file satisfied the Xerox package and failed every
HP INF with "The publisher of an Authenticode(tm) signed catalog has not yet been
established as trusted" - a coin toss, not a mechanism. The certificate is now
extracted with Get-AuthenticodeSignature and added to Trusted Publishers, for
every catalog under the package rather than the first INF's neighbours. On a
locked bay there is no prompt to answer, so the old failure was silent.

Verified on Windows against real packages, not by reading: all six drivers this
site needs install through the script, a second run is a no-op, a wrong driver
name fails with the names the package actually offers, and the DSC cycle behaves
- TestOnly exits 1 on a clean box, install exits 0, TestOnly then exits 0.

The packages themselves stay out of git: they are licensed vendor binaries, and
they belong on the share beside the other imaging payloads.

DEPLOYING-DRIVERS.md carries the GE-Enforce entry, the DSC configuration and the
Intune shape, plus the constraint that has cost a session before: the SFLD share
is mounted only during the enforcement cycle, so this runs as a manifest entry
and never as its own scheduled task.
2026-08-19 09:33:05 -04:00
cproudlock
2083029ff2 Generate the collector script per site, and bring EventSaver into the repo
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
A site adopting ShopDB had to be handed two files and told what to edit in them.
Both are now the product's, and one of them the server writes for you.

GET /api/computers/client-script (admin) returns Report-AssetToShopDB.ps1 with
this site's values already in it: site_base_url becomes the -ApiUrl default and
the new computers_routableranges setting becomes -AllowedRanges. Only the
PARAMETER DEFAULTS are substituted - the copy in plugins/computers/client/ stays
runnable, so there is no second version to drift from the first - and everything
stamped stays overridable by argument or registry, because a bay may need to
differ from its site. Settings > Computers > Asset reporter edits the ranges,
downloads the script and shows its SHA-256.

The collector key is deliberately not stamped in, and a test fails if it ever
is. That file lands on every shop-floor PC, and a token spread across hundreds
of bays cannot be rotated quietly; it stays in the registry, provisioned per
ADOPTING-AT-ANOTHER-SITE.md.

The routable ranges are the last thing that was hardcoded in that script. They
are now a setting, so West Jefferson's two CIDRs move out of source code and
into that site's own configuration - which is what ADR-015 asks for - and a site
that sets nothing still works, because the script falls back to the NIC carrying
the default route.

EventSaver joins it in plugins/slides/client/, source only: EventSaver.cs and
EventSaver.ini, no compiled .scr - a binary is a release asset, like the
installer exe. The share path that was compiled into Config.Folder is gone. It
used to be the fallback when the ini was missing, which silently pointed a new
site at the reference site's file server; it is now empty, and failing visibly
beats displaying another site's slides. Verified by compiling the edited source
in the Windows VM with the in-box csc.exe: 15,872 bytes, exit 0.

Also: the DSC example in the adoption guide gains a CollectorRanges resource and
stops passing -ApiUrl to a script that already reads BaseUrl from the registry
the same example writes, and the guide points at the generated download instead
of hand-editing a URL.

The contract test caught the endpoint importing shopdb directly for the version
string, which ADR-002 forbids a plugin from doing. The product and contract
versions are in app.config now, which a plugin reads through current_app.

Adds docs/proposals/printer-assignment.md: assign printers to a PC in ShopDB and
let the bay install them, with what the fleet data says about drivers - HP and
Xerox cover 41 of 44 printers with universal drivers, there are no Brother
printers at all despite 208 files of Brother inkjet drivers in the installer,
and printerdrivers holds one row pointing at a per-model folder instead of a
universal driver.
2026-08-18 15:51:14 -04:00
cproudlock
96f127f8c8 Bring the collector script into the repo, with no site in it
Report-AssetToShopDB.ps1 lived on one site's imaging share and was, per the
adoption guide, "provided on request" - which is not a distribution mechanism for
a product meant to be adopted. It now lives in plugins/computers/client/, beside
the collector contract it implements (ADR-006), so the two version together.

Three things named West Jefferson and no longer do (ADR-015):

- The server. It resolves from HKLM:\SOFTWARE\GE\ShopDB BaseUrl - the value
  Install-GEEnforce.ps1 already writes and the enforcement client cannot run
  without - or from -ApiUrl. With neither it logs and exits 0 rather than
  posting somewhere wrong. Any bay running this script runs the enforcement
  client, so the value is present wherever it is deployed.
- The corporate VLANs. Two hardcoded CIDRs decided which NIC's address was
  reported, with a comment reading "update if site re-VLANs". A site may now
  name its ranges (-AllowedRanges, or a CollectorRanges registry value); with
  none configured the NIC carrying the DEFAULT ROUTE is used, which expresses
  "the routable NIC, not the controller NIC" without knowing any site's
  addressing.

VERIFIED IN THE WINDOWS VM, not by reading it - and the VM earned its keep. The
local array was called $allowedRanges, which is the SAME VARIABLE as the [string]
parameter $AllowedRanges because PowerShell names are case-insensitive; the array
was silently coerced to an empty string, and .Count on a scalar string is 1. The
script therefore believed a range was configured, skipped the default-route
fallback, and reported no IP at all. Linux pwsh parsed it happily. Renamed to
$rangeList, and the four paths were then confirmed on Windows: no config skips
cleanly, BaseUrl resolves from the registry, an unconfigured site picks the
default-route NIC, a configured range selects or excludes as asked.
2026-08-18 09:59:51 -04:00
cproudlock
f34b9ca710 Carry the level everywhere a position is drawn, and gate it per occurrence
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 11s
CI / migrations-mysql (push) Failing after 7s
The hover mini-map said "This asset has a position (2835, 1410) but no level"
for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid
prop, NONE of its seven call sites were taught to pass one - printer, machine and
PC detail pages, the toner report, enforcement reports, the warranty chip and the
dashboard cards - so the component correctly reported a missing level and the
preview never drew. Two payloads behind those views also emitted mapx/mapy with
no level: the toner report and the enforcement report.

The map PDF export had the ORIGINAL bug still in it: it plotted every filtered
asset onto the sheet, so exporting the ground floor printed second-floor markers
on it. Worse than on screen, because nobody can correct a sheet once it has been
printed and carried onto the floor. It now exports only the level being viewed.

The legacy import loader sent mapleft/maptop with no level at three call sites.
That loader is the one still to run against production, and every marker it
created would have been undrawable. It now resolves the site's default level -
the legacy schema predates levels and has one floor plan, so that is what its
coordinates mean.

THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid',
not whether each position does: one module emitted 'mapx' six times and 'levelid'
once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/
and plugins/, and fails any Vue file that binds tooltip coordinates without
:levelid. Both new rules were confirmed to fail the build against planted
violations before being relied on.

Printer QR labels: the asset number is no longer printed. A label now reads name
(8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber
because that is where sites actually keep it - every printer here has an empty
name field, so preferring the Windows queue name alone would have printed a blank
line on every label.
2026-08-18 09:36:45 -04:00
cproudlock
8bde89c47e Release 0.11.2
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
Bug fixes for the buildings-and-levels work in 0.11.0, every one of them found by
using the feature rather than by the suite.

Anyone running 0.11.0 or 0.11.1 with more than one level should take this
release: the map drew markers from every level on whichever floor plan happened
to be showing, which is the failure ADR-017 exists to prevent, and it was in the
one component that draws the map. The viewer also had no way to choose a level,
and the editor never accepted a click at all - its handler was bound only if the
map was already a picker at mount, and the editor opens with nothing selected.

Also: a level's native size could not be set (the settings page had no height
field), the same search could return different rows because fourteen searchers
truncated without an ORDER BY, and both map pages carried a scrollbar from
subtracting the wrong page chrome from the viewport height.

No schema change, and the plugin contract stays at 0.20.0.

The version and the changelog are the release; the detail is in the entry.
2026-08-17 15:46:38 -04:00
cproudlock
afd3dce493 Give the map editor's search box room for its own placeholder
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 previous commit stopped the panel header from colliding with the "Assets"
heading, but left the search box sharing a row with the type dropdown: 139px of
input for a placeholder needing 216px, so it still read "Search name o". A
control whose own label does not fit is not a narrower control, it is an
unlabelled one.

One control per row in a 318px panel. Measured in the browser: the input is now
286px against 216px of text, so the placeholder reads in full.
2026-08-17 15:24:37 -04:00
cproudlock
89248407e7 Make the map editor accept a click, and unclutter its panel
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
Clicking the map in the editor did nothing, ever. The click handler was bound
inside `if (props.pickerMode)` at mount, and the editor mounts with no asset
selected, so the handler was never attached; selecting an asset flipped the prop
but nothing rebound it. The asset forms were unaffected because their picker
mounts inside a modal that is already in picker mode, which is why this looked
like an editor-only fault. The handler is now bound unconditionally and
handleMapClick keeps its own picker-mode guard.

Verified in a browser against the dev instance, both ways: with the old binding
a click on a selected asset produced no position at all; with the fix the same
click reports 1652, 1138.

Markers are now drawn in picker mode too. Placing one relative to the machines
already on the floor is the entire task, and the old code skipped rendering them
whenever the map was a picker.

Two things that looked like stray widgets:
- The editor's panel header put a heading and three controls on one row inside a
  320px panel, squeezing the search box until its placeholder read "Search na".
  The heading takes its own row and the controls share the next.
- The legend drew its bar and border even with nothing to put in it, which read
  as an empty input box under the toolbar. It renders only when it has entries.
2026-08-17 15:20:44 -04:00
cproudlock
aa6db94179 Fix the levels viewer: markers were drawn on whichever plan was showing
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s
Five defects, four of them mine from 0.11.0, found by using the feature.

THE SERIOUS ONE: ShopFloorMap never checked a marker's level. It skipped null
coordinates and drew everything else on whatever blueprint was displayed, so
level 1 markers appeared on level 2 - the exact failure ADR-017 exists to
prevent, in the one component that draws the map. The build gate did not catch it
because that rule checks files EMITTING mapx, not the component consuming it.
Markers are now filtered to the drawn level, and a position with no level is
omitted rather than approximated.

The map page had no way to choose a level at all. It read currentlevelid only to
title the PDF, so a second floor was unreachable from the viewer that most people
use. Adds a level selector (hidden when a site has one level), passes it to the
map, and switches the drawing, the bounds, the coordinate space and the markers
together - swapping the image without the bounds would place every marker against
the wrong scale.

Searching the map now follows results across levels: a search whose matches are
all on another floor showed an empty map while the filter counted them.

Floor map settings had NO height input - only width - so a level's native size
could not be set even while empty, which is the one time it is editable. Both
fields are there now, and size is editable on a level that has markers, because
refusing it blocked the case the feature was built for: a new blueprint of new
dimensions on a floor already full of markers. It confirms first and points at
landmark recalibration.

Search results differed between the sidebar box and the results-page box:
- 14 of 16 searchers truncated with .limit() and no ORDER BY, so the database
  could return a DIFFERENT subset of matching rows for the same query. Every
  searcher now ends in a total order (display key plus primary key).
- Searching a term already in the URL was a duplicate navigation the router
  aborts, so the route watcher never fired and the button did nothing. The
  sidebar never hit this, because it always navigates from another page - which
  is why the two boxes appeared to disagree.

Also removes a scrollbar from both map pages. They subtracted 2rem and 40px from
100vh for the page chrome, which is really 90px of padding on .main-content, so
each overflowed by the difference. The padding is now a CSS variable both the
layout and the pages read. Measured in the browser before and after: 1058 vs a
1000px viewport, now 1000.
2026-08-17 14:59:44 -04:00
cproudlock
dd503be4ba Release 0.11.1
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
A patch release. The pagination-cap fixes, and the documentation that missed the
0.11.0 installer by a few minutes.

docs/UPGRADE.md is one of the documents the Windows installer puts on the server,
and its buildings-and-levels section was committed after 0.11.0 was tagged. So
0.11.0 shipped the page an operator reads WHILE upgrading without the guidance
about that upgrade - which matters most on an air-gapped box, where there is no
other way to reach it. The installer now also ships FLOOR-MAP.md for the same
reason the Windows runbooks ship.

No application code changed on the server side and the plugin contract stays at
0.20.0.

The version and the changelog are the release; the detail is in the entry.
2026-08-17 14:38:59 -04:00
cproudlock
5f5ed7b1d4 Record the pagination-cap fixes in the changelog
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 8s
2026-08-17 14:17:21 -04:00
cproudlock
62f4a42210 fix: page past the 100-row cap in batch label sheets and asset pickers
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
Follow-up to the application-picker fix. Three of these were already wrong on
data that exists today, not merely latent.

Batch label printing is the worst of them: AssetLabelBatch asked for 500
machines or PCs, got 100, and printed a sheet that looked complete. With 262
machines and 290 PCs in the catalogue that is a physically short run with no
error anywhere - the operator finds out at the label printer, or later at the
bay with no label on it. PrinterQRBatch, USBLabelBatch and PrintedPartsLabels
had the same shape and are fixed alongside it, before their tables cross 100
too.

MachineForm's "controls" PC dropdown offered the first 100 of 290, so a
machine could not be linked to a PC sorting late in the list. NetworkDeviceForm
had it for models, which are already past 100 - and the same file already
called modelsApi.listAll() correctly two lines away.

Adds listAll() to the machines, computers, printers, network, measuring-tools,
USB and printed-parts APIs, all delegating to fetchAllPages().

Still outstanding: callers of vendors, locations, business units and the type
catalogues that ask for more than 100. Those tables are all well under the cap
today, so they are correct for now and wrong the day they are not.
2026-08-17 14:10:08 -04:00
cproudlock
9c1c6c5729 fix: page past the 100-row cap in application pickers
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 8s
get_pagination_params clamps perpage to MAX_PAGE_SIZE (100) and reports
nothing about having done so, so a caller asking for perpage: 1000 gets the
first 100 rows and a success response. Every picker built that way looked
complete and was not.

Found on a live site with 126 active applications: the 26 sorting last were
absent from the knowledge-base topic dropdown, so an article could not be
filed against them. Nothing was wrong with those application records, and
editing them could never have helped.

Adds fetchAllPages() to the api module, generalizing the one call site that
already handled this correctly (modelsApi.listAll), and points the four
application pickers at a new applicationsApi.listAll(): the KB article form,
the KB list's topic filter, the notification form, and the report filter
builder.

Lists that render a page at a time are untouched - they page for a reason.
Other callers still asking for more than 100 rows of vendors, locations,
models, subnets and the rest are latent: correct only while those tables stay
under 100, and silent on the day they do not.
2026-08-17 14:06:35 -04:00
cproudlock
741dda5be7 Document buildings and levels where the old single-map model was still taught
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
The 0.11.0 release changed what a map position means and six documents still
described the model it replaced. Each of these could have caused a real mistake
rather than being merely out of date:

- IMPORT-API mapped legacy mapleft/maptop to mapx/mapy with no mention of the
  level, so a scripted import - including the classic-ASP one still to run
  against production - would have produced markers the map shows as "level
  unknown". It now maps levelid too and says how to resolve the default level.
- API-REFERENCE enumerates the unauthenticated surface in full, because that is
  what a deploy reviewer reads, and the three public /api/maplevels reads were
  missing from it. Also records why the write split is asymmetric: repositioning
  needs assets.edit, creating a level needs admin, since a level's dimensions are
  the coordinate space every marker on it is expressed in.
- CONFIG still presented the four map_* settings as live, telling the reader to
  re-upload a blueprint in a settings page that no longer drives the map. They
  are marked superseded and kept for downgrade.
- UPGRADE gained a 0.11.0 section: nothing moves on screen, and replacing a
  blueprint with one of different dimensions moves every marker on that level, so
  recalibrate from landmarks rather than editing width and height.
- PLUGIN-HOOKS now states that a map overlay keys on assetid and must not return
  coordinates or a level - a second copy of a position is one that can disagree.

Adds FLOOR-MAP.md, the operator's page: loading a plan, placing markers, and
what to do when the plan changes, with the reasoning left in ADR-017. START-HERE
routes to it from the new-site path, and specifically as the page to read BEFORE
a floor plan changes.
2026-08-17 13:35:57 -04:00
677 changed files with 95566 additions and 4706 deletions

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
# Keep the build context lean and prevent host artifacts from clobbering
# in-image installs. node_modules is platform-specific: copying the host's
# (Windows) tree over the container's `npm ci` breaks rollup's native binary.
**/node_modules
frontend/dist
frontend/src/.plugins-staged
frontend/src/router/routes.gen.js
# Python caches and virtualenvs
**/__pycache__
**/*.pyc
.venv
venv
# Local dev and VCS noise
.git
.gitignore
*.log
.env.local
# Installer build artifacts (not part of the app image)
installer-arch-dev/build
installer-arch-dev/dist

BIN
.env

Binary file not shown.

View File

@@ -4,7 +4,7 @@
# refuses to boot when SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, or # refuses to boot when SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, or
# CORS_ORIGINS are missing or use the dev defaults. # CORS_ORIGINS are missing or use the dev defaults.
# #
# See docs/DEPLOY.md for the full per-site deployment runbook. # See the DEPLOY wiki page for the full per-site deployment runbook.
# ---- Flask ---- # ---- Flask ----
FLASK_APP=wsgi.py FLASK_APP=wsgi.py
@@ -25,7 +25,7 @@ JWT_SECRET_KEY=change-this-to-another-secure-random-string
# Format: mysql+pymysql://<user>:<password>@<host>:<port>/<database>?charset=utf8mb4 # Format: mysql+pymysql://<user>:<password>@<host>:<port>/<database>?charset=utf8mb4
# In docker-compose, host is `db` (the service name). # In docker-compose, host is `db` (the service name).
# The ?charset=utf8mb4 keeps the connection on utf8mb4; create the database as # The ?charset=utf8mb4 keeps the connection on utf8mb4; create the database as
# utf8mb4 too (see docs/DEPLOY.md). Both must be utf8mb4 to match the schema. # utf8mb4 too (see the DEPLOY wiki page). Both must be utf8mb4 to match the schema.
DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8mb4 DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8mb4
# ---- CORS (required, no wildcards in production) ---- # ---- CORS (required, no wildcards in production) ----
@@ -36,6 +36,18 @@ DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8m
# Wildcard '*' is rejected by ProductionConfig.validate(). # Wildcard '*' is rejected by ProductionConfig.validate().
CORS_ORIGINS=http://localhost:5173 CORS_ORIGINS=http://localhost:5173
# ---- Reverse proxy (optional, default 0 = no proxy) ----
# Set to the number of proxies actually in front of this instance - 1 when IIS
# or nginx serves it under a subpath such as /shopdb. Without it the app sees
# the proxy's connection instead of the browser's (scheme http, the proxy's
# host and IP), so any absolute URL it builds is wrong at the browser. That is
# what made a redirect on the endpoints list unfollowable behind IIS.
#
# Leave it at 0 when nothing fronts the app. It makes X-Forwarded-* trusted,
# and a client can send those headers itself - the login rate limit and the
# account lockout are keyed on the client address.
TRUSTED_PROXY_COUNT=0
# ---- JWT lifecycle (optional, defaults shown) ---- # ---- JWT lifecycle (optional, defaults shown) ----
JWT_ACCESS_TOKEN_EXPIRES=3600 JWT_ACCESS_TOKEN_EXPIRES=3600
JWT_REFRESH_TOKEN_EXPIRES=2592000 JWT_REFRESH_TOKEN_EXPIRES=2592000
@@ -64,6 +76,7 @@ ZABBIX_TOKEN=
# COLLECTOR_API_KEY_<PLUGINNAME> first, then COLLECTOR_API_KEY as fallback. # COLLECTOR_API_KEY_<PLUGINNAME> first, then COLLECTOR_API_KEY as fallback.
# COLLECTOR_API_KEY= # COLLECTOR_API_KEY=
# COLLECTOR_API_KEY_COMPUTERS= # COLLECTOR_API_KEY_COMPUTERS=
# COLLECTOR_API_KEY_ENDPOINTS=
# ---- Trusted plugin publisher keys (ADR-013, optional) ---- # ---- Trusted plugin publisher keys (ADR-013, optional) ----
# Public-key PEM paths (OS path separator: ':' on Linux, ';' on Windows) used # Public-key PEM paths (OS path separator: ':' on Linux, ';' on Windows) used
@@ -107,5 +120,5 @@ ZABBIX_TOKEN=
# Serve the app under a URL prefix instead of the server root, e.g. as an IIS # Serve the app under a URL prefix instead of the server root, e.g. as an IIS
# Application at /ops under an existing site. The frontend must be rebuilt with # Application at /ops under an existing site. The frontend must be rebuilt with
# the matching base: VITE_BASE_PATH=/ops/ npm run build. Leave unset when the # the matching base: VITE_BASE_PATH=/ops/ npm run build. Leave unset when the
# app owns its own site/port (the default). See docs/INSTALL-WINDOWS-IIS.md. # app owns its own site/port (the default). See the INSTALL-WINDOWS-IIS wiki page.
# MOUNT_PATH=/ops # MOUNT_PATH=/ops

6
.gitattributes vendored
View File

@@ -4,3 +4,9 @@
*.sh text eol=lf *.sh text eol=lf
scripts/check-naming-and-style.sh text eol=lf scripts/check-naming-and-style.sh text eol=lf
.githooks/pre-commit text eol=lf .githooks/pre-commit text eol=lf
# Batch files must be CRLF. cmd.exe parses a lone LF inconsistently around
# labels and goto, so an LF-only .cmd can run its happy path and then jump
# nowhere on the error path - which is the path that matters in a promote
# script.
*.cmd text eol=crlf

View File

@@ -24,6 +24,10 @@ name: CI
on: on:
push: push:
pull_request: pull_request:
# Lets a run be retried from the Actions page without inventing a commit.
# Gitea's API exposes no rerun endpoint, so without this the only way to
# re-test a runner-side change is to push something.
workflow_dispatch:
jobs: jobs:
backend: backend:
@@ -31,12 +35,23 @@ jobs:
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python
# Pin the interpreter instead of inheriting the runner image's default.
# requirements.txt is hash-pinned and was compiled on 3.14; on an older
# Python, alembic additionally requires tomli, which has no hash in the
# lock file and pip then refuses the whole install.
uses: actions/setup-python@v5
with:
python-version: '3.14'
- name: Install dependencies - name: Install dependencies
run: | run: |
python3 --version python3 --version
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install --upgrade pip .venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt .venv/bin/pip install -r requirements.txt
# pytest lives in requirements-dev.txt, not requirements.txt. Without
# this the test step dies with "No module named pytest".
.venv/bin/pip install -r requirements-dev.txt
- name: Run backend tests - name: Run backend tests
run: .venv/bin/python -m pytest run: .venv/bin/python -m pytest
@@ -77,21 +92,48 @@ jobs:
--health-cmd="mysqladmin ping -h localhost -uroot -proot" --health-cmd="mysqladmin ping -h localhost -uroot -proot"
--health-interval=5s --health-timeout=5s --health-retries=20 --health-interval=5s --health-timeout=5s --health-retries=20
env: env:
DATABASE_URL: mysql+pymysql://root:root@127.0.0.1:3306/shopdb_ci?charset=utf8mb4 # 'mysql' is the service name, not 127.0.0.1. Under a container-mode
# runner the job is itself a container on the services network, so the
# service is reached by name. Published ports land on the runner host,
# not on the job's localhost the way they do on GitHub-hosted runners.
DATABASE_URL: mysql+pymysql://root:root@mysql:3306/shopdb_ci?charset=utf8mb4
SECRET_KEY: ci-secret SECRET_KEY: ci-secret
JWT_SECRET_KEY: ci-jwt-secret JWT_SECRET_KEY: ci-jwt-secret
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python
# Pin the interpreter instead of inheriting the runner image's default.
# requirements.txt is hash-pinned and was compiled on 3.14; on an older
# Python, alembic additionally requires tomli, which has no hash in the
# lock file and pip then refuses the whole install.
uses: actions/setup-python@v5
with:
python-version: '3.14'
- name: Install dependencies - name: Install dependencies
run: | run: |
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install --upgrade pip .venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt .venv/bin/pip install -r requirements.txt
- name: Drop the git-crypt .env
# .env is committed git-crypt encrypted. CI has no key, so the checked
# out file is the raw encrypted blob and python-dotenv dies reading it
# as UTF-8. This job supplies DATABASE_URL, SECRET_KEY and
# JWT_SECRET_KEY through env: already, so the file is not needed.
run: rm -f .env
- name: Force utf8mb4 on the CI database - name: Force utf8mb4 on the CI database
# Uses pymysql, already a runtime dependency, rather than the mysql CLI,
# which is not present in every runner image.
run: | run: |
mysql -h 127.0.0.1 -uroot -proot -e \ .venv/bin/python - <<'PY'
"ALTER DATABASE shopdb_ci CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" import pymysql
c = pymysql.connect(host="mysql", port=3306, user="root", password="root")
with c.cursor() as cur:
cur.execute("ALTER DATABASE shopdb_ci CHARACTER SET utf8mb4 "
"COLLATE utf8mb4_unicode_ci")
c.close()
PY
- name: Fresh core upgrade from empty - name: Fresh core upgrade from empty
run: .venv/bin/flask db upgrade run: .venv/bin/flask db upgrade
- name: Install every bundled plugin (runs its chain) - name: Install every bundled plugin (runs its chain)

86
.github/agent-workflow.md vendored Normal file
View File

@@ -0,0 +1,86 @@
# Agent workflow rules
Companion to `copilot-instructions.md` (which owns naming/style/architecture).
These cut wasted turns. All are lessons from real sessions.
## Environment - know these, do not rediscover
- Shell is **PowerShell**: use `;` not `&&`, `2>$null` not `2>/dev/null`.
- API container `shopdb-flask-wilm-api-1` (Flask, port `5001`).
- DB container `shopdb-flask-wilm-db-1` (MySQL 8, root pw `root_password_123`).
- SPA is **baked into the image** (not bind-mounted). Plugin code is
**bind-mounted read-only**.
- `docs/` is stripped to a public wiki mirror - keep working docs out of it.
- `requirements.txt` is hash-checked - never hand-edit.
## Verify, don't react
- The terminal "Command failed" banner fires on any non-zero exit or stderr
output and is **usually a false negative** (`curl --fail` on a 400,
pytest/npm/git writing to stderr). Judge success from real output.
- Read container health via
`docker inspect --format '{{.State.Health.Status}}'` plus a probe **without**
`--fail` - never eyeball the exit code.
## Container runs the image, not your edits
Before running anything in the container that must reflect a change:
1. Frontend: `npm run build`, then
`docker cp frontend/dist/. shopdb-flask-wilm-api-1:/app/frontend/dist`.
2. Backend/scripts/templates: `docker cp` the files (or rebuild) first.
If a result contradicts a change you just made, suspect stale container code
before re-deriving the fix.
## Testing
- Confirm the working directory once, up front.
- Run the smallest test that proves the point; find the minimal polluter for
isolation bugs. Run the full suite only at the end.
- Clear stale local state before plugin/migration tests:
`if (Test-Path instance/plugins.json) { Remove-Item instance/plugins.json -Force }`.
- Naming-gate hits inside local venvs (`.ci-venv/`, `env/`) are not repo code -
do not "fix" vendored libraries.
- Final gates, exactly as CI: `python -m pytest tests/ -q`;
`cd frontend; npx vitest run; npm run build; cd ..`;
`bash scripts/check-naming-and-style.sh`.
## Migrations and imports
- Every migration: idempotent guard + real downgrade; must run clean on strict
MySQL 8. If you rename indexes in `upgrade()`, update `downgrade()` to match.
- Avoid duplicate indexes: `index=True` plus an explicit `db.Index` on the same
column creates two.
- Importers/seeders match by natural key and must be idempotent - prove it by
running twice (expect `updated`, not `created`). Watch all-or-nothing writes.
## Don't offer rollbacks you don't need
State current state plainly; surface a rollback path only for a real regression.
## Wilmington (wilm) local-run known failures - do not re-investigate
Running the full `python -m pytest tests/ -q` on a Windows dev box (not CI)
produces a stable set of environmental failures that are NOT code regressions.
Treat these as expected locally; judge your change against CI/Linux instead:
- `test_core/test_security_headers.py::test_a_served_upload_is_sandboxed` -
`PermissionError [WinError 32]` (served upload file still locked by another
process). Windows file-lock timing, not a headers bug.
- `test_plugin_contract.py::test_import_scan_covers_symlinked_plugins` -
`OSError [WinError 1314]` (symlink privilege not held). Needs admin/Developer
Mode to create symlinks; irrelevant to plugin logic.
- `test_plugin_importguard.py::test_verified_source_signed_ok_tampered_raises` -
CRLF vs LF (`b'# plugin\r\n' == b'# plugin\n'`). Git autocrlf checkout, not a
signing bug.
- `test_docs_publishable.py` and `test_docs_versions.py` - the local `docs/`
tree is not the stripped public wiki mirror, so doc-count / PROJECT-MAP
assertions fail. Expected off-CI.
- `test_core/test_public_read_surface.py::test_every_get_route_is_gated_or_named_public`
- pre-existing ungated-GET inventory drift; only act on it if YOUR change
added a new GET route.
If a run shows ONLY these, the suite is green for our purposes. Anything outside
this list is real - investigate it.

View File

@@ -4,6 +4,16 @@ Follow these when suggesting code. They are enforced by a naming/style hook and
by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build. by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build.
`CONTRIBUTING.md` is the full authority; this is the short version. `CONTRIBUTING.md` is the full authority; this is the short version.
## General Guidelines
- Finish the in-flight task before starting new workstreams.
- When asked to "take notes" on a future idea, capture it in the notes file only - do not implement it, and do not expand on it at length in replies.
- Use a temp file for multi-line git commit messages instead of heredoc syntax: `git commit -F <file>`.
- Avoid using `powershell.exe -File` for launching scripts, as it fails if the script file is missing or quarantined.
## Installer Logic
- The endpoint agent installer shipped to endpoints is generated by `_install_script()` in `plugins/endpoints/api/siteagent.py`, NOT `agent/installer/install-shopdbagent.ps1`. Any installer logic change must be made in `siteagent.py`, followed by rolling the containers.
- The user's workflow: the agent hands over only the built EXEs (`shopdbagent.exe`, `shopdb-endpoint-tray.exe`); the user uploads them via the "Upload New/Legacy Agents" field in the Build Site Agent UI and builds the package with the newest version selected at the top.
## Naming (LOCKED - the hook rejects violations) ## Naming (LOCKED - the hook rejects violations)
- **DB tables**: lowercase, concatenated, plural. No underscores, no dashes. - **DB tables**: lowercase, concatenated, plural. No underscores, no dashes.
@@ -67,11 +77,17 @@ name. Spell out: `config context manager request response environment utilities`
`migrations/versions/`; each plugin owns its own chain under `migrations/versions/`; each plugin owns its own chain under
`plugins/<name>/migrations/`. New schema = a new migration with an idempotent `plugins/<name>/migrations/`. New schema = a new migration with an idempotent
guard and a real downgrade. Migrations must run clean on strict MySQL 8. guard and a real downgrade. Migrations must run clean on strict MySQL 8.
Any model column/table change ships WITH its migration in the SAME change, and
bringing up a new image MUST run `flask db upgrade` then
`flask plugin upgrade-all` before serving traffic. A model that references a
column its migration has not applied yet 500s every query on that table
(`1054 Unknown column`), so code and schema must never be deployed out of step.
- **Asset model is the platform contract.** Physical things are an `Asset` plus a - **Asset model is the platform contract.** Physical things are an `Asset` plus a
plugin subtype row linked by `assetid` (FK, `ON DELETE CASCADE`). Consumables plugin subtype row linked by `assetid` (FK, `ON DELETE CASCADE`). Consumables
with quantities are standalone tables, not assets. with quantities are standalone tables, not assets.
- **Ledger pattern**: a cached `quantityonhand` moves in the SAME commit as the - **Ledger pattern**: a cached `quantityonhand` moves in the SAME commit as the
signed transaction row it reflects. signed transaction row it reflects.
- **Configuration for endpoint agent**: The endpoint agent's live collector key and base URL are provisioned in the Windows registry `HKLM\SOFTWARE\GE\ShopDB` (values `CollectorKey`, `BaseUrl`), not committed in `appsettings.json`; `appsettings.json` holds only non-secret runtime settings.
## Before you finish a change ## Before you finish a change

View File

@@ -1,9 +1,11 @@
# CI for shopdb-flask on GitHub Actions. # CI for shopdb-flask on GitHub Actions.
# #
# Mirrors the internal CI pipeline. Four jobs on push + pull_request: # Mirrors the internal CI pipeline. Jobs on push + pull_request:
# backend - pytest (SQLite via TestingConfig, no DB service needed) # backend - pytest (SQLite via TestingConfig, no DB service needed)
# naming - the CONTRIBUTING.md naming/style gate # naming - the CONTRIBUTING.md naming/style gate
# gates - proves those checks actually RAN rather than skipping
# frontend - vitest + Vue build # frontend - vitest + Vue build
# lean-build - ADR-013: a per-site build carries only its plugins
# migrations-mysql - the REAL multi-site deploy path: fresh flask db upgrade # migrations-mysql - the REAL multi-site deploy path: fresh flask db upgrade
# + every plugin's chain on utf8mb4 MySQL 8, idempotent on # + every plugin's chain on utf8mb4 MySQL 8, idempotent on
# a second run. The pytest suite only exercises SQLite # a second run. The pytest suite only exercises SQLite
@@ -12,6 +14,11 @@
name: CI name: CI
# Action versions are the Node 24 native releases (checkout@v5, setup-node@v5,
# setup-python@v6). The v4/v5 generation targets Node 20, which the runners now
# force onto Node 24 with a deprecation warning on every job - working today,
# a hard failure whenever GitHub drops the shim.
#
# Jobs run on the org's self-hosted "arc-runner-set" (enterprise # Jobs run on the org's self-hosted "arc-runner-set" (enterprise
# ge-aerospace-runner-group, Linux). GitHub-hosted runners are blocked by the # ge-aerospace-runner-group, Linux). GitHub-hosted runners are blocked by the
# org IP allow list (hosted Azure runner IPs are not allow-listed -> checkout # org IP allow list (hosted Azure runner IPs are not allow-listed -> checkout
@@ -22,12 +29,18 @@ on:
push: push:
pull_request: pull_request:
# A second push supersedes the first: the older run answers a question nobody is
# asking any more, and it holds a self-hosted runner while doing it.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
backend: backend:
runs-on: arc-runner-set runs-on: arc-runner-set
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/setup-python@v5 - uses: actions/setup-python@v6
with: with:
python-version: '3.14' python-version: '3.14'
cache: pip cache: pip
@@ -37,17 +50,35 @@ jobs:
naming: naming:
runs-on: arc-runner-set runs-on: arc-runner-set
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- run: bash scripts/check-naming-and-style.sh - run: bash scripts/check-naming-and-style.sh
gates:
# Prove the gates RAN. Several skip themselves when the file they read is
# absent, which is correct in this checkout (docs/ becomes the wiki) and was
# silently wrong here for months: the modules skipped, CI went green, and an
# outside branch added nine routes documented nowhere and passed.
#
# A gate that stops running is worse than no gate, because nobody looks at
# it again.
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.14'
cache: pip
- run: pip install -r requirements-dev.txt
- run: bash scripts/check-gates-ran.sh
frontend: frontend:
runs-on: arc-runner-set runs-on: arc-runner-set
defaults: defaults:
run: run:
working-directory: frontend working-directory: frontend
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/setup-node@v4 - uses: actions/setup-node@v5
with: with:
node-version: '20' node-version: '20'
cache: npm cache: npm
@@ -62,8 +93,8 @@ jobs:
# code is absent from the bundle - the delete-a-plugin guarantee in CI. # code is absent from the bundle - the delete-a-plugin guarantee in CI.
runs-on: arc-runner-set runs-on: arc-runner-set
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/setup-node@v4 - uses: actions/setup-node@v5
with: with:
node-version: '20' node-version: '20'
cache: npm cache: npm

103
.github/workflows/publish-images.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
# Build and publish per-channel container images to GHCR.
#
# WHY this exists: pilot dev workstations sit behind an inspecting proxy
# (Zscaler) that resets npm's many small fetches mid-install, so building the
# image FROM SOURCE on a pilot box is unreliable. These runners
# (arc-runner-set) install npm cleanly - the CI `frontend` job already proves
# it - so we build the image ONCE here and pilots pull the finished image.
# A single image pull is one resumable HTTPS stream, far more robust through
# the proxy than hundreds of npm requests.
#
# Channels are mapped to how THIS repo actually ships, which is not how the
# branch this came from ships. Upstream, main is a release branch and dev-be is
# the trunk. Here main IS the trunk - it runs hundreds of commits past the last
# release - and releases are tags (ADR-007). Publishing main as :stable would
# put unreleased work behind a word that promises the opposite, to sites that
# have no way to know:
#
# tag v* -> ghcr.io/<owner>/shopdb-flask:stable (badge off) the release
# main -> ghcr.io/<owner>/shopdb-flask:edge (DEV badge) the trunk
#
# The image also carries a short-SHA tag for traceability and rollback.
#
# Automatic publishing is OFF until someone confirms the GHCR package exists
# under this owner and decides who may pull it. Re-enable by uncommenting the
# push: block below; workflow_dispatch works meanwhile.
name: publish-images
on:
# push:
# tags:
# - 'v*'
# branches:
# - main
# Manual rebuild/publish of the current ref's channel image.
workflow_dispatch:
# One in-flight publish per branch; a newer push supersedes an older one.
concurrency:
group: publish-images-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-push:
runs-on: arc-runner-set
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v5
# Lowercase the owner: GHCR repository paths must be lowercase, but
# github.repository_owner can carry uppercase (e.g. ge-aero is fine, but
# this keeps the workflow correct for any owner casing).
- name: Resolve image name and channel tag
id: meta
run: |
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
image="ghcr.io/${owner}/shopdb-flask"
# A tag is a release, anything else is the trunk. Deliberately not
# keyed on the branch name: a release here is a tag, so keying on
# "main" would label the trunk stable.
if [ "${{ github.ref_type }}" = "tag" ]; then
channel="stable"
else
channel="edge"
fi
echo "image=${image}" >> "$GITHUB_OUTPUT"
echo "channel=${channel}" >> "$GITHUB_OUTPUT"
echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Connect to a persistent, out-of-cluster BuildKit deployment instead of
# spinning up a builder against a local Docker daemon. The arc-runner-set
# pods have no Docker daemon (no /var/run/docker.sock) and cannot run a
# privileged DinD sidecar under cluster policy, so we point Buildx at a
# remote buildkitd Service. The endpoint is provided as a repo/org secret
# (e.g. tcp://buildkit.buildkit.svc.cluster.local:1234), so it is not
# hardcoded here. See deploy/ci/buildkit-remote.yaml for the deployment.
- name: Set up Buildx (remote BuildKit)
uses: docker/setup-buildx-action@v3
with:
driver: remote
endpoint: ${{ secrets.BUILDKIT_ENDPOINT }}
- name: Build and push channel image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.channel }}
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.channel }}-${{ steps.meta.outputs.sha }}
# Reuse the channel tag as a cache source/target so subsequent builds
# only redo layers that actually changed.
cache-from: type=registry,ref=${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.channel }}
cache-to: type=inline

View File

@@ -0,0 +1,32 @@
# Darkwing vulnerability scan, manual dispatch.
#
# Copied verbatim from the ge-aero/darkwing README so it stays byte-identical
# to the sanctioned integration. Do not edit this file to add triggers or
# gating - see scan-darkwing-pr.yml for that, which is ours to change.
#
# The file must sit on the default branch before a scan can run on ANY branch.
name: Scan (darkwing, portable)
on:
workflow_dispatch:
inputs:
target_ref:
description: Ref, branch, or sha of this repo to scan
required: false
type: string
default: main
permissions:
contents: read # checkout this repo via the ambient GITHUB_TOKEN
security-events: write # SARIF upload to the Security tab
actions: read # required by upload-sarif on private/internal repos
jobs:
scan:
runs-on: darkwing-scan
steps:
# @v1 is a moving tag pointing to the latest release that is compatible
# with this workflow file.
- uses: ge-aero/darkwing@v1
with:
target_ref: ${{ inputs.target_ref || 'main' }}

62
.github/workflows/scan-darkwing-pr.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
# Darkwing scan on pull requests, so the "check against darkwing" is a check
# and not a thing somebody remembers to click.
#
# WHY THIS IS SEPARATE from scan-darkwing-portable.yml: that file is copied
# byte-for-byte from the ge-aero/darkwing README and is left alone so it keeps
# matching the sanctioned integration. Everything below is ours.
#
# THE TRAP THIS AVOIDS. The README workflow resolves its scan target as
# `inputs.target_ref || 'main'`. On a pull_request event there are no inputs,
# so that expression falls through to 'main' and the job scans the default
# branch instead of the code under review - a green Darkwing check that never
# looked at the change. Resolve the head SHA explicitly.
#
# Darkwing exits 0 whatever it finds (its own FAQ: "Does a finding fail my
# build? No."), so a passing job means the scan RAN, not that the code is
# clean. Findings land in Security -> Code scanning. Turning a finding into a
# hard failure means reading findings.sarif ourselves; the commented step at
# the bottom is the shape of that, deliberately left off until we have seen the
# false-positive rate on this repo.
name: Scan (darkwing, PR)
on:
pull_request:
permissions:
contents: read
security-events: write
actions: read
# One scan per PR. A new push supersedes the old one rather than queueing a
# second Bedrock spend against a diff nobody is reviewing any more.
concurrency:
group: darkwing-pr-${{ github.ref }}
cancel-in-progress: true
jobs:
scan:
runs-on: darkwing-scan
steps:
- uses: ge-aero/darkwing@v1
with:
# The PR's head, NOT the default branch. See the note above.
target_ref: ${{ github.event.pull_request.head.sha }}
# Gating, once the signal is trusted. Darkwing writes findings.sarif into
# its work dir and attaches it as the `findings` artifact; this reads the
# severities out and fails on error-level results only.
#
# - name: Fail on error-level findings
# run: |
# python - <<'PY'
# import json, pathlib, sys
# sarif = pathlib.Path('darkwing/findings.sarif')
# if not sarif.is_file():
# sys.exit('no findings.sarif: the scan did not produce results')
# doc = json.loads(sarif.read_text())
# bad = [r for run in doc.get('runs', [])
# for r in run.get('results', [])
# if r.get('level') == 'error']
# if bad:
# sys.exit(f'{len(bad)} error-level finding(s); see the Security tab')
# PY

13
.github/workflows/smoke.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Runner smoke
on:
workflow_dispatch:
jobs:
hello:
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v4
- run: |
echo "runner: $RUNNER_NAME os: $RUNNER_OS"
python3 --version || echo "no python3 on PATH"
node --version || echo "no node on PATH"
bash --version | head -1

60
.gitignore vendored
View File

@@ -73,6 +73,10 @@ id_rsa.*
*.ppk *.ppk
*.p12 *.p12
*.pfx *.pfx
# Local/test TLS material for the dev HTTPS listener. Self-signed, per-host,
# never shipped. The container mounts these at runtime; a site provides its own.
certs/
secrets.json secrets.json
secrets.yaml secrets.yaml
secrets.yml secrets.yml
@@ -83,3 +87,59 @@ scripts/site_imports/wjf/idmap.json
# work-PC publication clone: bundle drop folder for the transfer pipeline # work-PC publication clone: bundle drop folder for the transfer pipeline
_transfer/ _transfer/
# Local developer scratch notes and chat handoffs - never committed
devnotes/
# docs/ is generated/published to the wiki, not tracked in the product repo.
# A single tracked file here makes docs/ exist in the CI checkout, which
# defeats the intentional skip in the docs publish/version tests. Keep this
# local-only guide out of version control so those tests skip as designed.
docs/CSV-IMPORT-GUIDE.md
# Local-only authored docs (executive summaries, workflow/security writeups,
# planner exports, draft decks and diagrams). Same reasoning as above: keep
# personal docs out of version control. docs/openapi.json stays tracked and
# is intentionally NOT ignored here.
docs/EXECUTIVE-SUMMARY.md
docs/DESIGN-DECISIONS-agent.md
docs/OPERATOR-WORKFLOW.md
docs/SECURITY-RISK-ASSESSMENT.md
docs/ShopDB-Planner-Tasks.csv
docs/DRAFT--ShopDB-CSV-Import-Guide.pptx
docs/diagrams/
docs/media/
# Local-only working artifacts: DB dumps, sample/test datasets, scratch
# scripts, captured command output, generated maps/reports, vulnerability
# scan output, and staged agent publish trees. None of these are product
# source; they are developer scratch that should never enter version control.
# exports/ (merge kits, decks, catalog snapshots) stays on disk for reference
# but is not tracked.
backups/
exports/
testdata/
vuln/
agent/publish-wjf/
*.sql
shopmap.pdf
shopmap.png
agent_build.txt
agent_publish.txt
backend_out.txt
backend_out2.txt
docker_build.txt
docker_up.txt
gates_out.txt
pytest_targeted.txt
pt*.txt
alandry.py
kendry.py
# Visual Studio local editor state (not product source).
.vs/
# Scratch tooling under scripts/ (catalog diff/merge helpers, docx generator).
# Not product source; developer-local utilities.
scripts/.temp/
scripts/docs/

View File

@@ -10,6 +10,940 @@ ADR-007 and ADR-002.
## [Unreleased] ## [Unreleased]
### Added
- **A link that opens the code generator with the work already in it.** Copy
share link hands somebody a URL that reopens the page with the content, the
label, the copy count and every layout value the sender changed - so "here is
the label, print it" is a link rather than a screenshot and a list of
settings to retype. Everything rides in the fragment, which browsers never
transmit: it does not reach Flask, the IIS log or the proxy in between. Only
the values that differ from a first-time visitor's defaults travel, so the
link stays short enough to paste into a chat box, and an unknown key is
ignored rather than refused, so a link made by a newer version still opens.
The picture never travels - a data URL runs to megabytes and no chat client
would carry it. This is convenience, not secrecy: anyone holding the link can
read the content, which is what Secure Send is for.
- **Reading anything now needs a permission.** A sweep of the URL map found
**165 GET routes that answered anybody with no token at all**: the whole
asset inventory, every machine, PC, printer and network device, the USB
checkout history and who holds which drive, the staff directory, the core
reports. Guest access had been argued about one feature-flag at a time while
the front door stood open. Every one of them is now behind the read
permission it belongs to - `assets.view`, `machines.view`, `printers.view`
and so on - which also means the guest role can open exactly the ones a site
chooses and nothing else.
Thirty-three endpoints stay public, each named with its reason in
`tests/test_core/test_public_read_surface.py`: the app shell and its
bootstrap, the settings allowlist, images fetched by `<img src>` (which
carries no Authorization header), what a display kiosk renders before anybody
signs in, the parts and USB kiosks, the client scripts that run as SYSTEM
before a user session exists, and a secure-send link whose token is itself
the authorisation. That file is a standing gate: a new public read fails CI
until somebody adds it to the list, in front of a reviewer. Four more reads
need a login but no permission because the handler decides from who you are -
your own user record, your own API tokens, the role catalogue - and the same
test proves each still refuses an anonymous caller.
The employees plugin gained `employees.view`: the directory is names, SSOs,
photographs and reporting lines, and it had no permission at all.
- **A guide's sections can be ticked off while you read it.** Only checklist
items were tickable, which answered "what have I done" but not the question a
long procedure actually raises - where was I. Somebody looks away to do the
thing the guide asked for and looks back at a wall of text. Every step and
every heading now carries a checkbox in the reader; ticking one strikes it
through and fades it rather than hiding it, because scanning back up the page
has to still show what was done. Headings count as well as steps: a guide
converted from an HTML article often has fifty headings and no steps at all.
Progress is per browser and per version, as it already was for checklists -
shopfloor PCs share a login, so one technician's ticks must not greet the
next person on that bay.
- **A guest is a role now, not a switch per feature.** Whether somebody with no
account may see something was answered one feature at a time: a
`securesend_allow_guests` setting, an enable flag per kiosk, and very nearly a
third for guides - each with its own settings page and its own hand-written
branch inside a route. It is a permissions question, and this app already
administers permissions per role, so anyone not signed in now holds the
seeded `guest` role. It starts with nothing, so an upgrade opens no door; a
site opens one by ticking a box in the same role grid it uses for everybody
else, and closes it the same way - taking effect on the next request, not the
next restart. `require_permission` consults the role when there is no token,
which is the whole mechanism.
What a guest may be granted is bounded: any `.view` or `.read` permission,
plus writes a plugin has explicitly declared safe to perform without an
account (`get_guest_grantable_permissions`, contract 0.21.0). Secure Send
declares its create permission, because sending a password to a vendor is the
one write the product means to offer somebody who never signs in. Everything
else is refused by the API and greyed out in the grid rather than warned
about. A guest write records no creator - the audit trail can say when and
from where, never who - and that is the reason for the bar.
GE-Enforce and Slides gained read permissions (`geenforce.view`,
`slides.view`) so they can be opened to guests without handing out the manage
right; their read routes accept either, so nothing an existing manager could
reach has moved. The navigation endpoint now returns only what the caller can
actually reach, so a signed-out visitor sees the pages that were opened to
them rather than a sidebar of links that bounce off a login page.
- **USB check-in / check-out audit report.** CMMC removable-media tracking is an
audit obligation and there was no report for it - answering "who had this
drive, and when" meant paging through the check-in/out list by hand. The
window is named rather than a day count: today, last month, six months, a
year, or all time. Events rather than totals, because that is the question an
audit actually asks, with a CSV export for the people who want it in a
spreadsheet. Works against the external `cmmc_usb` log or the self-hosted
tables; in self-hosted mode one stored loan expands into a take and a return,
so a returned drive is distinguishable from one still out.
- **`flask guides export` / `flask guides import` - a guide can reach another
site.** Sites are separate databases and the ones that matter are air-gapped
from wherever a guide gets written, so a procedure written once was stranded
where it was typed. Export writes one `.guide.json` per guide with its images
carried inside it - one file, because a bundle that needs its images copied
alongside it is a bundle that will eventually arrive without them - and
import reads them back. The slug travels, since that is the address somebody
pastes into a work order and the same guide should answer to the same name on
every site; ids, revision history and publication state do not, because those
belong to the database that issued them. Imports arrive as drafts;
`--replace` updates a guide already carrying that slug rather than creating a
second one, which is how a corrected guide is re-shipped.
- **`flask guides import-html` - an existing HTML article becomes a guide.**
A site adopting this app arrives holding procedures already written: styled
HTML pages that get mailed around and opened off a share. Retyping one into
the editor is how a good procedure stays on a share forever. The importer
reads the page and produces blocks - steps, callouts, code, checklists,
tables, lists - and lifts embedded images into `guideimages` so a 1.6 MB
document stops carrying its screenshots as base64. Imports land as DRAFTS,
and `--dry-run` (the default) reports what it would produce without writing.
Conversion is deliberately lossy: colour, fonts and layout go, because that
is the whole reason a guide is stored as blocks rather than markup. The
blocks go through the same validator the editor uses, so an import cannot
store something the editor could not have produced.
### Fixed
- **Global search answered twelve domains behind one permission.** The route is
gated on `assets.view`, and that was the entire check: looking up an asset
number also read out of the staff directory, the USB checkout history, the
notification log and the network inventory, none of which consult their own
permission. The `SEARCH_DOMAINS` settings are site feature toggles, not
authorization, and were never doing that job. Each domain now answers only to
a caller holding its permission - `employees.view` for the directory,
`usb.view` for checkouts, `network.view` for address and subnet matches,
`computers.view` for hostnames. Asset, custom-field and vendor/model results
stay on `assets.view`, since all three emit asset rows. The check is
`caller_holds` rather than `require_permission`: a search that refused the
whole request because one domain was off-limits would be useless, so a domain
the caller cannot see simply contributes nothing. This is also what makes
`assets.view` safe to hand to the guest role again, which is what lets a
signed-out visitor look up an asset number without also getting the staff
directory.
- **The printer supplies card was ordered by printer name, so the emptiest
cartridge was not first and sometimes was not shown at all.** Rows sorted on
"is critical" and then alphabetically, and critical is everything at or below
5% - so a cartridge at 5% and one at 0% tied on the first key and the alphabet
broke the tie. That is the wrong order on its own, and worse than it looks:
the dashboard queue keeps only the first few rows per source, so a printer
that was genuinely empty got truncated off the card while a fuller one held
its place. The card exists to answer "what do I walk out and change today" and
was answering it with the alphabet. Rows now sort by the emptiest cartridge on
the printer, ascending. Waste cartridges were already normalised to percent
remaining, so one ascending sort is worst-first for every supply type and the
critical flag falls out of it rather than needing its own key; the name only
breaks ties.
- **A permission became grantable to anonymous visitors because of how it was
spelled.** `guest_grantable_permissions()` opened any permission whose name
ended `.view` or `.read` to the guest role, on the reasoning that reading is
the safe default. That is true of a permission gating a list of asset numbers
and false of one gating bytes, and a suffix cannot tell them apart. Twenty-four
of eighty-five qualified, and nobody had decided any of them:
`applications.view` reached the SYSTEM-context PowerShell bodies and the
installer binaries a site pushes to its bays; `backups.view` returned the full
payload that the separate `backups.download` permission exists to protect;
`assets.view` gated a global search that queries the staff directory, the USB
history and the network inventory with no per-domain check of its own;
`audit.view` and `settings.view` are admin-category and were opened by naming
alone. Every entry is now DECLARED, by core or by the plugin that owns it,
having read what its routes actually return. Adding one is a code change,
which is the point - it puts the decision in a diff rather than in a suffix.
The two routes serving script bodies and installer packages moved to a new
`applications.download` permission, which is not grantable, so the catalogue
metadata can still be opened to a site's floor without the bytes going with it.
- **Deactivating an account did not end its sessions.** There is no token
denylist in this product, so the JWT user loader is the only place a
deactivated account can be stopped, and it did a bare lookup by id. Somebody
deactivated at 09:00 kept a working access token until it expired and a
refresh token that minted new ones for thirty days. `/api/auth/refresh` and
the API-token path already checked `isactive`; the loader now does too.
- **The login rate limit could be reset by a header the caller writes.**
`client_ip()` read the first `X-Forwarded-For` hop unconditionally, so
rotating it per request bought an unbounded budget, and every audit row
recorded the forged address. This walked around `TRUSTED_PROXY_COUNT`, which
exists to decide whether a proxy is really in front: the app deliberately
leaves ProxyFix off by default for exactly this reason, and then read the
header anyway. Resolution is now from `remote_addr`, which ProxyFix has
already filled from the header where a proxy is configured. Two plugins had
hand-rolled the same mistake for their own limiters and now share the helper,
which is on `shopdb.api` so the third one does not. **A site behind IIS must
set `TRUSTED_PROXY_COUNT`** or its whole floor shares one rate-limit bucket;
INSTALL-WINDOWS-IIS.md now says so.
- **What an operator approved and what an endpoint ran were two different
lookups.** Delivery descriptors for `run_script` and `update_app` are resolved
when the agent claims a command, not when the command is queued, and the
command row recorded no digest - the enqueue path resolved the descriptor
purely to validate it and threw the result away. Anyone able to replace a
script body or an installer package could do so in the gap, and the
re-resolved descriptor would then faithfully describe the new bytes: the agent
verifies correctly and runs something nobody approved, with the audit trail
naming the operator who queued the original. The approved digest is now pinned
on the command at enqueue and the claim refuses a descriptor that no longer
matches, failing the push instead of shipping it. Separately, an agent may
only finalize a command that is actually running, so a result cannot be posted
for a command that was never claimed nor overwrite a recorded failure with a
success.
- **The published site's API documentation page had no spec to show.**
`shopdb/core/api/docs.py` reads `docs/openapi.json` at runtime to serve
`/api/docs`, and publication excluded the whole of `docs/` because the prose
becomes the wiki. So every site installed from the published repo shipped the
ReDoc page, loaded it, and answered `{"error":"openapi.json not generated"}`.
The spec now publishes: it is a product file that happens to live under
`docs/`, not documentation. The prose still goes to the wiki and nowhere else.
- **Three gates were reporting green by not running.**
`test_api_inventory_parity`, `test_openapi_spec` and `test_docs_publishable`
all skip themselves when the file they read is absent - correct in a
published checkout, and silently wrong on GitHub, which runs against exactly
that checkout. An outside branch added nine routes documented nowhere and
passed CI. `scripts/check-gates-ran.sh` now asserts each gate actually ran,
splitting the ones that must run everywhere from the ones that need files
publication deliberately leaves behind, and a new `gates` job runs it in CI.
Verified in both directions: it passes here, and it fails when the file a
gate reads is taken away.
- **Secure Send now hands you a message, not a bare URL.** A link on its own
invites being pasted onto text already sitting in the chat box, and a chat
client turns the selection into the link's caption - so the secret travels as
the visible message and the link beside it protects nothing. That happened in
production: six LAPS passwords sent as the caption of a link whose whole job
was to keep them out of the chat. Copy message puts a sentence on the
clipboard instead, which has nowhere to attach itself, and the success card
now says plainly to delete the password from the chat box before pasting.
Nothing about the link itself was wrong - the URL carries only the token, the
key rides in the fragment, and neither ever reached the message.
- **Ticking a section off crossed out its title and nothing else.** The strike
now covers everything under that heading, including its sub-sections, which
is what "this section is done" means to somebody who ticked the thing that
contained them. It needed the heading RANK, which the HTML importer was
discarding: every heading arrived as a sibling, so a parent had no children
to cross out. Headings now carry their level, render at their rank, and a
guide written before this reads as flat, exactly as it did. Code blocks,
tables and images inside a finished section are faded but not struck - a line
through a command makes it unreadable, and re-reading the command is usually
why somebody looks back at a step they have already done.
- **Tech Tools was invisible to anyone without an account**, even though every
tool on the page is deliberately usable without one and Secure Send is the
single write this product offers a guest. The nav item named no permission,
because the plugin had none to name, so the sidebar filter dropped it. The
plugin gained `tools.view`, and the item now names either that or
`tools.secrets` - somebody granted only Secure Send still needs the entry
that leads to it.
- **The sidebar offered pages the reader could not open, and hid ones they
could.** A nav item is hidden from anybody who does not hold its permission,
but only four items ever declared one - so the filter did nothing for the
rest, and a signed-out visitor granted `machines.view` still saw an empty
sidebar. Every item now names the read permission its page needs, in the
plugins, in the core navigation endpoint and in the frontend's fallback list.
A signed-in member with a sparse role will notice the same correction: a link
that could only answer 403 no longer appears.
- **Every report offers to email itself, not just two of them.** `POST
/api/reports/email` has always taken any `{columns, rows}`, but the button was
on the toner and warranty pages only - because those two happen to be their
own routed pages, and the reports page had no button at all. It is now beside
Export CSV for every report the page can build a table from, which is all of
them: the plugin reports through their declared columns, the core reports
through the flat list they return, and the asset inventory summary by
flattening its three tables into one with the grouping named in a column.
Presentation-only fields - a status colour, a link id - are left out, since a
column of hex codes is worse than no column. The button is hidden without the
`reports.export` permission the endpoint enforces, on all three pages: offering
somebody a button that can only answer 403 is worse than not offering it.
- **A long drive name pushed Take and Return out of the card.** On the USB kiosk
the name is a flex item, which defaults to `min-width: auto`, so it kept its
full intrinsic width and shoved the button through the side of the card -
leaving a drive nobody could take. Names now wrap, including the serial-style
ones that carry no spaces at all, and a description is clamped to two lines so
one long name cannot make every card in its row tall. The locker line is never
clipped: it is how somebody finds the thing.
- **Muted text on the USB kiosk and in guides was not muted.** Both named a
`--textmuted` variable this app has never defined, so the browser dropped the
declaration and the hints, subtitles and locker lines rendered at full
strength against the text they were meant to sit behind. The variable is
`--text-light`.
- **Plugin reports rendered a raw JSON dump.** The reports page ran a hardcoded
switch over five core report ids and fell through to
`console.error('Unknown report')`, and its results panel was a matching chain
of per-id templates ending in a `JSON.stringify`. So a plugin could register a
report, watch it appear in the catalogue, and get a wall of JSON when it was
opened - which is where the three printedparts reports have been since they
were added. Any report that declares an endpoint is now fetched from it, and
any report returning `{columns, rows}` renders as a table. A plugin needs no
core code to have a working report. CSV export follows the same endpoint
rather than assuming `/api/reports/<id>`, which does not exist for a plugin
report, and a report that fails now says so instead of showing an empty panel.
Export CSV is fetched through the api client rather than `window.open`: a new
window carries no Authorization header, so every report behind a real
`jwt_required` answered the export button with a 401. The core reports are
jwt-optional, which is the only reason the old way ever appeared to work.
- **Reports had no way to choose a time period.** Endpoints accepted a window;
the page offered no control for it, so `printedparts-consumption` was
permanently stuck on its 30-day default.
- **USB kiosk: check a drive out and back in without a login.** The people who
actually take USB drives are standing at the cabinet, and most of them have no
ShopDB account - so check-out and check-in were gated behind a login that was
not doing much anyway, since both modes already attribute the action to a
scanned BADGE rather than to the signed-in user. `/usb-kiosk` is a full-screen
touch screen modelled on the parts kiosk, and works against the self-hosted
tables or an existing `cmmc_usb` database.
It meets the same bar as the parts kiosk: it can move a device between
checked-out and checked-in and nothing else - no create, edit or retire - and
it does not exist at all until a site sets `usb_kiosk_enabled`. An
unauthenticated write is a decision about the door.
The drives carry no barcode, so unlike the parts kiosk there is nothing to
present and the screen has to offer a LIST. That widens what a logged-out
screen exposes, so the list is not anonymous: an SSO comes first and has to
resolve to a real person before any drive is named - the same bar taking one
already had to clear. The main screen shows the caller's own drives, what is
free, and a COUNT of everything else; the holders of other people's drives
appear only behind a deliberate second tap, so a passer-by cannot read the
whole cabinet off a wall panel.
A badge that resolves to no employee is REFUSED, unlike the signed-in route
which creates the user row: a checkout nobody can trace to a person defeats
the point, and at a kiosk there is no operator standing there to vouch for it.
Returning somebody else's drive asks first, then records the badge that was
scanned - so the log says who actually handed it back, which is the honest
record and needs no change to a schema this app does not own. Refusing a
mismatch outright is available as a setting, but is not the default: a drive
left behind by somebody on leave would then be unreturnable at the kiosk,
which just moves the problem to a person with a login.
The list of free drives is filtered from a search box, and lays itself out in
as many columns as the panel is wide - up to four on a wall display. A cabinet
of thirty drives was one long column somebody had to read through. A drive is
found by its id, by what is written on it, or by where it lives, in whatever
word order comes naturally: "locker engineer" finds the engineering locker,
because that is what people say.
- **Audit log retention.** The audit log is append-only and nothing ever removed
a row, so it grew for as long as a site ran - here to the point where the
audit page was tens of thousands of pages deep and therefore useless, because
nobody pages through that to find anything. Site & Facility gains
`audit_retentiondays` plus a card showing the log's size and exactly how many
entries a purge would remove, and there is `flask audit stats` /
`flask audit purge --apply` for the server. **Opt-in: the default is 0, which
keeps everything.** An upgrade that silently started deleting a site's audit
history would be indefensible. Deletion runs in committed batches, because one
`DELETE` over a million rows holds table locks long enough to block the
collectors posting inventory; a run that stops early reports how much is left
rather than claiming it finished. Purging needs its own `audit.purge`
permission - reading the log and destroying it are different acts.
- **Guides: images, and a reader who cannot see drafts.** Guide images are
fetched through the authenticated API client instead of being pointed at by
`<img src>`. An image tag sends no Authorization header and this app keeps its
JWT in one, so every guide image was a broken icon for every user; the same
change makes stored image links survive a subpath mount. A failed image now
says so in words rather than showing a broken icon. Revision and image routes
are gated on their parent guide - revision ids are sequential, so reading one
guide used to be enough to walk the ids and read every unpublished draft on
the site. Search, the guide list and the tag chips now match the PUBLISHED
text, not the working copy, so a reader can no longer get a hit on wording
that is not in the page when they open it.
### Fixed
- **A guide's URL no longer moves when its title is edited.** The slug was
regenerated on every save that carried a title, and the editor autosaves every
three seconds - so fixing a typo in a heading silently moved the guide and
broke exactly the pasted links the stored slug exists to protect. The slug is
now set once, at create.
- **Publishing a guide after a failed save reported success.** `publish()` ran
regardless of whether the pre-publish save reached the server, so the author
saw "Published version N" while readers got the previous revision. A failed
save now stops the publish and says why.
- **The ADR-015 site-literal check was enforcing nothing.** Its patterns are
assembled from fragments so the published script does not contain the strings
it greps for, but they used the apostrophe-escape form (`'a'\''b'`) instead of
concatenation (`'a''b'`), embedding a literal apostrophe in every pattern.
Nothing could ever match and the check passed everything from the day it was
written. Fixed, and it immediately found 25 real violations - among them a QA
ServiceNow tenant URL sitting in the shipped defaults in seven places.
- **ServiceNow ships off and blank.** The defaults pointed at one organisation's
QA ServiceNow tenant with the integration enabled, so a site that had never
configured ServiceNow still rendered ticket links - to a tenant its
technicians cannot reach, and for an adopting site, into somebody else's
instance. There is no cross-site default for "which ServiceNow is yours".
- **Credentials are no longer stored in backup revisions.** The NTLARS/DNC
registry carries an FTP password in cleartext, and it was stored verbatim -
rendered by the revision panel and handed back by the download route as a
ready-to-import `.reg`. Credential values are now redacted at parse, so they
never reach the database, and the rendered file NAMES each withheld value
instead of carrying a placebo one: importing a fake password would produce a
machine that looks restored and cannot connect. `scripts/redact_backup_secrets.py`
scrubs revisions recorded before this (dry run by default). Rotate any
password that was stored.
- **A search domain that fails is no longer indistinguishable from one that
found nothing.** Each domain catches broadly and returns no rows so one broken
table cannot take the whole search box down - but the technician was then told
they might have misspelled a query that was right, and concluded the record
did not exist. Failed domains are named in the response, shown as a banner,
and suppress the did-you-mean.
- **An empty `sourcehostname` defeated backup dedup permanently.** The dedup
query maps `''` to `IS NULL` while the insert stored the raw `''`, so those
rows could never match their own chain: every post inserted, the table grew
without bound and the history filled with identical entries. Normalized once
at ingest, whitespace included.
- **`printedparts` migration 0003 was unguarded.** Unlike its twenty siblings it
ran a bare `ALTER`, so `flask plugin upgrade-all` failed on any database built
by `create_all` and took 0004 down with it. The migration test that should
have caught this only covered single-migration chains; it now covers every
post-cutover chain.
- **Guide links with several parameters opened the wrong page.** Text is escaped
before formatting, so a captured URL arrives carrying entities and was then
escaped a second time - `&amp;amp;` in every multi-parameter ServiceNow and
SharePoint link. Emphasis could also reach inside an `href`, and
`//host` passed as an in-app link when the browser reads it as off-site.
- **Retire an asset from the interface.** Machines, PCs, printers and measuring
tools had no way to take a record out of ShopDB without calling the API, so a
scrapped tool stayed in every list and report. The endpoints already existed
and were already soft deletes: the record leaves the lists while its
calibration history, backups and relationships stay put, and it can be
restored by setting `isactive` back. Called Retire rather than Delete for
that reason, and the confirm says what is kept. USB is not included - it
retires through a status column in the external CMMC database and needs its
own decision.
- **`flask catalog dedupe`, `merge` and `rename`: catalog hygiene.** The legacy
data carried space-stripped twins - `DellInc.` beside `Dell Inc.`,
`Windows10` beside `Windows 10` - which rode into the shipped seed catalog
and out to every site that ran `seed catalog`. `dedupe` merges only
whitespace-and-punctuation twins, because those are one name typed two ways;
`Dell` and `Dell Inc.` normalise apart and are deliberately left alone, since
that is a naming choice only a person can make. The surviving row is the most
referenced, tie-broken toward the spaced spelling. `merge` makes that
judgment call explicitly and refuses to invent a target that does not exist.
`rename` fixes a stripped name with no twin, which neither of the others can
reach. All three report and change nothing unless `--apply` is passed, and
`dedupe` is idempotent. References are discovered from the live schema, not
listed in code: there are 14 foreign keys into these three tables, and
`machines.controllervendorid` is not one anybody would enumerate by hand.
- **Secure Send: hand somebody a password without leaving it in a chat
window.** A link in Tech Tools that carries one credential, dies when it is
opened, and expires on its own if nobody opens it. The secret is encrypted in
the sender's browser and the key rides in the URL fragment, which browsers
never transmit, so the server stores ciphertext it cannot read and a
compromised database yields nothing. Nobody can recover a lost secret either,
including an administrator, which is the same property seen from the other
side. Redeeming is a POST because chat and mail clients prefetch links to
build previews, and a one-view secret consumed by a preview window is a secret
the recipient never sees; a GET reports availability only. Expiry defaults to
24 hours (maximum a week) and views to one, whichever comes first, and a spent
row is deleted. Creating needs a login and `tools.secrets`; reading needs
neither, because the recipient usually has no account and the 128-bit token is
the authorisation. The 4096-character ciphertext limit is deliberately the
whole server-side enforcement: a zero-knowledge server cannot tell a password
from a document, so size is the only control it has, and raising it turns this
into the file transfer it was scoped not to be. Documented in
`docs/SECURE-SEND.md`.
- **A site can let people without an account create a Secure Send link.**
New `securesend_allow_guests` setting (Settings > Tools > Secure Send), off by
default because a guest secret has no creator and the audit trail can then say
when and from where but never who. Guest creation is rate limited per address
and the setting fails closed if it cannot be read, so an error can never be
what opens an unauthenticated write endpoint.
- **The code generator can print a back side.** A badge card is two-sided and
the generator only ever emitted one page per card. The Back side section
turns on a second page per card carrying either the same code again, a fixed
block of text, its own per-row content (CSV `back` / `backlabel`), or
nothing. Page order is a choice, because the two kinds of printer want
opposite things: **interleaved** front-back-front-back for a duplex card
printer, **grouped** all-fronts-then-all-backs for a single-sided printer
that gets its stack flipped and reloaded. Getting that backwards prints
perfectly readable cards with the wrong back on every one, which is why the
order is pinned by tests rather than by looking at the preview.
- **The gap between a code and its caption is its own control.** It was the
quiet zone doing double duty: closing up a caption meant shaving the clear
space a scanner needs, and the only way to get a code to scan was to push the
caption away from it. Quiet zone now means only the code's own margin; gap
means only the distance to the caption, and adds to the quiet zone rather
than eating into it. Every stock preset carries a gap that suits it.
- **Contents can be positioned inside the label without moving the label.**
Align and Vertical place the code and caption block anywhere on the stock,
and Content X/Y nudge it from there. The existing X/Y nudge kept its
behavior and is now labeled Media X/Y: it moves the whole printed area, for
a printer whose origin is off, which is a different problem from composing a
label.
- **A picture can go on the label.** Pick an image once and it prints on every
label - above the code, beside it, or behind everything as a watermark - on
the front, the back, or both. The file never leaves the browser, so an
air-gapped site can use it, and it is deliberately not saved between visits:
a data URL runs to megabytes and would take every sizing value with it when
the storage quota gave out.
- **The back can carry its own code, in its own symbology.** "Back carries" now
defaults to a code rather than text, takes per-row content from the CSV
`back` column, and has its own QR/CODE128 choice - a QR on the front that
opens the record, a CODE128 on the back that the bench scanner already reads.
A back set to carry a code can no longer be laid out as text-only: that
combination printed a blank card that looked like the code had failed, and it
is corrected on load for anyone who already saved it.
### Fixed
- **Every detail page had the same browser tab title.** A machine, a PC and a
printer all read "West Jefferson ShopDB - Machine" or the equivalent, so two
tabs on two different records were indistinguishable, a bookmark said nothing
about what it pointed at, and a link shared into Teams previewed as the
section rather than the thing. Detail pages now name the record: machines,
PCs, printers, network devices, subnets, measuring tools, USB devices,
printed parts, applications, employees, knowledge base articles and guides.
A record with no usable name, or one that failed to load, keeps the section
name, and a long name is cut at a word boundary. Report pages gained proper
titles too, which also fixes "Pc Relationships".
- **Guides would have been dropped at provisioning.** The plugin was missing
from `deploy/site-profile-universal.json`, the profile the released Windows
installer is built from, so the wizard would not have offered it and
`flask plugin prune-schema` drops the tables of any plugin absent from the
profile (ADR-014). Its model also imported core directly rather than through
`shopdb.api`, which the contract test catches.
- **The shipped catalog offered two ways to spell the same vendor, and one row
that should never have left a test.** `HP Inc.` and `Zebra Technologies` were
empty rows duplicating `HP` and `Zebra`, which carry every model between
them; neither exists in this site's database, so they would have arrived as
duplicates at a new site and nowhere else. `UnscopedLiveVendor` was a test
artifact: `export_catalog.py` reads the live development database, so a row
left behind by a lab run was published as reference data. The canonical name
is whichever spelling holds the entries, which is why it is `Dell Inc.` but
`HP` and `Zebra`. Vendors 51 to 48.
- **The shipped seed catalog contained duplicate vendors, models and operating
systems.** Four space-stripped twins, each with a correctly spaced original
already present, so a new site inherited the same company listed twice.
`OptiPlex5000` had no twin and was renamed to `OptiPlex 5000` rather than
dropped. Vendors 53 to 51, models 128 to 127, operating systems 14 to 12.
Not a code fault: the collector strips only leading and trailing whitespace
and treats the OS list as lookup-only, so it neither created these nor is
creating more. They came in with the classic-ASP import.
- **A maintenance script could not find the database the site actually uses.**
`python scripts/export_catalog.py` on a deployed site died with 1045 "Access
denied for user 'root'@'localhost'", naming a user that site does not have.
Nothing loads `.env` for a plain script run: `wsgi.py` does it for the served
app and the `flask` CLI does it for itself, so both reach the real database,
while a bare `python scripts/...` gets neither and falls back to a hardcoded
`root@localhost` URL. The read-only failure is the harmless version - six of
the seven scripts write, and on a box where those fallback credentials happen
to resolve they would have silently rewritten the wrong database. All seven
now load `.env` from the repo root, resolved from the script's own location
rather than the working directory.
- **A search result opened the list it sits in, not the record it found.** A
NOTICE linked to `/notifications`, so clicking the row search had already
identified sent the reader to the list page to find it again by hand. The id
was in the payload the whole time and only the URL ignored it. The subnet
searcher had the same defect: an IP search resolves which subnet an address
falls inside, then linked to the `/network` hub rather than that subnet.
Both now link to the record. Every other result type already carried its id.
- **`flask plugin new` scaffolded a frontend that no lean build could prune.**
It wrote views to `frontend/src/views/<name>/` and a route file to
`frontend/src/router/routes/<name>.js`, while all 16 bundled plugins keep
their frontend in `plugins/<name>/frontend/`, staged by
`stage-frontend.mjs`. Nothing looked broken, which is why it lasted: the
router still globs `./routes/*.js` during the Phase 4 dual-location
transition, so the pages loaded. What a new plugin author silently lost was
ADR-013 lean builds, since pruning works off the staged tree, plus stranded
views on deleting the plugin. The templates for the right layout already
existed; only the output paths were wrong. Their core imports moved to the
`@` alias, which resolves the same before and after staging, where the
previous `../../api` was correct only in the old location.
- **A zero bar height printed a barcode 176 wide and 0 high.** Nothing rejected
it: the value went straight into a CSS length, `height: 0in` is perfectly
valid, and an image at `height: 100%` of a zero-height box is zero pixels
tall. A cleared field was its own version of the same bug - `'' + 'in'` is not
a length, the browser dropped the declaration, and the box fell back to auto
height. Every measurement now goes through `labelVars()`, which will not emit
NaN, an empty length, or a zero where a zero means the box stops existing;
padding, quiet zone and gap still accept zero, because there it is a real
answer. The stylesheet carries its own fallbacks too, though those only cover
a property that is absent - a property set to garbage is dropped at computed
value time and takes the fallback with it, which is why the sanitiser is the
fix and the CSS is only a net.
- **A caption could eat the barcode.** The code box and the caption both grew,
so any caption took half the label and kept going: "BAY 12 PRESS LINE 1" on
1.00 x 0.50in stock left a 36px stub, which is a code that is present,
unscannable, and reads as one that failed to render. The caption now takes
only the room it needs and a CODE128 keeps at least 40% of the label, all of
it when stacked. A code that genuinely fails to render says so in the preview
now, in red, rather than leaving white space that looks like a label with no
code on it.
- **The generator prints from inside the app shell.** It was a full-screen
route with no sidebar and a narrow column of controls on the left. It is now
an ordinary page - navigation, full width - and `style.css` hides the shell at
print time, so the stock still gets nothing but labels. Verified against a
real print PDF rather than a preview.
- A test print paired each page with a code by loop position while the codes
are keyed to position in the whole run, so a two-sided test card could carry
the wrong code. The page index now travels with the page.
- The back label's dotted preview outline outspecified the print reset and drew
a dashed rule down the edge of every second card.
- **The warranty list could not be searched by machine number.** The number was
in the payload and rendered in the table as a chip, but the search box looked
only at vendor, service level, service tag and asset number. That made the
identifier the feature was built around - a shopfloor PC is warranted as a PC
but found by the machine it drives - the one thing the box could not find.
Machine number and machine name are now searched too, and the placeholder
names them.
- **`flask plugin upgrade-all` failed a plugin whose tables already existed**
("table securesendsecrets already exists" on the tools plugin). Every
post-cutover baseline emitted a plain `op.create_table`, which is correct only
while a table's existence and its plugin's Alembic stamp cannot disagree.
They can: anything that builds tables from the models stamps nothing, and a
run that creates a table then fails before its stamp commits leaves the same
split, where re-running is the obvious response. Creates and indexes across
all nine table-creating plugin migrations now skip what is already there.
Indexes are matched on their COLUMNS rather than their name, because
`db.create_all()` names them by SQLAlchemy's convention and a migration by
this repo's, so a name-keyed check would silently add a duplicate index
instead of skipping. Note the remaining limit: this makes a baseline
idempotent, not a whole chain. A longer chain can still meet an `ALTER` whose
column is already present, which no guard on `create_table` addresses.
- **Saving a machine that has a controlling PC answered "this relationship
already exists" and refused the save.** Reported against a map position
change, but the map was incidental: the form re-POSTed the controlling-PC
link on every save, and the row was already there. It used "do I know the
existing link's id" as a stand-in for "is there an existing link", and read
that id from a key the API does not emit, so the two disagreed on any machine
that had a PC. The same conflation meant CLEARING a machine's controlling PC
silently did nothing, which was the more dangerous half and had not been
reported. The loaded PC is now what decides, and re-selecting the same PC is
a no-op rather than a delete-and-recreate.
### Changed
- **The computers collector no longer accepts `defaultprinter` or `printers`,
and can no longer write a printer assignment.** Those keys used to become
`defaultprinter`/`connectedto` relationship rows straight from a report, which
contradicts the rule the whole printer feature rests on: a collector report is
OBSERVED state and never ASSIGNED state, or a bay that installed the wrong
printer makes itself right simply by reporting it. It was not a dormant code
path - the collector schema advertised both keys and the example client in
`docs/COLLECTOR-INTEGRATION.md` set `defaultprinter`, while another page of
the same document promised nothing on that path writes an assignment row.
**A client still sending them keeps working**: the keys are ignored, not
rejected, and the response carries a warning naming them and pointing at
`POST /api/collector/printers` for reporting queues and
`seed-from-observed` for adopting them. Silently dropping them would have left
an integrator believing assignments were still being written.
- **Tech Tools no longer asks people to sign in for tools that need no
account.** `/tools`, `/tools/codes` and `/tools/secure-send` were all behind
`requiresAuth`. The label generator runs entirely in the browser - no API, no
data, nothing to protect - so gating it only ever cost a guest the use of it,
and gating the Secure Send page contradicted the guest setting that lets
somebody with no account create a link. The server still decides who may
create a secret: making a page reachable is not the same as allowing the
action, and the two were conflated.
- **Settings on the left, labels on the right.** The controls were a narrow
column with the preview trailing underneath, so the labels being adjusted were
off the bottom of the screen. The preview is now a column of its own that
stays in view and scrolls by itself, and it folds back to one column on a
narrow window. Note for anyone changing it: that scrolling column is exactly
the kind of ancestor that clips a multi-page print to whatever was on screen,
so print resets its overflow - verified by printing a six-page two-sided run,
not by looking at the preview.
- CSV parsing and page building moved out of the generator component into
`plugins/tools/frontend/labelPages.js`, where the column contract and the
two-sided page order are covered by tests.
## [0.12.0] - 2026-08-20
Printers become a property of the bay rather than of whoever last walked up to a
PC, and the container stops throwing away the site's own files on every update.
**Air-gapped sites need a fresh offline bundle for this one.** The stack moves to
MySQL 8.4 LTS, and an existing tarball only carries the 8.0 image, so
`docker compose up` would ask for an image the site cannot pull.
### Added
- **Printer assignment has a form.** `PrinterAssignmentPicker` is the ONE picker
for both ends: MachineForm gained it (the machine is where the assignment
belongs and there was no way to set it except the relationships card), and
PCForm now uses the same component instead of its own copy, so the two sides
of an override cannot drift. PCForm also stopped reconciling row at a time
through the generic relationship endpoints, which left a PC half-assigned on
an HTTP failure; it calls
`PUT /api/printers/assignments/for-asset/<id>` instead.
- **A relationship type can declare itself singular**
(`relationshiptypes.issingular`, migration 7d34), and `defaultprinter` does.
Setting a second default REPLACES the first. The unique constraint is
(source, target, type), so two different targets were two valid rows and the
resolver took the OLDEST: a new default silently lost.
- **Observed printer queues.** `POST /api/collector/printers` (the ADR-006 hook,
no new transport and no new credential) records what a bay ACTUALLY has in a
plugin-owned table, kept strictly apart from what it is assigned. Adoption is
explicit via `POST /api/printers/assignments/seed-from-observed`, which routes
through the same reconcile path as the editor and REFUSES a queue matching no
known printer. New client script `Report-PrintersToShopDB.ps1`.
- **Wave-gated driver rollout.** `Install-ShopdbPrinterDrivers.ps1` takes
`-WaveStart`, `-Waves`, `-WaveUnit` and `-IgnoreWave`. GE-Enforce offsets each
PC by SHA256(hostname) % 5 MINUTES, which was sized for a JSON check, not a
100 MB driver set: ungated, ~300 bays pull ~30 GB inside one five-minute
window on the share the whole floor depends on. Each bay derives its wave from
its own hostname, the gate runs BEFORE the manifest is read (the manifest is
on that share too), and it fails closed on an unparseable date.
### Changed
- **MySQL 8.4 LTS** in both compose files, and as the default in
`build-offline-bundle.ps1`. 8.0 reached end of life in April 2026, which
`INSTALL-WINDOWS-IIS.md` already said while the stack still pinned it; the two
halves of the product disagreed about which server a site should run.
Verified against a real 8.4.11 server rather than by changing a tag: the core
chain and five plugin chains applied clean, 66 tables at a single
`utf8mb4_unicode_ci` collation, and the image's PyMySQL authenticates against
8.4's `caching_sha2_password` (which is why `cryptography` is pinned).
Sites upgrading an EXISTING server should note that 8.4 removes
`mysql_native_password`: an account created on 5.6 or 5.7 must be moved to
`caching_sha2_password` before the upgrade or it cannot log in afterwards.
Air-gapped sites need a fresh offline bundle, since the tarball carries the
MySQL image.
- **The database healthcheck probes over TCP** rather than the unix socket. The
entrypoint's init pass answers on the socket while running the server with
`--skip-networking`, so a socket ping reported healthy DURING init and
`depends_on: service_healthy` released `api` and `migrate` against a server
that was about to restart. Found by having it happen: a probe passed at 8s and
the next query failed because the server was mid-restart.
### Fixed
- **The image did not build.** `npm run build` fires a `prebuild` hook that runs
`node ../scripts/stage-frontend.mjs`, which stages each plugin's frontend into
the Vite tree and codegens `routes.gen.js`. The Dockerfile's frontend stage
copied `frontend/` alone and flattened it to the stage root, so that path
resolved to `/scripts` and every build since the staging script landed died on
`Cannot find module`. The stage now keeps the repo-relative layout and copies
`scripts/stage-frontend.mjs` and `plugins/` in beside it. Verified end to end:
16 plugin frontends staged, `frontend/dist` in the final image.
- **The compose stack did not persist `instance/`.** `db_data` was a volume and
the instance directory was not, so `docker compose build api && up -d api`
recreated the container and discarded `plugins.json` along with every upload:
floor plans, branding, model and application images, employee photos,
warranty proofs, slides, printed-part files and the Dell OAuth token. The
visible symptom was a site coming back with its plugins disabled. Both compose
files now mount an `instance_data` volume (the air-gap `migrate` service too,
since `plugin upgrade-all` writes `plugins.json`), and DEPLOY.md carries the
one-time rescue for a stack that predates it.
- **A drifted print queue is corrected, not just a missing one.** Queues were
matched by NAME alone, so a bay whose printer had moved or whose queue was
built on a replaced driver looked converged and printed to the wrong device.
`Set-ShopdbPrinters.ps1` now repoints a wrong port and swaps a wrong driver
IN PLACE with `Set-Printer`, so the queue keeps its name, sharing, permissions
and whoever holds it as their default. The driver is only swapped when the
wanted one is staged, and there is still no removal path in the script.
- **MachineForm's dropdowns all came up empty.** It read `.data.data` off
`computersApi.listAll()`, which already resolves to the array, so the whole
parallel load threw into the catch and the machine's own values never loaded.
- **The legacy import loader dropped `machines.printerid`**, the classic
system's record of each machine's default printer, so the production import
would have lost every one.
## [0.11.3] - 2026-08-19
Fixes the last of the buildings-and-levels bugs, and brings the shop-floor
clients into the product: the collector reporter, EventSaver and a printer
assignment feature that lets a bay install its own printers.
**Anyone on 0.11.0-0.11.2 should take this.** The hover preview reported "this
asset has a position but no level" for every asset, and the map PDF printed
markers from every floor onto one sheet.
### Fixed
- **Every hover mini-map said "no level".** When 0.11.0 gave LocationMapTooltip a
levelid, none of its seven call sites were taught to pass one - printer,
machine and PC detail, the toner report, enforcement reports, the warranty chip
and the dashboard cards. Two payloads behind them also emitted coordinates with
no level.
- **Map PDF export printed other floors' markers.** It plotted every filtered
asset onto one sheet, which is worse than the on-screen version was: nobody can
correct a sheet once it is printed and carried onto the floor.
- **The legacy import loader created undrawable markers**, sending mapleft/maptop
with no level at three call sites. That loader is the one still to run against
production.
- The build gate that should have caught all of this asked whether a FILE
mentions `levelid`, not whether each position does - one module emitted `mapx`
six times and `levelid` once and passed. It now checks per occurrence, covers
`scripts/`, and fails any Vue file that binds tooltip coordinates without a
level.
### Added
- **Printer assignment.** Printers belong to the MACHINE and reach whichever PC
controls it, so a reimaged or swapped PC comes back with the bay's printers and
nothing had to be saved off the old one. New `usesprinter` relationship type,
propagating through `controls` at read time; `GET /api/printers/for-host/<host>`
for the client; `PUT /api/printers/assignments/for-asset/<id>` to reconcile a
whole set in one call.
- **`printerdrivers.vendorid`**, so one row serves a make. HP and Xerox universal
drivers cover 41 of the reference site's 44 printers; binding a driver to a
single model meant 21 near-duplicate rows. Resolution is model, then vendor,
then the pre-vendorid name convention.
- **Client scripts** in `plugins/printers/client/`: stage a site's driver set
(`Install-ShopdbPrinterDrivers.ps1`, with `-TestOnly` for DSC), converge a
bay's queues (`Set-ShopdbPrinters.ps1`), and apply the per-user default
(`Apply-ShopdbDefaultPrinter.ps1`).
- **The collector reporter is in the repository** at
`plugins/computers/client/`, with no site in it, and the server generates a
copy carrying this site's URL and ranges: Settings > Computers > Asset
reporter, or `GET /api/computers/client-script`. The collector key is
deliberately never stamped into it.
- **EventSaver source** at `plugins/slides/client/`. The site UNC that was
compiled into it is gone; a missing ini now fails visibly rather than pointing
a new site at the reference site's file server.
- Settings: `computers_routableranges` (replaces two hardcoded VLANs) and the
buildings-and-levels admin gains editable width AND height.
### Changed
- The map editor accepts a click. Its handler was bound only if the map was
already a picker when it mounted, and the editor opens with nothing selected,
so placing a marker by hand was impossible for the life of the page.
- Global search orders every query before truncating, so the same search cannot
return a different subset twice.
## [0.11.2] - 2026-08-17
Bug fixes for the buildings-and-levels work in 0.11.0, all found by using it.
Anyone on 0.11.0 or 0.11.1 with more than one level should take this: until now
the map drew markers from every level on whichever floor plan was showing.
### Fixed
- **Markers from other levels were drawn on the displayed level.** The map
component filtered out positions with no coordinates and drew everything else,
never checking which drawing a position belonged to - the exact failure ADR-017
exists to prevent, in the one component that draws the map. Markers are now
filtered to the level being shown, and a position with no level is omitted
rather than approximated onto the default.
- **The map page had no way to choose a level.** It read the current level only
to title a PDF export, so a second floor was unreachable from the viewer most
people use. A level selector appears when a site has more than one, and
switching moves the drawing, the bounds, the coordinate space and the markers
together.
- **The map editor never accepted a click.** Its click handler was bound only if
the map was already in picker mode when it mounted, and the editor opens with
nothing selected, so selecting an asset and clicking did nothing at all for the
life of the page. The asset forms were unaffected, because their picker is
mounted inside a dialog that is already in picker mode.
- Existing markers are now drawn while placing one. Positioning an asset relative
to the machines already on the floor is the whole task.
- Searching the map follows results across levels: a search whose matches are all
on another floor showed an empty map while the filter counted them.
- **A level's native size could not be set.** The settings page had a width field
and no height field at all, and locked both once any marker was placed - which
blocked the case the feature was built for, a new blueprint of new dimensions
on a floor that already has markers. Both fields are editable, with a
confirmation and a pointer to landmark recalibration.
- **The same search could return different results.** Fourteen of the sixteen
searchers behind global search truncated with `LIMIT` and no `ORDER BY`, so the
database was free to return a different subset of matching rows each time.
Every searcher now ends in a total order.
- Re-searching a term already in the address bar did nothing: the router treats
it as a duplicate navigation and aborts, so the results page never re-queried.
This is why the sidebar box and the results-page box appeared to disagree.
- The empty legend no longer draws its bar and border, which read as a stray
input box under the map toolbar.
- The map editor's panel header no longer crams a heading and three controls onto
one row, which had squeezed the search box until its placeholder read
"Search na".
- Both map pages had a scrollbar. They subtracted the wrong amount from the
viewport height for the page chrome, so each overflowed by the difference; the
padding is now a value the pages and the layout share.
## [0.11.1] - 2026-08-17
A patch release: the pagination-cap fixes, plus the documentation that missed the
0.11.0 installer. No application behaviour changed on the server and the plugin
contract stays at 0.20.0.
`docs/UPGRADE.md` is one of the documents the Windows installer puts on the box,
and its buildings-and-levels section landed after 0.11.0 was tagged - so 0.11.0
shipped the page an operator reads WHILE upgrading without the guidance about
that upgrade. An air-gapped server has no other way to reach it.
### Fixed
- Pickers and batch label sheets no longer stop at 100 rows. `perpage` is
clamped to `MAX_PAGE_SIZE` (100) server-side and the response says nothing
about it, so a control asking for 500 or 1000 received the first 100 rows and
a success. On a site with 126 applications the 26 sorting last could not be
chosen as a knowledge-base topic; batch label printing rendered a sheet for
the first 100 of 262 machines that looked complete. New `fetchAllPages()`
helper plus `listAll()` on the applications, models, machines, computers,
printers, network, measuring-tools, USB and printed-parts APIs, used by the
KB article form and topic filter, the notification form, the report filter
builder, `AssetLabelBatch`, `PrinterQRBatch`, `USBLabelBatch`,
`PrintedPartsLabels`, `MachineForm` and `NetworkDeviceForm`. Lists that render
a page at a time are unchanged - they page deliberately.
### Changed
- The installer now also ships `FLOOR-MAP.md`, the operator's page for loading
floor plans, placing markers and moving them when a plan changes. Same reason
the Windows runbooks ship: the box cannot reach the wiki.
- The bundled `UPGRADE.md` now carries the 0.11.0 section: an existing map is
preserved and nothing moves on screen, and replacing a blueprint with one of
different dimensions moves every marker on that level, so recalibrate from
landmarks rather than editing the level's width and height.
## [0.11.0] - 2026-08-17 ## [0.11.0] - 2026-08-17
The floor map became a set of drawings instead of one picture. A site can hold The floor map became a set of drawings instead of one picture. A site can hold
@@ -1071,7 +2005,7 @@ letting other GE Aerospace sites stand up their own self-hosted instance
- Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides; - Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides;
reconciled README, DEPLOY, status docs, and ROADMAP. reconciled README, DEPLOY, status docs, and ROADMAP.
- ADR-007 (product versioning and releases), CHANGELOG, and best-effort - ADR-007 (product versioning and releases), CHANGELOG, and best-effort
Gitea Actions CI (backend tests, naming/style gate, frontend build). CI (backend tests, naming/style gate, frontend build).
### Changed ### Changed
@@ -1102,11 +2036,3 @@ letting other GE Aerospace sites stand up their own self-hosted instance
integration that passed the key as a query parameter. See integration that passed the key as a query parameter. See
`docs/COLLECTOR-INTEGRATION.md`. `docs/COLLECTOR-INTEGRATION.md`.
[Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.10.0...HEAD
[0.10.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.9.0...v0.10.0
[0.9.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.1...v0.9.0
[0.8.1]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.0...v0.8.1
[0.8.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.7.0...v0.8.0
[0.7.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.6.0...v0.7.0
[0.6.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.5.0...v0.6.0
[0.5.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/releases/tag/v0.5.0

View File

@@ -36,6 +36,7 @@ should be typed here again.
| what the collector sends | `docs/COLLECTOR-INTEGRATION.md`, ADR-006 | | what the collector sends | `docs/COLLECTOR-INTEGRATION.md`, ADR-006 |
| how a site is deployed | `docs/INSTALL-WINDOWS.md`, `docs/OPERATE-WINDOWS.md` | | how a site is deployed | `docs/INSTALL-WINDOWS.md`, `docs/OPERATE-WINDOWS.md` |
| how code reaches GitHub | `tools/export-github.sh` | | how code reaches GitHub | `tools/export-github.sh` |
| which branch reaches which server | `devnotes/deploy-and-branch-model.html` |
## Standards are gates, not prose ## Standards are gates, not prose
@@ -52,6 +53,13 @@ venv/bin/python scripts/gen_project_map.py --check
A new plugin table means updating `PLUGIN_TABLE_OWNERS` and A new plugin table means updating `PLUGIN_TABLE_OWNERS` and
`EXPECTED_HEAD_REVISION` in `tests/test_plugin_migrations.py`. `EXPECTED_HEAD_REVISION` in `tests/test_plugin_migrations.py`.
Dependencies have their own check. It needs the network, so it is not in the
suite - run it before a release, and see `docs/DEPENDENCY-SECURITY.md`:
```bash
venv/bin/python scripts/scan_sbom.py --gate # exit 1 on a SHIPPED finding
```
## Database ## Database
- **Active:** `shopdb_flask` (MySQL, asset-based schema) - **Active:** `shopdb_flask` (MySQL, asset-based schema)
@@ -95,7 +103,9 @@ surface and always ships.
- `shopdb/plugins/loader.py` - discovery and dependency-aware loading - `shopdb/plugins/loader.py` - discovery and dependency-aware loading
- `shopdb/plugins/alembic_template.py` - the env every plugin chain runs through - `shopdb/plugins/alembic_template.py` - the env every plugin chain runs through
- `frontend/src/router/index.js`, `frontend/src/components/AppSidebar.vue` - `frontend/src/router/index.js`, `frontend/src/components/AppSidebar.vue`
- `tools/export-github.sh` - scrub and publication gate - `tools/export-github.sh` - scrub and publication gate; builds the dist for
the branch it is on (`wj` -> /shopdb, `dev` -> /ops) and refuses on any other
- `tools/mirror-wilm.sh` - push a carried wilm bundle to gitea
## State ## State

View File

@@ -24,6 +24,24 @@
- **Index names**: `idx_<table>_<column>`. Underscores allowed here only because indexes are infrastructure, not data. - **Index names**: `idx_<table>_<column>`. Underscores allowed here only because indexes are infrastructure, not data.
- Good: `idx_machines_locationid` - Good: `idx_machines_locationid`
### Migrations and deploy ordering
- **A model change ships WITH its migration in the same change.** Adding or
changing a column/table on a SQLAlchemy model without the matching Alembic
migration is a broken change, even if it passes tests against a freshly built
schema.
- **Code and schema must never deploy out of step.** A model that references a
column its migration has not applied yet makes MySQL raise
`1054 Unknown column`, which 500s every query on that table (this is not a
degraded read - the whole endpoint fails).
- **Bringing up a new image MUST run migrations before serving traffic**, in
order: `flask db upgrade` (core chain) then `flask plugin upgrade-all`
(per-plugin chains). A rebuilt image with new model code but an
un-migrated database will 500 until this runs.
- Core schema lives in `migrations/versions/`; each plugin owns its own chain
under `plugins/<name>/migrations/`. New schema = a new migration with an
idempotent guard and a real downgrade, clean on strict MySQL 8.
### Allowed acronyms (closed list, expand by PR) ### Allowed acronyms (closed list, expand by PR)
- **Universal**: `id`, `url`, `api`, `http`, `https`, `json`, `jwt`, `sql`, `os`, `ip`, `dns`, `csv`, `pdf`, `cors`, `ttl`, `uuid`, `html`, `css`, `orm` - **Universal**: `id`, `url`, `api`, `http`, `https`, `json`, `jwt`, `sql`, `os`, `ip`, `dns`, `csv`, `pdf`, `cors`, `ttl`, `uuid`, `html`, `css`, `orm`
@@ -136,7 +154,7 @@ When creating a new plugin:
4. Follow naming conventions above. 4. Follow naming conventions above.
5. Run `flask plugin install <name>` to install. 5. Run `flask plugin install <name>` to install.
See `docs/PLUGIN-QUICKSTART.md` (when published) for the 30-minute walkthrough. See the PLUGIN-QUICKSTART wiki page (when published) for the 30-minute walkthrough.
## Testing ## Testing
@@ -151,6 +169,56 @@ Tests are required for:
- New plugin hooks (contract test) - New plugin hooks (contract test)
- Schema migrations (before/after data integrity) - Schema migrations (before/after data integrity)
## Branches: what belongs on main, and what does not
`main` is the product every site runs and the publication mirror exports.
`<site>` branches hold work that is not ready for other sites to see. There are
two: `wj` here, and `wilm` on the corporate GitHub, where the other site
develops the .NET endpoint agent.
A site branch is not a fork. Both feed `main`, and the sooner they do it the
cheaper it is. A branch the dev box cannot see is still a branch: `wilm` is not
visible from here because this machine has no GitHub account, which is why
`fetch-wilm-branch.cmd` on the share exists to bring it back for review.
**Most work is product.** ADR-015 gives site differences three homes on `main`:
a setting with a neutral default, seed data, or a site-namespaced directory
(`scripts/site_imports/<site>/`, which the style gate exempts). That is the only
site-specific home in the tree, so a site branch is about TIMING, not locality:
things that are not ready yet, rather than things that are permanently ours.
Say which at commit time, with a trailer, because the decision does not survive
being deferred - months later it is a list of subject lines and a guess:
```bash
git commit -m "Add the thing" -m "Scope: product" # main should have it
git commit -m "Add the thing" -m "Scope: site" # this site alone
```
Then:
```bash
tools/wj-status.sh # what is unpromoted, and where it belongs
tools/promote-to-main.sh --dry-run <sha> # what files would reach main
tools/promote-to-main.sh <sha> [<sha> ...] # oldest first; runs main's gates
tools/git-push.sh main # you push, deliberately
```
Windows equivalents are `tools\git-push.cmd` and `tools\promote-to-main.cmd`.
Promotion cherry-picks NAMED commits rather than merging the branch. Merging
takes the site work along with the product work, which is how a site branch
quietly becomes the product.
**The failure to watch for is not what you think.** Promoting something
site-specific by accident is caught by the ADR-015 gate on the way into `main`.
The real risk is product work sitting on a site branch until bringing it back is
a port rather than a merge - one such round trip cost a MySQL dialect fix, a
contract violation and a plugin install.
So rebase the site branch on `main` often, and promote as you go rather than in
one batch at the end.
## Code review checklist ## Code review checklist
- [ ] No underscores in table or column names - [ ] No underscores in table or column names

View File

@@ -23,13 +23,72 @@ FROM node:20-slim AS frontendbuild
WORKDIR /build WORKDIR /build
# Copy only the manifests first so `npm ci` caches on dependency changes. # Optionally trust the site's corporate root CA so npm's TLS to the registry
COPY frontend/package*.json ./ # validates behind an inspecting proxy. The .pem is gitignored and absent from a
RUN npm ci # clean checkout, so the copy is made optional by pairing it with a file that
# always exists; the cert is installed only when actually provided. Without it
# npm 10.x behind an inspecting proxy fails silently and leaves an incomplete
# node_modules; a clean build with no proxy is unaffected.
COPY corp-root-ca.pem* frontend/package.json /tmp/corpca/
# node:20-slim ships without the ca-certificates package (no update-ca-certificates
# and no /usr/local/share/ca-certificates), so install it before trusting the cert.
RUN if [ -f /tmp/corpca/corp-root-ca.pem ]; then \
apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
cp /tmp/corpca/corp-root-ca.pem /usr/local/share/ca-certificates/corp-root-ca.crt && \
update-ca-certificates; \
fi
COPY frontend/ ./ # Copy only the manifests first so `npm ci` caches on dependency changes.
RUN npm run build COPY frontend/package*.json ./frontend/
# Output lands in /build/dist (Vite default), copied into the final stage below. # Trust the corp CA for npm's TLS only when it was actually installed above. The
# guard keeps NODE_EXTRA_CA_CERTS unset on a clean checkout (no cert), so Node
# does not warn about a missing cert path; a proxy-inspected build still gets
# the cert because the file is present.
# Install the frontend dependencies. vite and the rest of the build toolchain
# live under devDependencies, so include them explicitly (--include=dev).
# Behind an inspecting proxy (Zscaler) each package fetch is slow (40-50s) and
# npm's default concurrency piles up parallel sockets that the proxy resets
# mid-stream, making npm abort with "Exit handler never called" and leaving an
# incomplete node_modules with no vite. Serializing to a single socket
# (maxsockets 1) lets every fetch complete; the retry loop and raised timeouts
# ride out any remaining transient reset, and the final test hard-verifies the
# toolchain landed so a truncated install fails loudly here rather than as a
# confusing "vite: not found" three steps later.
RUN if [ -f /usr/local/share/ca-certificates/corp-root-ca.crt ]; then \
export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root-ca.crt; \
fi; \
cd frontend && \
npm config set fetch-retries 5 && \
npm config set fetch-retry-factor 2 && \
npm config set fetch-retry-mintimeout 20000 && \
npm config set fetch-retry-maxtimeout 120000 && \
npm config set fetch-timeout 600000 && \
npm config set maxsockets 1 && \
( npm ci --include=dev --no-audit --no-fund \
|| { echo "npm ci attempt 1 failed, retrying..."; sleep 5; npm ci --include=dev --no-audit --no-fund; } \
|| { echo "npm ci attempt 2 failed, retrying..."; sleep 10; npm ci --include=dev --no-audit --no-fund; } ) && \
test -x node_modules/.bin/vite || { \
echo "ERROR: node_modules/.bin/vite missing after npm ci (proxy likely truncated the install)."; \
exit 1; \
}
# The frontend keeps its repo-relative layout here, because npm prebuild runs
# `node ../scripts/stage-frontend.mjs`, which stages each plugin's frontend/ into
# the Vite tree and codegens routes.gen.js. That script resolves its repo root as
# its own directory's parent, so it needs scripts/ and plugins/ as SIBLINGS of
# frontend/. Building with frontend/ flattened to the stage root made `../scripts`
# resolve to /scripts and the build failed on a missing module.
COPY scripts/stage-frontend.mjs ./scripts/
COPY plugins/ ./plugins/
COPY frontend/ ./frontend/
RUN if [ -f /usr/local/share/ca-certificates/corp-root-ca.crt ]; then \
export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root-ca.crt; \
fi; \
cd frontend && npm run build
# Output lands in /build/frontend/dist (Vite default), copied into the final
# stage below.
# ---- Stage 2: Python application image ---- # ---- Stage 2: Python application image ----
FROM python:3.14-slim AS base FROM python:3.14-slim AS base
@@ -60,7 +119,14 @@ COPY scripts/ ./scripts/
COPY wsgi.py ./ COPY wsgi.py ./
# Built SPA from stage 1. Flask serves it via register_frontend_routes. # Built SPA from stage 1. Flask serves it via register_frontend_routes.
COPY --from=frontendbuild /build/dist ./frontend/dist COPY --from=frontendbuild /build/frontend/dist ./frontend/dist
# Create instance/ IN THE IMAGE, owned by the app user, before the chown below.
# Docker seeds an empty named volume from the image's content at the mountpoint,
# ownership included. Without this the mountpoint is created root-owned 0755 and
# the container, which runs as shopdb, cannot write plugins.json or any upload
# into its own instance directory.
RUN mkdir -p /app/instance
RUN useradd --create-home --shell /bin/bash shopdb \ RUN useradd --create-home --shell /bin/bash shopdb \
&& chown -R shopdb:shopdb /app && chown -R shopdb:shopdb /app
@@ -69,14 +135,20 @@ USER shopdb
EXPOSE 5001 EXPOSE 5001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl --fail --silent http://localhost:5001/api/auth/login -X POST \ CMD curl --silent http://localhost:5001/api/auth/login -X POST \
-H "Content-Type: application/json" -d '{}' \ -H "Content-Type: application/json" -d '{}' \
| grep -q "VALIDATION_ERROR" || exit 1 | grep -q "VALIDATION_ERROR" || exit 1
# Threaded workers, matching docker-compose.yml - see the reasoning there. A
# compose file overrides this CMD, but an image run without one (a plain
# `docker run`, a site's own orchestrator) would otherwise still get the
# blocking sync default and the same starvation.
CMD ["gunicorn", \ CMD ["gunicorn", \
"--bind", "0.0.0.0:5001", \ "--bind", "0.0.0.0:5001", \
"--worker-class", "gthread", \
"--workers", "4", \ "--workers", "4", \
"--timeout", "60", \ "--threads", "8", \
"--timeout", "30", \
"--access-logfile", "-", \ "--access-logfile", "-", \
"--error-logfile", "-", \ "--error-logfile", "-", \
"wsgi:app"] "wsgi:app"]

View File

@@ -22,7 +22,7 @@ ShopDB tracks and manages:
**Backend:** **Backend:**
- Python 3.14 with Flask - Python 3.14 with Flask
- SQLAlchemy ORM - SQLAlchemy ORM
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md) - MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page)
- JWT authentication - JWT authentication
- Plugin architecture for extensibility - Plugin architecture for extensibility
@@ -100,7 +100,7 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
- Python 3.14 - Python 3.14
- Node.js 18+ - Node.js 18+
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md) - MySQL 5.7+ (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page)
ShopDB Flask uses MySQL as the canonical database. SQLite is used only for the ShopDB Flask uses MySQL as the canonical database. SQLite is used only for the
test suite (`TestingConfig` in `shopdb/config.py` points at an in-memory test suite (`TestingConfig` in `shopdb/config.py` points at an in-memory
@@ -121,7 +121,7 @@ so a container deploy needs no separate Node build step.
```bash ```bash
cp .env.example .env cp .env.example .env
# Edit .env: set SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS, # Edit .env: set SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS,
# and the MYSQL_* passwords (see docs/CONFIG.md for every variable). # and the MYSQL_* passwords (see the CONFIG wiki page for every variable).
docker compose up -d --build docker compose up -d --build
@@ -172,7 +172,7 @@ including VS Code and troubleshooting, is the DEVELOPMENT-SETUP page in the
project wiki. project wiki.
To import a site's legacy data, use the HTTP import surface: an admin API To import a site's legacy data, use the HTTP import surface: an admin API
token plus [docs/IMPORT-API.md](docs/IMPORT-API.md) drive the whole migration token plus the IMPORT-API page in the project wiki drive the whole migration
through documented endpoints (`X-Import-Mode` preserves original timestamps). through documented endpoints (`X-Import-Mode` preserves original timestamps).
`scripts/site_imports/wjf/` is the reference site's loader, kept as a worked `scripts/site_imports/wjf/` is the reference site's loader, kept as a worked
example of the whole sequence. example of the whole sequence.
@@ -181,11 +181,11 @@ example of the whole sequence.
| Target | Use | | Target | Use |
|---|---| |---|---|
| **Windows Server + IIS** (how sister sites run) | **[docs/INSTALL-WINDOWS.md](docs/INSTALL-WINDOWS.md)** - one installer `.exe`, offline, no manual IIS work. Day 2: [docs/OPERATE-WINDOWS.md](docs/OPERATE-WINDOWS.md) | | **Windows Server + IIS** (how sister sites run) | **the INSTALL-WINDOWS page in the project wiki** - one installer `.exe`, offline, no manual IIS work. Day 2: the OPERATE-WINDOWS page in the project wiki |
| Linux / Docker, air-gapped | [docs/DEPLOY-AIRGAP.md](docs/DEPLOY-AIRGAP.md) | | Linux / Docker, air-gapped | the DEPLOY-AIRGAP page in the project wiki |
| Linux / Docker, connected | [docs/DEPLOY.md](docs/DEPLOY.md) | | Linux / Docker, connected | the DEPLOY page in the project wiki |
For every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md). For every environment variable and Setting key see the CONFIG page in the project wiki.
### What the Windows installer requires ### What the Windows installer requires
@@ -205,7 +205,7 @@ The binding constraint is the operating system rather than IIS: the payload is
Python 3.14 plus HttpPlatformHandler and URL Rewrite, and the handler itself runs Python 3.14 plus HttpPlatformHandler and URL Rewrite, and the handler itself runs
on older IIS. An older host is untested rather than known-broken. on older IIS. An older host is untested rather than known-broken.
If you must deploy onto something older, [docs/INSTALL-WINDOWS-IIS.md](docs/INSTALL-WINDOWS-IIS.md) If you must deploy onto something older, the INSTALL-WINDOWS-IIS page in the project wiki
is the manual procedure and has no OS gate - it even documents MySQL 5.6, which is is the manual procedure and has no OS gate - it even documents MySQL 5.6, which is
that era of machine. The trade is stated there: it produces a server the that era of machine. The trade is stated there: it produces a server the
installer will not subsequently upgrade. installer will not subsequently upgrade.
@@ -248,7 +248,7 @@ Query parameters for list endpoints:
## Where to start ## Where to start
[docs/START-HERE.md](docs/START-HERE.md) routes by what you are here to do - the START-HERE page in the project wiki routes by what you are here to do -
standing up a site, deploying the shop-floor tools, writing a plugin, standing up a site, deploying the shop-floor tools, writing a plugin,
integrating with the API, or diagnosing something. On the published mirror the integrating with the API, or diagnosing something. On the published mirror the
same page is the wiki's front door. same page is the wiki's front door.
@@ -259,7 +259,7 @@ ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for p
The image bundles these plugins; only the ones a site installs are loaded. The The image bundles these plugins; only the ones a site installs are loaded. The
current count and each plugin's version and migration head are in current count and each plugin's version and migration head are in
[docs/PROJECT-MAP.md](docs/PROJECT-MAP.md), which is generated: the PROJECT-MAP page in the project wiki, which is generated:
- **backups** - Machine configuration backups (NTLARS/DNC, CMM, part marker, UDC) with revision history and diffs - **backups** - Machine configuration backups (NTLARS/DNC, CMM, part marker, UDC) with revision history and diffs
- **computers** - Shopfloor PCs and workstations, collector fleet ingest - **computers** - Shopfloor PCs and workstations, collector fleet ingest

12
agent/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# .NET build output - never tracked; produced by dotnet build/publish.
bin/
obj/
publish/
# Application-delivery development builds. Held in reserve for extensive
# testing; never shipped to a site publish folder or tracked.
dev-bin/
# Local publish/staging scratch and command-output captures.
_stage/
*.user

177
agent/README.md Normal file
View File

@@ -0,0 +1,177 @@
# ShopDB Endpoint Agent
A Windows telemetry agent that reports endpoint state to a ShopDB server's
`endpoints` plugin. It runs as a Windows service in production and as a console
app for development and testing.
## Projects
- `ShopDbEndpointAgent.Core` - contracts, config, logging, collection, and the
resilient collector HTTP client. No host concerns; unit-testable.
- `ShopDbEndpointAgent` - the service host (generic host + Windows service
lifetime). Composes Core through DI. This is the telemetry runtime.
- `ShopDbEndpointAgent.Tray` - interactive user-session tray app: a system-tray
icon with "Send Status now" (on-demand report) and the 5-click issue reporter
(ServiceNow flow is still a scaffold). Separate process from the service,
because a SYSTEM service cannot draw a tray icon in a user session.
## Configuration
Runtime config is layered. Non-secret defaults live in `appsettings.json`
(shipped with the build). The collector key and base URL are provisioned out of
band in the registry and win over `appsettings.json`, so a distributed build
never carries the secret and the site can rotate the key without a rebuild.
### Registry (production)
Create these values under `HKLM\SOFTWARE\GE\ShopDB` (64-bit view):
| Value | Type | Meaning |
|----------------|--------|--------------------------------------------------|
| `BaseUrl` | String | `https://shopdb.example.net` (server root) |
| `CollectorKey` | String | The endpoint agent key from Settings -> Endpoints -> Key management |
Example (elevated PowerShell):
```
New-Item -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Force | Out-Null
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'BaseUrl' -Value 'https://shopdb.example.net'
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'CollectorKey' -Value '<paste the generated key>'
```
### appsettings.json (all environments)
| Key | Default | Meaning |
|-------------------|-------------|------------------------------------------------|
| `baseurl` | "" | Dev fallback for the server root (registry wins). |
| `apikey` | placeholder | Dev fallback for the collector key (registry wins). |
| `cadenceseconds` | 900 | Report interval (15 min). |
| `jitterseconds` | 60 | Random +/- spread on the interval (anti-herd). |
| `adoptonfirstrun` | true | Send `adopt=true` on first contact only. |
| `utilitiespath` | "" | Directory of site PowerShell "agent utilities". |
| `logpath` | "" | Override the rolling log directory. |
For a dev box, set `baseurl` and `apikey` here and skip the registry.
## Running
Console (dev), one report then exit:
```
dotnet run --project ShopDbEndpointAgent -- --once
```
Console (dev), continuous cadence loop:
```
dotnet run --project ShopDbEndpointAgent -- --console
```
## Install as a Windows service
Publish self-contained, then register with `sc.exe` (elevated):
```
dotnet publish ShopDbEndpointAgent -c Release -r win-x64 --self-contained -o C:\ProgramData\ShopDB\EndpointAgent\bin
sc.exe create "ShopDBEndpointAgent" binPath= "C:\ProgramData\ShopDB\EndpointAgent\bin\shopdb-endpoint-agent.exe" start= auto DisplayName= "ShopDB Endpoint Agent"
sc.exe description "ShopDBEndpointAgent" "Reports endpoint telemetry to ShopDB."
sc.exe start "ShopDBEndpointAgent"
```
The service runs as LocalSystem by default, which it needs to read the Security
event channel and system WMI.
## Tray app (system-tray icon)
The tray is a per-user process, not a service, so it starts at sign-in rather
than under `sc.exe`. The generated `install.ps1` handles it: it copies
`shopdb-endpoint-tray.exe` next to the agent and registers a machine-wide logon
autostart (`HKLM\Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`,
64-bit view). The installer deliberately does NOT start the tray itself: it runs
elevated, so any process it starts inherits an admin token, and mapped drives are
per logon token - an elevated tray probes the wrong drive table. The Run key only
fires at logon, which would leave the tray dead until sign-out on a machine
someone is already using, so the installer also registers a transient
`ShopDB tray first launch` scheduled task under `S-1-5-32-545` at `RunLevel
Limited`, runs it once, and unregisters it. That task is the only mechanism that
can reach into an existing interactive session without carrying the installer's
token. Pass `-NoTray` for a headless, service-only install.
User-context collection does not depend on the tray. The installer registers a
`ShopDB user-context probe` scheduled task against the Users group at
`RunLevel Limited`, which runs `shopdbagent.exe --collect-usercontext` in the
operator's own session at logon and every 30 minutes.
Right-click the tray icon for:
- `Send Status now` - reports this PC to ShopDB immediately so the monitor page
refreshes without waiting for the next cadence tick. It shells out to
`shopdbagent.exe --once`, reusing the exact production collection path; the
tray holds no collector key. A balloon tip reflects success or failure (on
failure, see the rolling agent log).
- `Report an issue...` - the ServiceNow ticket flow (scaffold; submission path
is an open decision).
A desktop shortcut to `shopdb-endpoint-tray.exe` can be added later for an
explicitly launchable ServiceNow surface.
## WebUI command execution model (security context)
The WebUI can send an endpoint an allowlisted command (server source of truth:
`plugins/endpoints/commands.py`; agent handler: `CommandExecutor`). The agent
service runs as LocalSystem, but not every command runs in that context. The
launch context is chosen per action so processes surface to the operator while
privileged operations keep the token they need:
- Generic `process_start` (for example DncMain, UDC) launches into the ACTIVE
CONSOLE USER's session at medium integrity, so the app is visible on the
operator's desktop. CyberArk/EPM brokers any elevation afterward.
- Generic `process_stop` / `process_status` run as SYSTEM (kill and probe).
- `service_start` / `service_stop` / `service_status` run as SYSTEM, because
service control requires the admin/SYSTEM token.
- `mtconnect_start` / `mtconnect_stop` / `mtconnect_status` all run as SYSTEM,
because MTConnect is the "UDC MTConnect Agent" Windows service and service
control requires the admin/SYSTEM token. `mtconnect_start` runs `install.bat`
(`Agent.exe install`) to install or repair the service, then ensures it is
running; `mtconnect_stop` stops the service; `mtconnect_status` queries it.
`run.bat` (`agent.exe run agent.cfg`) is a foreground TESTING launcher and is
deliberately NOT used by the agent.
- `system_restart` runs as SYSTEM and reboots the machine immediately via
`shutdown.exe /r /t 0` (no grace timer). It is a fixed action with no target,
gated server-side by the dedicated `endpoints.restart` permission on top of
`endpoints.command`, and fronted by a WebUI confirmation gate.
Do not route MTConnect through the generic `process_start` user-session path or
service control breaks. MTConnect is intentionally absent from the
`process_start` launch map for the same reason; the fixed `mtconnect_*` handler
owns it.
Behavior note for redeploys: the MTConnect service-control model is a runtime
change, so the agent must be rebuilt and redeployed for it to take effect.
Server-side command/allowlist/RBAC changes are picked up by the container
rebuild; rebuild and redeploy the agent whenever the runtime launch/control
logic itself changes.
## Diagnostics
- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\YYYYMMDD-agent.log`
(14-day retention).
- Windows Event Log: agent runtime failures and unhandled exceptions are written
to the `Application` log under source `ShopDB Endpoint Agent`. The source is
created on first elevated run (or by the installer). Filter Event Viewer by
that source to see agent/system interop failures.
## Agent utilities (PowerShell seam)
If `utilitiespath` points at a directory, the runtime runs each `*.ps1` there
per report, parses its JSON stdout, and merges the result into the report. This
is the seam the future server-side "Agent configuration" settings page uses to
push site-authored collectors down. Scripts must be code-signed and
allow-listed before this is enabled in the field (rollout concern).
## Logging extensibility
All runtime code logs through `Microsoft.Extensions.Logging` (`ILogger`). Adding
a sink later (a ShopDB HTTP sink, Seq, syslog) is a host-builder change in
`Program.cs::ConfigureLogging` with no changes to the worker, collector, or
client.

View File

@@ -0,0 +1,358 @@
using System.Management;
using System.Net.Http.Json;
using System.Net.NetworkInformation;
using System.Runtime.Versioning;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ShopDbAdoptionTool;
// ShopDB adoption tool (SPIKE). A one-shot, agentless enrollment client: it uses
// only the endpoints collector key to create a ShopDB PC (Computer) + Endpoint
// entry via the existing collector adopt path, then exits. It is NOT the endpoint
// agent - it installs nothing, runs no service, and reports no ongoing telemetry.
// For sites that want an inventory entry without running the full agent.
//
// Configuration comes from adopt.config.json beside the exe, or from
// --baseurl / --collectorkey / --hostname arguments (arguments win). The
// collector key is never written to disk by this tool; it is read from the
// config the operator generated and placed next to the exe.
[SupportedOSPlatform("windows")]
internal static class Program
{
private const string ReportPath = "/api/collector/endpoints";
private static async Task<int> Main(string[] args)
{
try
{
var config = LoadConfig(args);
if (string.IsNullOrWhiteSpace(config.BaseUrl) ||
string.IsNullOrWhiteSpace(config.CollectorKey))
{
Console.Error.WriteLine(
"Missing baseurl or collectorkey. Provide adopt.config.json next to the "
+ "exe or pass --baseurl and --collectorkey.");
return 2;
}
// The app manifest forces UAC elevation, so this is a belt-and-braces
// guard for edge cases (for example a stripped manifest). Full WMI and
// the machine Uninstall hives need admin, or collection is partial.
if (!IsElevated())
{
Console.Error.WriteLine(
"This tool must run as administrator so hardware and installed-"
+ "software collection succeeds. Right-click and Run as administrator.");
return 3;
}
var payload = BuildPayload(config);
Console.WriteLine($"Adopting '{payload.Hostname}' into {config.BaseUrl} ...");
Console.WriteLine(
$"Collected {payload.Applications?.Length ?? 0} installed applications.");
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
client.DefaultRequestHeaders.Add("X-API-Key", config.CollectorKey);
var url = config.BaseUrl!.TrimEnd('/') + ReportPath;
using var response = await client.PostAsJsonAsync(url, payload);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
Console.Error.WriteLine(
$"Adoption failed: HTTP {(int)response.StatusCode}. {body}");
return 1;
}
Console.WriteLine("Adoption succeeded.");
Console.WriteLine(body);
return 0;
}
catch (Exception error)
{
Console.Error.WriteLine($"Adoption error: {error.Message}");
return 1;
}
}
private static AdoptConfig LoadConfig(string[] args)
{
var config = new AdoptConfig();
var configPath = Path.Combine(AppContext.BaseDirectory, "adopt.config.json");
if (File.Exists(configPath))
{
var fromFile = JsonSerializer.Deserialize<AdoptConfig>(
File.ReadAllText(configPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (fromFile is not null)
config = fromFile;
}
for (var i = 0; i < args.Length - 1; i++)
{
var value = args[i + 1];
switch (args[i].ToLowerInvariant())
{
case "--baseurl":
config.BaseUrl = value;
break;
case "--collectorkey":
config.CollectorKey = value;
break;
case "--hostname":
config.Hostname = value;
break;
}
}
return config;
}
private static AdoptPayload BuildPayload(AdoptConfig config)
{
var hostname = !string.IsNullOrWhiteSpace(config.Hostname)
? config.Hostname!
: Environment.MachineName;
return new AdoptPayload
{
Hostname = hostname,
Adopt = true,
LoggedInUser = GetLoggedInUser(),
SerialNumber = WmiFirst("SELECT SerialNumber FROM Win32_BIOS", "SerialNumber"),
PcType = WmiFirst("SELECT Model FROM Win32_ComputerSystem", "Model"),
VendorName = WmiFirst("SELECT Manufacturer FROM Win32_ComputerSystem", "Manufacturer"),
ModelNumber = GetModelNumber(),
OsName = WmiFirst("SELECT Caption FROM Win32_OperatingSystem", "Caption"),
IpAddress = ResolveIpAddress(),
LastBootTime = GetLastBootTime(),
Applications = GetApplications(),
AgentVersion = "adopt-0.1.0",
};
}
// Mirrors the endpoint agent: prefer the WMI logged-on user, fall back to
// the process identity so the field is never blank.
private static string GetLoggedInUser()
{
var user = WmiFirst("SELECT UserName FROM Win32_ComputerSystem", "UserName");
return string.IsNullOrWhiteSpace(user)
? $"{Environment.UserDomainName}\\{Environment.UserName}"
: user;
}
// Human-readable model first (what pctype classifies on); SystemSKUNumber is
// an OEM SKU code, used only when Model is blank. Matches EndpointCollector.
private static string? GetModelNumber()
{
var model = WmiFirst("SELECT Model FROM Win32_ComputerSystem", "Model");
if (!string.IsNullOrWhiteSpace(model))
return model;
return WmiFirst("SELECT SystemSKUNumber FROM Win32_ComputerSystem",
"SystemSKUNumber");
}
private static DateTimeOffset? GetLastBootTime()
{
var raw = WmiFirst("SELECT LastBootUpTime FROM Win32_OperatingSystem",
"LastBootUpTime");
if (string.IsNullOrWhiteSpace(raw))
return null;
try
{
return ManagementDateTimeConverter.ToDateTime(raw).ToUniversalTime();
}
catch
{
return null;
}
}
// Run one WMI query and return the first row's column as a trimmed string,
// or null. Best-effort: WMI failures never abort adoption.
private static string? WmiFirst(string query, string column)
{
try
{
using var searcher = new ManagementObjectSearcher(query);
foreach (var item in searcher.Get())
{
var value = item[column]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(value))
return value;
}
}
catch
{
// Best-effort: the server treats every hardware field as optional.
}
return null;
}
// Installed-software SBOM. Mirrors the endpoint agent exactly: enumerate both
// Uninstall registry hives (64-bit and WOW6432Node), keep entries with a
// DisplayName, de-dupe on name+version, and emit {displayname, displayversion,
// publisher}. Requires admin to read the machine hives reliably (see the
// app manifest). Never throws out to the caller.
private static object[] GetApplications()
{
const string uninstall64 =
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
const string uninstall32 =
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
var result = new List<object>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
CollectUninstallHive(uninstall64, result, seen);
CollectUninstallHive(uninstall32, result, seen);
return result.ToArray();
}
private static void CollectUninstallHive(string subKey, List<object> result,
HashSet<string> seen)
{
try
{
using var machine = Microsoft.Win32.RegistryKey.OpenBaseKey(
Microsoft.Win32.RegistryHive.LocalMachine,
Microsoft.Win32.RegistryView.Default);
using var uninstall = machine.OpenSubKey(subKey);
if (uninstall is null)
return;
foreach (var name in uninstall.GetSubKeyNames())
{
try
{
using var entry = uninstall.OpenSubKey(name);
var displayName = entry?.GetValue("DisplayName")?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(displayName))
continue;
var displayVersion =
entry?.GetValue("DisplayVersion")?.ToString()?.Trim() ?? "";
var publisher =
entry?.GetValue("Publisher")?.ToString()?.Trim() ?? "";
if (!seen.Add(displayName + "\u0000" + displayVersion))
continue;
result.Add(new
{
displayname = displayName,
displayversion = displayVersion,
publisher,
});
}
catch
{
// Skip unreadable entries; keep collecting the rest.
}
}
}
catch
{
// Skip an unreadable hive; the other hive may still return data.
}
}
private static string? ResolveIpAddress()
{
try
{
foreach (var network in NetworkInterface.GetAllNetworkInterfaces())
{
if (network.OperationalStatus != OperationalStatus.Up)
continue;
if (network.NetworkInterfaceType == NetworkInterfaceType.Loopback)
continue;
foreach (var address in network.GetIPProperties().UnicastAddresses)
{
if (address.Address.AddressFamily ==
System.Net.Sockets.AddressFamily.InterNetwork)
return address.Address.ToString();
}
}
}
catch
{
// Best-effort: the server treats ipaddress as optional.
}
return null;
}
// True when the process runs with the Administrators group in its token.
private static bool IsElevated()
{
try
{
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity);
return principal.IsInRole(
System.Security.Principal.WindowsBuiltInRole.Administrator);
}
catch
{
return false;
}
}
}
internal sealed class AdoptConfig
{
[JsonPropertyName("baseurl")]
public string? BaseUrl { get; set; }
[JsonPropertyName("collectorkey")]
public string? CollectorKey { get; set; }
[JsonPropertyName("hostname")]
public string? Hostname { get; set; }
}
// Payload keys mirror the endpoints collector adopt schema exactly (lowercase,
// concatenated) so the server stores them without translation. This tool sends
// the adopt identity plus the hardware and SBOM subset the endpoint agent
// collects; ongoing health telemetry (cpu, services, events) is intentionally
// absent because this is agentless enrollment, not the agent.
internal sealed class AdoptPayload
{
[JsonPropertyName("hostname")]
public string Hostname { get; set; } = "";
[JsonPropertyName("adopt")]
public bool Adopt { get; set; }
[JsonPropertyName("loggedinuser")]
public string? LoggedInUser { get; set; }
[JsonPropertyName("serialnumber")]
public string? SerialNumber { get; set; }
[JsonPropertyName("pctype")]
public string? PcType { get; set; }
[JsonPropertyName("vendorname")]
public string? VendorName { get; set; }
[JsonPropertyName("modelnumber")]
public string? ModelNumber { get; set; }
[JsonPropertyName("osname")]
public string? OsName { get; set; }
[JsonPropertyName("ipaddress")]
public string? IpAddress { get; set; }
[JsonPropertyName("lastboottime")]
public DateTimeOffset? LastBootTime { get; set; }
[JsonPropertyName("applications")]
public object[]? Applications { get; set; }
[JsonPropertyName("agentversion")]
public string? AgentVersion { get; set; }
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ShopDbAdoptionTool</RootNamespace>
<AssemblyName>shopdb-adopt</AssemblyName>
<!-- 0.1.0 (SPIKE): one-shot, agentless enrollment. Uses only the endpoints
collector key to create a ShopDB PC (Computer) + Endpoint entry via the
existing POST /api/collector/endpoints adopt path. Deliberately NOT the
endpoint agent: no service, no polling, no delivery. For sites that want
an inventory entry without running the full agent. -->
<Version>0.1.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>none</DebugType>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Management" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.1.0.0" name="ShopDbAdoptionTool.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v2">
<!-- Adoption reads the machine Uninstall hives and full WMI hardware for
the SBOM and hardware collection. Require elevation so the run is not
silently partial when launched from a standard user token. -->
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 and Windows 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,235 @@
using System.Text.Json.Serialization;
namespace ShopDbEndpointAgent.Core;
// Runtime configuration for the agent. Non-secret values come from
// appsettings.json; the collector key and base URL are provisioned out of band
// in the registry (HKLM:\SOFTWARE\GE\ShopDB) and layered on top - see
// RegistryConfigProvider. The registry wins so a distributed appsettings.json
// never carries the secret and the site can rotate the key without a rebuild.
public sealed class AgentConfig
{
[JsonPropertyName("baseurl")] public string BaseUrl { get; set; } = "";
[JsonPropertyName("apikey")] public string ApiKey { get; set; } = "";
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
// Jitter spreads a fleet's reports so they do not arrive in lockstep
// (thundering herd). Actual delay = cadence +/- up to jitterseconds.
[JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60;
[JsonPropertyName("adoptonfirstrun")] public bool AdoptOnFirstRun { get; set; } = true;
// Directory holding site-provided PowerShell "agent utilities" the runtime
// may invoke through the documented collection seam. Empty = none loaded.
[JsonPropertyName("utilitiespath")] public string UtilitiesPath { get; set; } = "";
// Absolute path for the rolling diagnostics log. Empty = the default under
// %ProgramData%\ShopDB\EndpointAgent\logs.
[JsonPropertyName("logpath")] public string LogPath { get; set; } = "";
// What this endpoint watches. Shipped in appsettings.json (non-secret) so a
// site can retarget monitors without a rebuild. Processes are interactive
// apps watched by image name; services are Windows services watched by their
// service key name. Empty lists = nothing watched.
[JsonPropertyName("monitors")] public MonitorConfig Monitors { get; set; } = new();
// Per-image launchable paths for the generic process_start action. The wire
// historically never carried a path; now a server-provisioned monitor MAY
// carry one, but the agent only accepts it after LaunchPathPolicy validates
// it against ApprovedLaunchRoots (see below). Start otherwise sends only the
// image name and the agent maps it here to a known-good executable
// (or a .bat launcher). Anything not in the resolved map fails closed. A
// site overrides or extends the shipped defaults in appsettings.json without
// a rebuild. Keyed by process image name (case-insensitive, .exe optional).
[JsonPropertyName("launchpaths")]
public Dictionary<string, string> LaunchPaths { get; set; } = new();
// Approved root directories a server-provisioned launch path must sit under
// before the agent will honor it. Empty = fall back to LaunchPathPolicy's
// shipped org defaults (Program Files roots). A site (or the future Site
// Agent Configuration tool) can widen or narrow this without a rebuild.
[JsonPropertyName("approvedlaunchroots")]
public List<string> ApprovedLaunchRoots { get; set; } = new();
// Shipped defaults for the org's common shop-floor apps. A site's
// appsettings.json LaunchPaths entries win over these on a per-image basis.
// MTConnect is intentionally absent here: it is a Windows service controlled
// by the fixed mtconnect_* handler (install.bat + service control), not the
// generic process_start path. eDNC and UDC launch their runtime executables
// directly.
public static readonly IReadOnlyDictionary<string, string> DefaultLaunchPaths =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["DncMain.exe"] = @"C:\Program Files (x86)\Dnc\bin\DncMain.exe",
["UDC.exe"] = @"C:\Program Files\UDC\UDC.exe",
};
// The effective launch map: shipped defaults overlaid with appsettings.json
// entries (site wins). Keys are normalized to bare image names so a lookup
// tolerates 'DncMain' or 'DncMain.exe'.
public IReadOnlyDictionary<string, string> ResolveLaunchPaths()
{
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in DefaultLaunchPaths)
resolved[NormalizeImage(pair.Key)] = pair.Value;
foreach (var pair in LaunchPaths)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
continue;
resolved[NormalizeImage(pair.Key)] = pair.Value;
}
return resolved;
}
private static string NormalizeImage(string image)
{
var bare = (image ?? "").Trim();
if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
bare = bare[..^4];
return bare;
}
// REMOVED DELIBERATELY - DO NOT REINTRODUCE: 'scriptpaths' / ScriptPaths.
//
// This used to be a per-name map of PRE-STAGED scripts on the endpoint
// (typically under C:\ProgramData\ShopDB\EndpointAgent\scripts), which
// run_script would look up by name and execute. It was superseded by
// server-hosted delivery and left behind as dead config: nothing read the
// map, so it was an execution path that appeared supported but was not.
//
// It is gone rather than merely unused because reviving it is a security
// regression, and dead code invites revival. That model had NO integrity
// check - no digest, no provenance - so whoever could write into the staging
// directory chose what the agent ran as SYSTEM. ProgramData is world-
// writable by default unless the tree is explicitly tightened, which made
// the gate "is this name in appsettings.json", not "is this content
// trusted".
//
// The supported path is run_script with a server-issued delivery descriptor:
// the body is fetched from ShopDB over the collector credential and verified
// against a server-pinned SHA-256 before it executes, so the content is
// authenticated and the action is attributable in the command log. If you
// need a script to run on an endpoint, publish it server-side and send it
// through that path. If you need to trigger something already installed on
// the box, use scheduledtask_run, which is far narrower.
// Approved scheduled task paths for the scheduledtask_run action. The wire
// carries a free-text task path so the WebUI can expose it as a single
// field; ScheduledTaskPolicy validates it against this list and fails closed
// when it does not match. An entry matches that exact task path, or acts as
// a folder prefix for anything beneath it.
//
// Empty means "use the shipped Corporate Tools defaults"
// (ScheduledTaskPolicy.DefaultApprovedTaskPaths). Setting it REPLACES those
// defaults, so a site can narrow the set as well as widen it without an
// agent rebuild. Keep it tight: every entry here is something an operator
// holding endpoints.deploy can start, in whatever context the task itself
// registered.
[JsonPropertyName("approvedtaskpaths")]
public List<string> ApprovedTaskPaths { get; set; } = new();
public bool IsUsable()
=> !string.IsNullOrWhiteSpace(BaseUrl)
&& !string.IsNullOrWhiteSpace(ApiKey)
&& ApiKey != "REPLACE_WITH_COLLECTOR_KEY";
// Validates the BaseUrl scheme before any request is made. A plaintext
// http:// URL aimed at a real (non-loopback) server is the classic
// misconfiguration that hits the TLS port and used to spin the command
// long-poll into a storm; refusing to start on it turns a silent, noisy
// runtime failure into a single clear startup error. Loopback over http is
// still allowed so a developer running the API on plain http locally works.
// Returns a human-readable problem, or null when the BaseUrl is acceptable.
public string? BaseUrlProblem()
{
if (string.IsNullOrWhiteSpace(BaseUrl))
return null;
if (!Uri.TryCreate(BaseUrl.Trim(), UriKind.Absolute, out var uri))
return $"BaseUrl '{BaseUrl}' is not a valid absolute URL.";
if (uri.Scheme == Uri.UriSchemeHttps)
return null;
if (uri.Scheme == Uri.UriSchemeHttp)
{
if (uri.IsLoopback)
return null;
return $"BaseUrl '{BaseUrl}' uses plaintext http:// against a " +
"non-loopback host. The server requires https://; an http:// " +
"URL hits the TLS port and fails every request. Reprovision " +
"BaseUrl in HKLM\\SOFTWARE\\GE\\ShopDB (or appsettings.json) " +
"with an https:// URL.";
}
return $"BaseUrl '{BaseUrl}' uses unsupported scheme '{uri.Scheme}'. " +
"Use https:// (or http:// only for a loopback dev server).";
}
}
// Watched targets for this endpoint. Both lists carry entries with a wire name
// (process image name or Windows service key name) and an optional friendly app
// label the server/UI can show (for example "eDNC" for dncMain.exe).
public sealed class MonitorConfig
{
[JsonPropertyName("processes")] public MonitorTarget[] Processes { get; set; }
= Array.Empty<MonitorTarget>();
[JsonPropertyName("services")] public MonitorTarget[] Services { get; set; }
= Array.Empty<MonitorTarget>();
// Union the local (appsettings.json) watch list with the server-resolved one,
// keyed case-insensitively by wire name so a target watched both locally and
// by policy is not enumerated twice. Server entries win on the label (App) so
// a policy display name overrides a stale local one, but a local-only target
// is preserved - the server list augments, it does not replace. Nulls on
// either side degrade to the other, which is what keeps monitoring alive when
// a fetch fails.
public static MonitorConfig Merge(MonitorConfig? local, ResolvedMonitors? server)
{
return new MonitorConfig
{
Processes = MergeTargets(local?.Processes, server?.Processes),
Services = MergeTargets(local?.Services, server?.Services),
};
}
private static MonitorTarget[] MergeTargets(
MonitorTarget[]? local, MonitorTarget[]? server)
{
var byName = new Dictionary<string, MonitorTarget>(
StringComparer.OrdinalIgnoreCase);
foreach (var target in local ?? Array.Empty<MonitorTarget>())
{
if (string.IsNullOrWhiteSpace(target?.Name))
continue;
byName[target.Name.Trim()] = target;
}
foreach (var target in server ?? Array.Empty<MonitorTarget>())
{
if (string.IsNullOrWhiteSpace(target?.Name))
continue;
byName[target.Name.Trim()] = target;
}
return byName.Values.ToArray();
}
}
public sealed class MonitorTarget
{
// Process image name (for example "udc.exe") or Windows service key name
// (for example "MTConnectAgent"). Matched case-insensitively.
[JsonPropertyName("name")] public string Name { get; set; } = "";
// Human-facing application label, optional. Falls back to Name when empty.
[JsonPropertyName("app")] public string App { get; set; } = "";
// Optional server-provisioned launch path for a process monitor. Only set on
// process targets that a site leader configured a path for. The agent NEVER
// launches this blindly: LaunchPathPolicy validates it against an approved-
// root allowlist first, and a local appsettings.json launchpaths entry for
// the same image still wins. Null/empty means "no server path" - the agent
// falls back to its local launch map, and process_start fails closed when
// neither yields a validated path.
[JsonPropertyName("launchpath")] public string? LaunchPath { get; set; }
}

View File

@@ -0,0 +1,110 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
namespace ShopDbEndpointAgent.Core;
// Builds the effective AgentConfig by layering two sources:
// 1. appsettings.json - non-secret runtime defaults, shipped with the build.
// 2. HKLM\SOFTWARE\GE\ShopDB - the collector key and base URL, provisioned
// out of band (Intune/DSC/GE-Enforce). Registry values WIN so a
// distributed appsettings.json never carries the secret and the site can
// rotate the key without a rebuild.
//
// Reading the registry never throws out to the caller: a missing hive or value
// simply leaves the appsettings value in place, which is what makes dev boxes
// (appsettings only) and fielded machines (registry provisioned) both work.
public static class AgentConfigProvider
{
public const string RegistryPath = @"SOFTWARE\GE\ShopDB";
// Registry value names. These mirror the names the computers reporter uses
// (see plugins/endpoints/DESIGN-DECISIONS-agent.md section 1).
public const string BaseUrlValue = "BaseUrl";
public const string CollectorKeyValue = "CollectorKey";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public static AgentConfig Load(string? appSettingsPath = null,
Microsoft.Extensions.Logging.ILogger? logger = null)
{
var config = LoadAppSettings(appSettingsPath) ?? new AgentConfig();
ApplyRegistryOverrides(config, logger);
return config;
}
private static AgentConfig? LoadAppSettings(string? path)
{
path ??= Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(path))
return null;
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<AgentConfig>(json, JsonOptions);
}
catch
{
// Malformed appsettings: fall back to defaults + registry so the
// agent can still run if the secret is provisioned in the registry.
return null;
}
}
private static void ApplyRegistryOverrides(AgentConfig config,
Microsoft.Extensions.Logging.ILogger? logger = null)
{
// Try 64-bit view first, then the default/32-bit view. On a locked-down
// box (CyberArk/EPM gating regedit and hive reads) the read can throw
// SecurityException/UnauthorizedAccessException even when the values
// exist. We log the exact outcome per view instead of swallowing it, so
// "Config incomplete" can be told apart from "access denied".
RegistryView[] views = { RegistryView.Registry64, RegistryView.Registry32 };
foreach (var view in views)
{
try
{
using var machine = RegistryKey.OpenBaseKey(
RegistryHive.LocalMachine, view);
using var key = machine.OpenSubKey(RegistryPath);
if (key is null)
{
logger?.LogWarning(
"Registry key HKLM\\{Path} not found in {View} view.",
RegistryPath, view);
continue;
}
var baseUrl = key.GetValue(BaseUrlValue) as string;
if (!string.IsNullOrWhiteSpace(baseUrl))
config.BaseUrl = baseUrl.Trim();
var collectorKey = key.GetValue(CollectorKeyValue) as string;
if (!string.IsNullOrWhiteSpace(collectorKey))
config.ApiKey = collectorKey.Trim();
logger?.LogInformation(
"Registry {View}: BaseUrl {BaseUrlState}, CollectorKey {KeyState}.",
view,
string.IsNullOrWhiteSpace(baseUrl) ? "MISSING" : "present",
string.IsNullOrWhiteSpace(collectorKey) ? "MISSING" : "present");
// Success reading this view; stop unless nothing was found.
if (!string.IsNullOrWhiteSpace(baseUrl) ||
!string.IsNullOrWhiteSpace(collectorKey))
return;
}
catch (Exception error)
{
logger?.LogError(error,
"Registry read of HKLM\\{Path} in {View} view was blocked " +
"({ErrorType}). If CyberArk/EPM is gating the hive, grant the " +
"agent read access or use appsettings.json for this test.",
RegistryPath, view, error.GetType().Name);
}
}
}
}

View File

@@ -0,0 +1,99 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Agent self-monitoring (WER-style). Routes otherwise-unobserved failures -
// unhandled exceptions on any thread and faulted tasks nobody awaited - to the
// ILogger (so they land in the rolling file) AND directly to a dedicated
// Windows Event Log source so agent runtime failures and agent/system interop
// errors are visible in Event Viewer alongside the OS's own records.
//
// The Event Log source is created on first use; creating a source needs admin,
// so if it is absent and cannot be created we degrade to the file log only and
// never throw from a fault handler (that would mask the original failure).
public sealed class AgentFaultMonitor
{
public const string EventLogSource = "ShopDB Endpoint Agent";
public const string EventLogName = "Application";
private readonly ILogger logger;
private readonly bool eventLogReady;
public AgentFaultMonitor(ILogger logger)
{
this.logger = logger;
eventLogReady = EnsureEventLogSource();
}
// Wire the process-wide fault hooks. Call once at startup.
public void Install()
{
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}
public void ReportFatal(string context, Exception error)
{
// File log first, always: the Event Log sink below is admin-gated (the
// source cannot be created without elevation), so an unelevated run such
// as the tray-launched --once/--report path would otherwise fail with no
// trace anywhere. The rolling file log is the one sink guaranteed to work
// in every context.
logger.LogError(error, "Fatal in {Context}", context);
Report(EventLogEntryType.Error, 1000, $"{context}: {error}");
}
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args)
{
var error = args.ExceptionObject as Exception;
var message = error?.ToString() ?? "unknown unhandled exception";
logger.LogCritical(error, "Unhandled exception (terminating={Terminating})",
args.IsTerminating);
Report(EventLogEntryType.Error, 1001,
$"Unhandled exception (terminating={args.IsTerminating}): {message}");
}
private void OnUnobservedTaskException(object? sender,
UnobservedTaskExceptionEventArgs args)
{
logger.LogError(args.Exception, "Unobserved task exception");
Report(EventLogEntryType.Warning, 1002,
$"Unobserved task exception: {args.Exception}");
// Mark observed so it does not escalate to a process-level failure.
args.SetObserved();
}
private void Report(EventLogEntryType type, int eventId, string message)
{
if (!eventLogReady)
return;
try
{
EventLog.WriteEntry(EventLogSource, message, type, eventId);
}
catch
{
// Event Log write failed (source removed, log full): the file log
// already has this entry; a fault handler must never throw.
}
}
private static bool EnsureEventLogSource()
{
try
{
if (EventLog.SourceExists(EventLogSource))
return true;
EventLog.CreateEventSource(new EventSourceCreationData(
EventLogSource, EventLogName));
return true;
}
catch
{
// Needs admin the first time; the installer creates the source, so
// in the field this succeeds. In dev without admin we log to file.
return false;
}
}
}

View File

@@ -0,0 +1,26 @@
namespace ShopDbEndpointAgent.Core;
// A one-shot wake signal shared between the command worker and the telemetry
// worker. After a command runs (e.g. MTConnect start/stop), the command worker
// calls Request() so the telemetry loop collects immediately instead of waiting
// out the remaining cadence, and the WebUI card reflects the new state within a
// single report cycle rather than up to 15 minutes later.
public sealed class CollectSignal
{
private readonly SemaphoreSlim gate = new(0, 1);
// Wake the telemetry loop for one off-cadence collection. Extra calls while
// one is already pending collapse into a single wake (the gate caps at 1).
public void Request()
{
try { gate.Release(); }
catch (SemaphoreFullException) { /* a wake is already pending */ }
}
// Wait until either the cadence delay elapses or a collect is requested.
// Returns true when woken by a request, false on a normal cadence timeout.
public async Task<bool> WaitAsync(TimeSpan delay, CancellationToken token)
{
return await gate.WaitAsync(delay, token);
}
}

View File

@@ -0,0 +1,201 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Posts one EndpointReport to POST {baseurl}/api/collector/endpoints with the
// X-API-Key header (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping
// the ShopDB envelope { status, data, meta } to return the CollectorResponse.
//
// Resilience: transient failures (network errors, timeouts, 5xx, 429) are
// retried with exponential backoff plus jitter, bounded by maxAttempts. A 4xx
// other than 429 is a permanent error (bad key, unknown host in strict mode)
// and is surfaced immediately without retry - hammering the server changes
// nothing.
public sealed class CollectorClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization
.JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient http;
private readonly ILogger<CollectorClient> logger;
private readonly int maxAttempts;
private readonly Random jitter = new();
public CollectorClient(AgentConfig config, ILogger<CollectorClient> logger,
int maxAttempts = 4)
{
this.logger = logger;
this.maxAttempts = Math.Max(1, maxAttempts);
http = new HttpClient
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(30),
};
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
}
public async Task<CollectorResponse> SendAsync(
EndpointReport report, CancellationToken token)
{
Exception? last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
token.ThrowIfCancellationRequested();
try
{
return await SendOnceAsync(report, token);
}
catch (CollectorException error) when (error.IsPermanent)
{
// Bad credential or rejected payload: no retry.
throw;
}
catch (Exception error) when (
error is CollectorException or HttpRequestException or TaskCanceledException
&& !token.IsCancellationRequested)
{
last = error;
if (attempt == maxAttempts)
break;
var delay = BackoffDelay(attempt);
logger.LogWarning(
"Collector send attempt {Attempt}/{Max} failed: {Message}; retrying in {Delay}s",
attempt, maxAttempts, error.Message, delay.TotalSeconds);
await Task.Delay(delay, token);
}
}
throw last ?? new CollectorException(0, "collector send failed");
}
private async Task<CollectorResponse> SendOnceAsync(
EndpointReport report, CancellationToken token)
{
using var response = await http.PostAsJsonAsync(
"api/collector/endpoints", report, JsonOptions, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<CollectorEnvelope>(body, JsonOptions);
if (envelope?.Data is null)
throw new CollectorException((int)response.StatusCode, "empty response body");
return envelope.Data;
}
// Fetch the server-resolved watch list (policy-attached processes/services)
// for this hostname, GET /api/endpoints/monitors/resolved. Shares the same
// X-API-Key credential as telemetry. Best-effort: returns null on any
// failure so the caller falls back to the last-known/local list rather than
// blanking monitoring on a transient blip. A single attempt, no retry - the
// next telemetry cycle retries naturally, and a stale-but-usable watch list
// beats an empty one.
public async Task<ResolvedMonitors?> FetchResolvedMonitorsAsync(
string hostname, CancellationToken token)
{
var url = "api/endpoints/monitors/resolved?hostname="
+ Uri.EscapeDataString(hostname);
try
{
using var response = await http.GetAsync(url, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning(
"Resolved-monitors fetch returned HTTP {Status}: {Body}",
(int)response.StatusCode, body);
return null;
}
var envelope = JsonSerializer.Deserialize<ResolvedMonitorsEnvelope>(
body, JsonOptions);
return envelope?.Data;
}
catch (Exception error) when (
error is HttpRequestException or TaskCanceledException or JsonException
&& !token.IsCancellationRequested)
{
logger.LogWarning(error, "Resolved-monitors fetch failed; keeping current watch list");
return null;
}
}
private TimeSpan BackoffDelay(int attempt)
{
// 2^(attempt-1) seconds, capped, plus up to 1s jitter.
double seconds = Math.Min(30, Math.Pow(2, attempt - 1));
return TimeSpan.FromSeconds(seconds + jitter.NextDouble());
}
// Uploads one operator issue report (free-text reason/details plus an
// optional PNG screenshot) to POST /api/endpoints/operatorreports as
// multipart/form-data, authorized by the same X-API-Key collector key as
// telemetry. The server resolves this hostname to its asset. A single
// attempt: the tray shows the operator the outcome and can retry by hand,
// and a stuck retry loop holding a screenshot in memory is worse than a
// clear failure. Returns the parsed upload response (new row id) or throws.
public async Task<OperatorReportUploadResponse?> UploadOperatorReportAsync(
string reason, string details, string? screenshotPath, string hostname,
CancellationToken token)
{
using var form = new MultipartFormDataContent();
form.Add(new StringContent(reason ?? string.Empty), "reason");
form.Add(new StringContent(details ?? string.Empty), "details");
form.Add(new StringContent(hostname ?? string.Empty), "hostname");
StreamContent? imageContent = null;
FileStream? imageStream = null;
try
{
if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath))
{
imageStream = File.OpenRead(screenshotPath);
imageContent = new StreamContent(imageStream);
imageContent.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
form.Add(imageContent, "screenshot",
Path.GetFileName(screenshotPath));
}
using var response = await http.PostAsync(
"api/endpoints/operatorreports", form, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<OperatorReportUploadEnvelope>(
body, JsonOptions);
return envelope?.Data;
}
finally
{
imageContent?.Dispose();
imageStream?.Dispose();
}
}
public void Dispose() => http.Dispose();
}
public sealed class CollectorException : Exception
{
public int StatusCode { get; }
// A permanent failure is a 4xx that is not 429 (rate limit): retrying with
// the same key and payload cannot succeed, so the worker should back off to
// its normal cadence rather than fast-retry.
public bool IsPermanent =>
StatusCode >= 400 && StatusCode < 500
&& StatusCode != (int)HttpStatusCode.TooManyRequests;
public CollectorException(int statusCode, string body)
: base($"collector returned HTTP {statusCode}: {body}")
{
StatusCode = statusCode;
}
}

View File

@@ -0,0 +1,104 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Talks to the endpoint command channel (plugins/endpoints/AGENT-CONTRACT.md
// section 7). The agent opens no inbound port: it long-polls
// GET /api/endpoints/commands/next, which the server holds open until a command
// is queued (HTTP 200) or the hold window elapses (HTTP 204). After running the
// claimed command locally the agent posts the outcome to
// POST /api/endpoints/commands/{id}/result. Both share the endpoints collector
// key via X-API-Key, so the command channel rides the same kill switch as
// telemetry and adds no second secret.
public sealed class CommandClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization
.JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient http;
private readonly SocketsHttpHandler handler;
private readonly ILogger<CommandClient> logger;
public CommandClient(AgentConfig config, ILogger<CommandClient> logger)
{
this.logger = logger;
// The command channel long-polls: the server holds each claim open for a
// hold window and then, between windows, the pooled TLS connection sits
// idle. Gunicorn's keep-alive closes an idle socket after a couple of
// seconds, so a connection reused after that yields "unexpected EOF or 0
// bytes from the transport stream" on the next TLS handshake. Retire
// pooled connections faster than the server's keep-alive so a socket the
// server has already dropped is never handed back out; the lifetime cap
// is a hard backstop so no connection lingers indefinitely.
handler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1),
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
};
http = new HttpClient(handler, disposeHandler: true)
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
// Longer than the collector client: the claim request is held open
// server-side (~25s), so the read timeout must clear that plus slack.
Timeout = TimeSpan.FromSeconds(60),
};
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
}
// Claim the next pending command for this hostname, or null when the server
// returned 204 (nothing queued within the hold window). A 4xx other than 429
// is surfaced so the worker can back off instead of hot-looping a bad key or
// an unknown host.
public async Task<CommandClaim?> ClaimNextAsync(
string hostname, CancellationToken token)
{
var url = "api/endpoints/commands/next?hostname="
+ Uri.EscapeDataString(hostname);
using var response = await http.GetAsync(url, token);
if (response.StatusCode == HttpStatusCode.NoContent)
return null;
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<CommandClaimEnvelope>(
body, JsonOptions);
return envelope?.Data;
}
// Report a claimed command's outcome. Best-effort: a failure to post the
// result is logged and swallowed so a transient network blip does not crash
// the worker; the command simply stays 'running' server-side until a later
// operator retry supersedes it.
// hostname rides along so the server can check this agent owns the command
// it is finalizing. The shared collector key does not say who is calling,
// so without it any agent can post a result for any command id. The server
// treats the parameter as optional for older agents and enforces it when
// present, so shipping this ahead of a fleet update is safe.
public async Task ReportResultAsync(
int commandId, string hostname, CommandResult result,
CancellationToken token)
{
var url = $"api/endpoints/commands/{commandId}/result?hostname="
+ Uri.EscapeDataString(hostname);
using var response = await http.PostAsJsonAsync(
url, result, JsonOptions, token);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(token);
logger.LogWarning(
"Reporting result for command {CommandId} returned HTTP {Status}: {Body}",
commandId, (int)response.StatusCode, body);
}
}
public void Dispose() => http.Dispose();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// The command loop as a hosted BackgroundService, separate from TelemetryWorker
// so a slow or held-open command poll never delays a telemetry report and vice
// versa. It long-polls the server for an allowlisted command
// (plugins/endpoints/AGENT-CONTRACT.md section 7), runs it through
// CommandExecutor, and posts the result back. The server holds each poll open
// briefly, so this loop is effectively "wait for work, do it, immediately wait
// again" rather than a fixed-cadence poll.
//
// Like TelemetryWorker, one bad iteration never kills the loop: a permanent
// error (bad key, unknown host) backs off to a slow retry so it does not hammer
// the server, and any other failure is logged and retried after a short pause.
public sealed class CommandWorker : BackgroundService
{
// Pause after a transient failure before re-polling, so a flapping network
// or a briefly-down server does not spin this loop.
private static readonly TimeSpan TransientRetryDelay = TimeSpan.FromSeconds(10);
// Longer back-off after a permanent error (bad key, host removed): the
// operator has to fix something, so re-polling fast changes nothing.
private static readonly TimeSpan PermanentRetryDelay = TimeSpan.FromSeconds(300);
// A genuine idle long-poll close only happens after the server has held the
// request open for most of its hold window (~25s). A connection that drops
// in well under this did not idle out - it failed at connect/TLS (e.g. an
// http:// BaseUrl hitting the TLS port), which must NOT be treated as a
// normal close and fast re-polled. Below this threshold we take the
// transient-failure backoff instead.
private static readonly TimeSpan MinIdlePollDuration = TimeSpan.FromSeconds(5);
// Hard floor between poll starts. Even if some unforeseen path returns
// immediately every iteration, this caps the loop at a safe request rate so
// a misconfigured agent can never turn into a request storm.
private static readonly TimeSpan MinPollInterval = TimeSpan.FromSeconds(2);
private readonly CommandClient client;
private readonly CommandExecutor executor;
private readonly ILogger<CommandWorker> logger;
private readonly CollectSignal? collectSignal;
private readonly string hostname;
public CommandWorker(CommandClient client, CommandExecutor executor,
ILogger<CommandWorker> logger, CollectSignal? collectSignal = null)
{
this.client = client;
this.executor = executor;
this.logger = logger;
this.collectSignal = collectSignal;
hostname = Environment.MachineName;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Command channel started for host {Host}", hostname);
while (!stoppingToken.IsCancellationRequested)
{
var pollStarted = System.Diagnostics.Stopwatch.StartNew();
try
{
var claim = await client.ClaimNextAsync(hostname, stoppingToken);
if (claim is not null)
await RunClaimAsync(claim, stoppingToken);
// On a 204 (no work) the server already consumed the hold
// window, so re-poll immediately.
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (CollectorException error) when (error.IsPermanent)
{
logger.LogError(error,
"Permanent command-channel error; backing off");
await SafeDelay(PermanentRetryDelay, stoppingToken);
}
catch (Exception error) when (IsIdlePollClose(error)
&& pollStarted.Elapsed >= MinIdlePollDuration)
{
// The server (or a proxy) held the poll open for most of the
// hold window and then closed it without a command. That is the
// normal end of a hold window, not a failure: log quietly at
// debug and re-poll immediately.
logger.LogDebug(error, "Command poll connection closed; re-polling");
}
catch (Exception error)
{
// Anything else - including a connection/TLS failure that drops
// in under MinIdlePollDuration (e.g. an http:// BaseUrl hitting
// the TLS port) - is a real transient failure. Back off so a
// misconfiguration cannot spin into a request storm.
logger.LogWarning(error, "Command poll failed; retrying shortly");
await SafeDelay(TransientRetryDelay, stoppingToken);
}
// Floor the loop rate. On the immediate re-poll paths (204 or a
// genuine idle close) the elapsed time already exceeds this, so it
// adds no latency; it only bites if an iteration returned unusually
// fast, capping the worst case at a safe request rate.
var remaining = MinPollInterval - pollStarted.Elapsed;
if (remaining > TimeSpan.Zero)
await SafeDelay(remaining, stoppingToken);
}
logger.LogInformation("Command channel stopped");
}
private async Task RunClaimAsync(CommandClaim claim, CancellationToken token)
{
logger.LogInformation("Running command {Id} action={Action}",
claim.EndpointCommandId, claim.Action);
CommandResult result;
try
{
result = await executor.ExecuteAsync(claim, token);
}
catch (Exception error)
{
logger.LogError(error, "Command {Id} action={Action} threw",
claim.EndpointCommandId, claim.Action);
result = new CommandResult
{
Succeeded = false,
Message = $"Command failed: {error.Message}",
};
}
await client.ReportResultAsync(
claim.EndpointCommandId, hostname, result, token);
logger.LogInformation("Command {Id} finished succeeded={Succeeded}",
claim.EndpointCommandId, result.Succeeded);
// A command changed something on the endpoint (e.g. MTConnect start/stop);
// wake the telemetry loop to collect now so the WebUI card reflects the
// new state within one report cycle instead of after the full cadence.
collectSignal?.Request();
}
// A long poll that the server or an intermediary closes without sending a
// command surfaces as an HttpRequestException wrapping an HttpIOException
// (ResponseEnded), or a bare IOException. Whether that is a benign hold-
// window close or a fast connect/TLS failure is decided by the caller using
// how long the poll stayed open (see MinIdlePollDuration); this only matches
// the exception shape.
private static bool IsIdlePollClose(Exception error)
{
for (Exception? current = error; current is not null; current = current.InnerException)
{
if (current is System.Net.Http.HttpIOException httpIo
&& httpIo.HttpRequestError == System.Net.Http.HttpRequestError.ResponseEnded)
return true;
if (current is System.IO.IOException)
return true;
}
return false;
}
private static async Task SafeDelay(TimeSpan delay, CancellationToken token)
{
try
{
await Task.Delay(delay, token);
}
catch (OperationCanceledException)
{
// Shutting down: let the loop condition end it.
}
}
}

View File

@@ -0,0 +1,51 @@
using System.Runtime.InteropServices;
namespace ShopDbEndpointAgent.Core;
// Hides the console window this process was given.
//
// shopdbagent.exe is a console executable, so anything that starts it in an
// interactive session gets a visible black window. That was invisible while the
// tray owned the probe - it launched with CreateNoWindow - but the user-context
// scheduled task runs the exe directly in the operator's session, where the
// window is very much visible and pops up on every collection cycle.
//
// The window is hidden rather than the exe rebuilt as a WinExe: the agent is
// also a service host and a command-line tool, and both of those want a real
// console for --once, --report and service debugging.
public static class ConsoleWindow
{
private const int SW_HIDE = 0;
[DllImport("kernel32.dll")]
private static extern nint GetConsoleWindow();
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(nint window, int command);
// Best-effort: a process with no console (already detached, or redirected by
// a caller) simply has nothing to hide, and failing to hide a window must
// never stop the collection that window belongs to.
public static void Hide()
{
if (!OperatingSystem.IsWindows())
{
return;
}
try
{
var window = GetConsoleWindow();
if (window != nint.Zero)
{
ShowWindow(window, SW_HIDE);
}
}
catch
{
// Nothing useful to do or say: there is no log sink yet at the point
// this runs, and a visible window is cosmetic next to losing data.
}
}
}

View File

@@ -0,0 +1,231 @@
using System.Text.Json.Serialization;
namespace ShopDbEndpointAgent.Core;
// Wire keys MUST stay lowercase/no-underscore to match the ShopDB collector
// schema (plugins/endpoints/AGENT-CONTRACT.md). PascalCase C# properties are
// mapped back with JsonPropertyName so the serializer does not rename them.
public sealed class EndpointReport
{
[JsonPropertyName("hostname")] public string Hostname { get; set; } = "";
[JsonPropertyName("adopt")] public bool? Adopt { get; set; }
[JsonPropertyName("loggedinuser")] public string? LoggedInUser { get; set; }
[JsonPropertyName("serialnumber")] public string? SerialNumber { get; set; }
[JsonPropertyName("pctype")] public string? PcType { get; set; }
[JsonPropertyName("vendorname")] public string? VendorName { get; set; }
[JsonPropertyName("modelnumber")] public string? ModelNumber { get; set; }
[JsonPropertyName("osname")] public string? OsName { get; set; }
[JsonPropertyName("ipaddress")] public string? IpAddress { get; set; }
[JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; }
[JsonPropertyName("agentversion")] public string? AgentVersion { get; set; }
[JsonPropertyName("trayversion")] public string? TrayVersion { get; set; }
[JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; }
[JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; }
[JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; }
[JsonPropertyName("cpupercent")] public double? CpuPercent { get; set; }
[JsonPropertyName("memorypercent")] public double? MemoryPercent { get; set; }
[JsonPropertyName("services")] public object[]? Services { get; set; }
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
[JsonPropertyName("scheduledtasks")] public object[]? ScheduledTasks { get; set; }
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
// Additive inventory telemetry. The server does not persist these yet; its
// ingest logs them as unknown keys (plugins/endpoints/plugin.py) so the
// incoming values are observable before any DB column or UI card exists.
// Keys stay lowercase/no-underscore to match AGENT-CONTRACT.md.
[JsonPropertyName("disks")] public object[]? Disks { get; set; }
[JsonPropertyName("bios")] public object? Bios { get; set; }
[JsonPropertyName("memory")] public object? Memory { get; set; }
[JsonPropertyName("processor")] public object? Processor { get; set; }
[JsonPropertyName("osinfo")] public object? OsInfo { get; set; }
[JsonPropertyName("domain")] public string? Domain { get; set; }
// Installed-software SBOM, mirroring the Assess-Orchestrator applications
// collection: both Uninstall registry hives, {displayname, displayversion,
// publisher}. Server logs it as an unpersisted key until the SBOM store lands.
[JsonPropertyName("applications")] public object[]? Applications { get; set; }
// True when Windows is holding a reboot. A machine that has been waiting to
// reboot for weeks explains stalled patching and "the fix did not take"
// reports, and on a shop floor PC nobody reboots voluntarily.
[JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; }
// Which signal(s) drove pendingreboot, so the server can tell a box staged
// mid-servicing (pendingfilerename) from one with a stale servicing key.
// Empty array = checked, nothing pending. Omitted = the check failed.
[JsonPropertyName("rebootreasons")] public string[]? RebootReasons { get; set; }
// hiberbootenabled (fast startup configured) and lastboottype (what the
// last boot actually did). Uptime is not trustworthy when these disagree.
[JsonPropertyName("bootinfo")] public object? BootInfo { get; set; }
// Defender health. Persisted but deliberately not surfaced: known false
// positives in this environment.
[JsonPropertyName("defender")] public object? Defender { get; set; }
// Physical drive health from two sources: MSFT_PhysicalDisk (the only one
// that reports NVMe) and Win32_DiskDrive.Status (ATA/SATA and fallback).
// Each entry carries a precomputed predictfailure so an alert is a boolean
// test rather than a string match against an enum. Null predictfailure
// means the drive did not answer - unknown, not healthy.
[JsonPropertyName("diskhealth")] public object[]? DiskHealth { get; set; }
// Session-scoped data gathered by a probe running as the signed-in user:
// mapped drives and user-owned listeners. Null when nobody is signed in or
// the probe did not produce a result this cycle - absent means "not
// observed", which is deliberately distinct from an empty list.
[JsonPropertyName("usercontext")] public object? UserContext { get; set; }
// Pending Dell Command | Update results, produced by the shopdb-dellupdates
// agent utility rather than collected in C#: the data comes from dcu-cli's
// own scan output, so the utility that parses it belongs next to the task
// that writes it. Null means no scan has ever run (or the utility is not
// installed), which is deliberately distinct from a scan that found nothing
// pending - the first is "unknown", the second is "known clean".
[JsonPropertyName("dellupdates")] public object? DellUpdates { get; set; }
}
public sealed class EndpointEventRecord
{
[JsonPropertyName("eventid")] public int EventId { get; set; }
// The Windows event record id: monotonic and unique within its channel on a
// given machine. It is both the agent's harvest watermark and the server's
// dedupe key, so the same record can never be stored twice. Null only when
// the source record did not expose one.
[JsonPropertyName("eventrecordid")] public long? EventRecordId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
[JsonPropertyName("fields")] public Dictionary<string, object>? Fields { get; set; }
}
public sealed class CollectorResponse
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("action")] public string? Action { get; set; }
[JsonPropertyName("assetid")] public int? AssetId { get; set; }
[JsonPropertyName("identityvalue")] public string? IdentityValue { get; set; }
[JsonPropertyName("warnings")] public string[]? Warnings { get; set; }
}
// The collector wraps the plugin payload in the ShopDB envelope
// { status, data: { action, assetid, identityvalue, warnings }, meta }.
public sealed class CollectorEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public CollectorResponse? Data { get; set; }
}
// Server response to an operator issue report upload. Carries the new row id so
// the agent can log it for correlation.
public sealed class OperatorReportUploadResponse
{
[JsonPropertyName("operatorreportid")] public int? OperatorReportId { get; set; }
[JsonPropertyName("assetid")] public int? AssetId { get; set; }
}
public sealed class OperatorReportUploadEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public OperatorReportUploadResponse? Data { get; set; }
}
// A command the server claimed for this endpoint on the long-poll. The
// allowlisted action name crosses the wire (see AGENT-CONTRACT.md section 7);
// generic actions (service_* / process_*) also carry the subject to act on -
// Target is a Windows service name or a process image (for example
// 'DncMain.exe') and TargetKind is 'service' or 'process'. Fixed-target actions
// (mtconnect_*) leave both null. Carrying the target on the wire is what lets a
// site build the agent once and drive every future policy/app from server data.
public sealed class CommandClaim
{
[JsonPropertyName("endpointcommandid")] public int EndpointCommandId { get; set; }
[JsonPropertyName("action")] public string Action { get; set; } = "";
[JsonPropertyName("target")] public string? Target { get; set; }
[JsonPropertyName("targetkind")] public string? TargetKind { get; set; }
// For update_app the server resolves the pinned application version into a
// delivery descriptor (package URL, SHA-256, silent-install switches). The
// agent fetches the bytes itself with its collector credential; the wire
// never carries the installer body. Null for every other action.
[JsonPropertyName("app")] public AppDeliveryClaim? App { get; set; }
// For run_script the server resolves the ShopDB-hosted script into a
// delivery descriptor (body URL, SHA-256, run context). The agent fetches
// the body itself with its collector credential; the wire never carries the
// script body. Null for every other action.
[JsonPropertyName("script")] public ScriptDeliveryClaim? Script { get; set; }
}
// The resolved installer-push descriptor accompanying an update_app claim.
// InstallPath is a ShopDB-relative package URL the agent joins to its BaseUrl
// and downloads with its collector credential; Sha256 pins the exact bytes;
// SilentArgs are this version's unattended-install switches (may be empty for
// an MSI, which the agent installs via msiexec /qn). AppVersionId is null when
// the push resolves an app-level install source rather than a pinned version.
public sealed class AppDeliveryClaim
{
[JsonPropertyName("appversionid")] public int? AppVersionId { get; set; }
[JsonPropertyName("appid")] public int AppId { get; set; }
[JsonPropertyName("appname")] public string? AppName { get; set; }
[JsonPropertyName("version")] public string? Version { get; set; }
[JsonPropertyName("installpath")] public string? InstallPath { get; set; }
[JsonPropertyName("sha256")] public string? Sha256 { get; set; }
[JsonPropertyName("silentargs")] public string? SilentArgs { get; set; }
}
// The resolved script-push descriptor accompanying a run_script claim.
// InstallPath is a ShopDB-relative body URL the agent joins to its BaseUrl and
// downloads with its collector credential; Sha256 pins the exact bytes;
// RunContext is 'system' (the agent's own service account) or 'user' (the
// interactive logged-on session), decided per script in the ShopDB library.
public sealed class ScriptDeliveryClaim
{
[JsonPropertyName("scriptid")] public int ScriptId { get; set; }
[JsonPropertyName("scriptname")] public string? ScriptName { get; set; }
[JsonPropertyName("installpath")] public string? InstallPath { get; set; }
[JsonPropertyName("sha256")] public string? Sha256 { get; set; }
[JsonPropertyName("runcontext")] public string? RunContext { get; set; }
}
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.
public sealed class CommandClaimEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public CommandClaim? Data { get; set; }
}
// The outcome the agent posts back after running a claimed command. When the
// action probed MTConnect, MtConnectRunning carries the observed state so the
// server can cache it for the WebUI toggle.
public sealed class CommandResult
{
[JsonPropertyName("succeeded")] public bool Succeeded { get; set; }
[JsonPropertyName("message")] public string? Message { get; set; }
[JsonPropertyName("mtconnectrunning")] public bool? MtConnectRunning { get; set; }
[JsonPropertyName("detail")] public Dictionary<string, object>? Detail { get; set; }
}
// The server-resolved watch list for this endpoint (processes and services the
// site's policy attaches to the box's PC type), fetched each telemetry cycle so
// a policy-attached monitor is enumerated without editing appsettings.json on
// the box. Same {name, app} shape as the local MonitorConfig, so the merge is a
// direct list union. Empty lists mean "watch nothing extra beyond local".
public sealed class ResolvedMonitors
{
[JsonPropertyName("processes")] public MonitorTarget[] Processes { get; set; }
= Array.Empty<MonitorTarget>();
[JsonPropertyName("services")] public MonitorTarget[] Services { get; set; }
= Array.Empty<MonitorTarget>();
}
// Envelope for the resolved-monitors route: { status, data: { processes, services } }.
public sealed class ResolvedMonitorsEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public ResolvedMonitors? Data { get; set; }
}

View File

@@ -0,0 +1,52 @@
namespace ShopDbEndpointAgent.Core;
// The curated Windows Event catalog, LOCKED to the server list in
// plugins/endpoints/plugin.py (CURATED_EVENT_IDS) and documented in
// plugins/endpoints/DESIGN-DECISIONS-agent.md section 4. The server stores ONLY
// these IDs; anything else in a report's `events` is ignored (not an error).
// Sending only these keeps the payload small and mirrors the catalog intent.
//
// Channels matter: an ID is read from its own log, so the collector reads
// Security, System, and Application - not System alone. IDs are namespaced by
// channel here because the same numeric ID can mean different things across
// logs; we only harvest the (channel, id) pairs the server curates.
public static class CuratedEventCatalog
{
public sealed record Entry(int EventId, string Channel, string Meaning);
public static readonly IReadOnlyList<Entry> Entries = new List<Entry>
{
new(4624, "Security", "Successful logon"),
new(4625, "Security", "Failed logon"),
new(4634, "Security", "Logoff"),
new(4672, "Security", "Special privileges assigned to new logon"),
new(4697, "Security", "Service installed"),
new(4698, "Security", "Scheduled task created"),
new(4618, "Security", "Monitored security event pattern"),
new(41, "System", "Kernel-Power, unexpected shutdown"),
new(55, "System", "NTFS file system corruption"),
new(7011, "System", "Service Control Manager timeout"),
new(10016, "System", "DCOM permission error"),
// Windows Error Reporting on the Application channel: 1000 is the
// faulting-application record (app, version, faulting module, exception
// code), 1001 is the WER bucket record Windows pairs with it, and 1002
// is an unresponsive app rather than a crash. The structured detail for
// all three arrives in the record's EventData, which the collector lifts
// into `fields`.
new(1000, "Application", "Application error / faulting module"),
new(1001, "Application", "Application error / bugcheck report"),
new(1002, "Application", "Application hang"),
};
// The distinct channels the collector must read.
public static readonly IReadOnlyList<string> Channels =
Entries.Select(entry => entry.Channel).Distinct().ToList();
// The set of curated IDs for a given channel, for a fast membership check
// while walking that channel's log newest-first.
public static IReadOnlySet<int> IdsForChannel(string channel)
=> Entries.Where(entry =>
string.Equals(entry.Channel, channel, StringComparison.OrdinalIgnoreCase))
.Select(entry => entry.EventId)
.ToHashSet();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Remembers the newest Windows event record id already reported for each
// curated channel, so a collect cycle harvests ONLY records the server has not
// seen.
//
// Why this exists: the collector used to re-read the newest N records of every
// channel on every cycle, and the server ingest appends event rows without a
// dedupe check. A single crash therefore landed in the database once per
// cadence tick for as long as it stayed inside that window - up to 96 identical
// rows a day per event. Carrying a watermark makes the steady state "only what
// is new", which is also what makes the full event XML affordable to send.
//
// The watermark is persisted under %ProgramData% (not in memory) because a
// service restart would otherwise re-report the whole window again, which is
// exactly the crash-loop case where duplicate events are most likely.
//
// Record ids are per channel and monotonic on a given machine. A cleared or
// rebuilt event log restarts them at 1; that is handled by treating a stored
// watermark HIGHER than the newest available record as stale and resetting it,
// otherwise the channel would go permanently silent.
public sealed class EventWatermarkStore
{
private const string StoreFileName = "eventwatermark.json";
// %ProgramData%\ShopDB\EndpointAgent - the same root the rolling agent log
// lives under, so all agent-owned state sits in one place.
public static string DefaultStateDirectory => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDB", "EndpointAgent");
private readonly string storePath;
private readonly ILogger<EventWatermarkStore> logger;
private readonly Dictionary<string, long> watermarks;
public EventWatermarkStore(ILogger<EventWatermarkStore> logger,
string? stateDirectory = null)
{
this.logger = logger;
var directory = string.IsNullOrWhiteSpace(stateDirectory)
? DefaultStateDirectory
: stateDirectory;
storePath = Path.Combine(directory, StoreFileName);
watermarks = Load();
}
// The last record id reported for this channel, or 0 when the channel has
// never been harvested. 0 means "first contact": the caller sends a bounded
// tail for initial context rather than the entire backlog.
public long GetWatermark(string channel)
=> watermarks.TryGetValue(channel, out var value) ? value : 0;
public bool HasWatermark(string channel)
=> watermarks.ContainsKey(channel);
// Advance the channel forward only. Never moves backwards, so an
// out-of-order read cannot cause the same record to be reported twice.
public void Advance(string channel, long recordId)
{
if (recordId <= 0)
return;
if (watermarks.TryGetValue(channel, out var current) && current >= recordId)
return;
watermarks[channel] = recordId;
}
// A cleared or rebuilt log restarts record ids near 1. When the newest
// record available is below the stored watermark the store is stale, so
// drop it and let the channel re-seed instead of going silent forever.
public bool ResetIfStale(string channel, long newestAvailableRecordId)
{
if (newestAvailableRecordId <= 0)
return false;
if (!watermarks.TryGetValue(channel, out var current) || current <= newestAvailableRecordId)
return false;
logger.LogInformation(
"Event channel {Channel} record id went backwards ({Stored} -> {Newest}); log was cleared, reseeding",
channel, current, newestAvailableRecordId);
watermarks.Remove(channel);
return true;
}
// Best-effort persist. A failure here costs at most a repeated report of
// records already sent, so it must never abort a collection.
public void Save()
{
try
{
var directory = Path.GetDirectoryName(storePath);
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
// Write-then-rename so a crash mid-write cannot leave a truncated
// file that would reseed every channel on the next start.
var temporaryPath = storePath + ".tmp";
File.WriteAllText(temporaryPath,
JsonSerializer.Serialize(watermarks));
File.Move(temporaryPath, storePath, overwrite: true);
}
catch (Exception error)
{
logger.LogDebug(error, "Could not persist event watermarks to {Path}", storePath);
}
}
private Dictionary<string, long> Load()
{
try
{
if (!File.Exists(storePath))
return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
var loaded = JsonSerializer.Deserialize<Dictionary<string, long>>(
File.ReadAllText(storePath));
return loaded is null
? new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, long>(loaded, StringComparer.OrdinalIgnoreCase);
}
catch (Exception error)
{
// A corrupt store reseeds every channel, which costs one bounded
// tail per channel - acceptable versus failing the collection.
logger.LogWarning(error,
"Event watermark store unreadable at {Path}; reseeding channels", storePath);
return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
}
}
}

View File

@@ -0,0 +1,218 @@
using System.Collections.Concurrent;
using System.Text;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// A dependency-free rolling-file ILoggerProvider. The runtime code only ever
// talks to ILogger, so swapping or adding sinks later (a ShopDB HTTP sink, Seq,
// syslog) is a host-builder change with zero worker changes. This provider
// writes one line per entry to a daily file under the configured log directory
// and prunes files older than the retention window.
public sealed class FileLoggerProvider : ILoggerProvider
{
private readonly string directory;
private readonly LogLevel minLevel;
private readonly int retentionDays;
private readonly string filePrefix;
private readonly object gate = new();
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
private string currentDateStamp = "";
private StreamWriter? writer;
// Why the sink can be unusable, or null when it opened normally. The
// constructor cannot throw (see below), but a caller that has a second,
// known-writable place to complain to needs to be able to ask.
public string? SinkProblem { get; private set; }
public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information,
int retentionDays = 14, string filePrefix = "agent")
{
this.directory = directory;
this.minLevel = minLevel;
this.retentionDays = retentionDays;
this.filePrefix = filePrefix;
// Best-effort: creating the log directory can fail on a locked-down host
// or when a non-privileged user targets a SYSTEM-owned path. Never let
// that abort construction; Write() degrades to a no-op if the sink is
// unusable.
//
// The reason is KEPT rather than discarded. A silently dead sink is what
// made the user-context probe undiagnosable: running as the operator it
// could not write into the SYSTEM-owned ProgramData log directory, so a
// probe that failed and a probe that never ran produced identical
// silence. Callers that have a writable fallback can now surface this.
try { Directory.CreateDirectory(directory); }
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
{
SinkProblem = $"{directory}: {error.Message}";
}
}
// The default log directory when config leaves LogPath empty:
// %ProgramData%\ShopDB\EndpointAgent\logs
//
// SERVICE-ONLY. This path is SYSTEM-owned and a signed-in operator cannot
// write to it, so anything running in the user's session must use
// UserProfileLogDirectory() instead.
public static string DefaultDirectory()
=> Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDB", "EndpointAgent", "logs");
// The shop-floor operator account. Every shop-floor endpoint uses this same
// local account name, so the path is fixed rather than resolved through the
// interactive token: the token lookup is the part that fails on a hardened
// endpoint, and when it fails the writer and the reader silently disagree
// about where the spool lives. If a site ever uses a different account only
// user-context is lost - telemetry, commands and inventory are unaffected.
public const string ShopFloorUserName = "ShopFloor";
public static string ShopFloorProfileDirectory()
=> Path.Combine(DrivesRoot, "Users", ShopFloorUserName);
private static string DrivesRoot =>
Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.Windows))
?? @"C:\";
// Where the TRAY and the user-context probe log:
// C:\Users\ShopFloor\ShopDB\Logs
// The operator owns this directory, so it is writable from the user session
// where ProgramData is not.
public static string UserProfileLogDirectory()
=> Path.Combine(ShopFloorProfileDirectory(), "ShopDB", "Logs");
public ILogger CreateLogger(string categoryName)
=> loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
internal bool IsEnabled(LogLevel level) => level >= minLevel && level != LogLevel.None;
internal void Write(string category, LogLevel level, string message, Exception? error)
{
var now = DateTimeOffset.Now;
var line = new StringBuilder()
.Append(now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz"))
.Append(" [").Append(LevelTag(level)).Append("] ")
.Append(ShortCategory(category)).Append(": ")
.Append(message);
if (error is not null)
line.Append(Environment.NewLine).Append(error);
lock (gate)
{
// Logging must never take down the agent. If the log file cannot be
// opened or written (for example a non-privileged user running the
// exe against a logs directory owned by the SYSTEM service, which
// yields UnauthorizedAccessException on open), swallow it and drop
// the line rather than letting it unwind through bootstrap. A failed
// sink is a degraded state, not a fatal one.
try
{
RollIfNeeded(now);
writer?.WriteLine(line.ToString());
}
catch (Exception sinkError) when (sinkError is IOException or UnauthorizedAccessException)
{
writer = null;
}
}
}
private void RollIfNeeded(DateTimeOffset now)
{
var stamp = now.ToString("yyyyMMdd");
if (stamp == currentDateStamp && writer is not null)
return;
writer?.Flush();
writer?.Dispose();
currentDateStamp = stamp;
// One convention for every log this codebase writes:
// "<yyyyMMdd>-<prefix>.log", so agent, tray and installer logs sort
// chronologically when they share a directory and can be collected with
// a single date-leading glob. The service previously used the opposite
// order, which meant a support request for "the logs from the 5th" had to
// know which component wrote which shape.
var name = $"{stamp}-{filePrefix}.log";
var path = Path.Combine(directory, name);
writer = new StreamWriter(new FileStream(
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
{
AutoFlush = true,
};
PruneOldFiles();
}
private void PruneOldFiles()
{
try
{
var cutoff = DateTime.Now.AddDays(-retentionDays);
// Both shapes: retention has to keep covering the "<prefix>-<date>"
// files written before the convention was unified, or an upgraded
// endpoint would keep its pre-upgrade logs forever.
string[] patterns = [$"*-{filePrefix}.log", $"{filePrefix}-*.log"];
foreach (var pattern in patterns)
foreach (var file in Directory.EnumerateFiles(directory, pattern))
{
if (File.GetLastWriteTime(file) < cutoff)
File.Delete(file);
}
}
catch
{
// Pruning is best-effort; never let housekeeping break logging.
}
}
private static string LevelTag(LogLevel level) => level switch
{
LogLevel.Trace => "TRC",
LogLevel.Debug => "DBG",
LogLevel.Information => "INF",
LogLevel.Warning => "WRN",
LogLevel.Error => "ERR",
LogLevel.Critical => "CRT",
_ => "---",
};
private static string ShortCategory(string category)
{
var dot = category.LastIndexOf('.');
return dot >= 0 && dot < category.Length - 1 ? category[(dot + 1)..] : category;
}
public void Dispose()
{
lock (gate)
{
writer?.Flush();
writer?.Dispose();
writer = null;
}
}
private sealed class FileLogger : ILogger
{
private readonly FileLoggerProvider provider;
private readonly string category;
public FileLogger(FileLoggerProvider provider, string category)
{
this.provider = provider;
this.category = category;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => provider.IsEnabled(logLevel);
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
provider.Write(category, logLevel, formatter(state, exception), exception);
}
}
}

View File

@@ -0,0 +1,24 @@
namespace ShopDbEndpointAgent.Core;
// The collection surface. The runtime worker depends only on this, so the
// concrete implementation (pure C# today, possibly C# + PowerShell utilities
// later) can change without touching the worker or the HTTP client.
public interface ICollector
{
EndpointReport Collect(string agentVersion, bool adopt);
}
// The documented seam for site-provided PowerShell "agent utilities". The
// agent configuration settings page (server-side, future) lets site staff load
// their own .ps1 scripts as utilities; the runtime discovers them under the
// configured utilities directory and merges their JSON output into the report.
//
// This iteration ships the interface and a discovery contract only - the
// concrete runner is added when the settings page can push utilities down.
public interface IAgentUtilityRunner
{
// Run every discovered utility and return a map of utility name -> parsed
// JSON result, to be attached to the report. Never throws: a failing
// utility is logged and skipped so it cannot break the telemetry run.
IReadOnlyDictionary<string, object> RunAll(CancellationToken token);
}

View File

@@ -0,0 +1,333 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
namespace ShopDbEndpointAgent.Core;
// Launches a program in the ACTIVE CONSOLE USER'S session at that user's
// integrity, from the agent's LocalSystem service (session 0).
//
// Why this exists: the agent runs as a session-0 service. A plain
// Process.Start() there launches GUI apps (DncMain.exe, UDC.exe) onto the hidden
// session-0 desktop, so the logged-in operator never sees a window even though
// the process is running. process_start needs the app to appear on the user's
// desktop, so we obtain the interactive user's token and CreateProcessAsUser.
//
// Elevation is deliberately NOT handled here: the process starts at the user's
// normal (medium) integrity and CyberArk/EPM brokers any elevation afterward.
//
// Only process_start uses this. Stop/status/service actions stay as SYSTEM.
public static class InteractiveProcessLauncher
{
// TimedOut=true means the process launched fine but was still running when
// the caller's wait budget expired. It is deliberately NOT killed, so the
// work still completes; the caller just stops blocking on it.
public readonly record struct LaunchOutcome(
bool Launched, string? Error, int? ExitCode = null, bool TimedOut = false);
private const int TokenDuplicate = 0x0002;
private const int TokenQuery = 0x0008;
private const int TokenAssignPrimary = 0x0001;
private const int TokenAdjustPrivileges = 0x0020;
private const int TokenAdjustDefault = 0x0080;
private const int TokenAdjustSessionId = 0x0100;
private const uint CreateUnicodeEnvironment = 0x00000400;
private const uint CreateNoWindow = 0x08000000;
private const uint InvalidSessionId = 0xFFFFFFFF;
private const uint Infinite = 0xFFFFFFFF;
// WaitForSingleObject returns this when the budget expires with the handle
// still unsignalled.
private const uint WaitTimeout = 0x00000102;
// Returns Launched=true when CreateProcessAsUser succeeds. Never throws;
// failures come back as Launched=false with a descriptive Error so the
// caller can surface a clean CommandResult.
public static LaunchOutcome LaunchInActiveSession(
string applicationPath, string? workingDirectory)
=> LaunchInActiveSession(applicationPath, null, workingDirectory);
// Overload that also passes a command line (arguments). When commandLine is
// supplied it is handed to CreateProcessAsUser as lpCommandLine with a null
// lpApplicationName so the arguments are honored; the executable is resolved
// from the command line's first token as usual.
//
// When waitForExit is true the launcher waits for the process to exit and
// returns its real exit code in LaunchOutcome.ExitCode, so a caller that
// needs the actual outcome (run_script in the user session) can report
// completed/failed instead of fire-and-forget "launched". GUI callers
// (process_start) leave it false and return as soon as the window is up.
//
// waitTimeout bounds that wait. This matters because the caller may be the
// telemetry loop, and telemetry IS the heartbeat: a script that hangs in the
// user session used to block here forever (the wait was hardcoded to
// Infinite), which would stall reporting and make a perfectly healthy PC
// read as offline in the WebUI. On expiry the child is left running and the
// outcome comes back with TimedOut=true. Null means wait forever and should
// only be used by a caller that is not on a timed path.
public static LaunchOutcome LaunchInActiveSession(
string applicationPath, string? arguments, string? workingDirectory,
bool waitForExit = false, TimeSpan? waitTimeout = null)
{
var sessionId = WTSGetActiveConsoleSessionId();
if (sessionId == InvalidSessionId)
return new LaunchOutcome(false,
"No active console session (no interactive user is signed in).");
if (!WTSQueryUserToken(sessionId, out var userToken))
return new LaunchOutcome(false,
"Could not obtain the interactive user token (WTSQueryUserToken failed): "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
var duplicatedToken = IntPtr.Zero;
var environmentBlock = IntPtr.Zero;
try
{
var desiredAccess = TokenAssignPrimary | TokenDuplicate | TokenQuery
| TokenAdjustPrivileges | TokenAdjustDefault | TokenAdjustSessionId;
if (!DuplicateTokenEx(userToken, (uint)desiredAccess, IntPtr.Zero,
SecurityImpersonationLevel.SecurityImpersonation,
TokenType.TokenPrimary, out duplicatedToken))
{
return new LaunchOutcome(false,
"Could not duplicate the user token (DuplicateTokenEx failed): "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
}
// A per-user environment block so the launched app sees the user's
// profile variables rather than SYSTEM's.
if (!CreateEnvironmentBlock(out environmentBlock, duplicatedToken, false))
environmentBlock = IntPtr.Zero;
var startupInfo = new STARTUPINFO();
startupInfo.cb = Marshal.SizeOf(startupInfo);
// Target the interactive desktop so the window is visible.
startupInfo.lpDesktop = @"winsta0\default";
var creationFlags = CreateUnicodeEnvironment | CreateNoWindow;
// With arguments, pass a single command line ("<exe> <args>") as
// lpCommandLine and a null lpApplicationName so the arguments apply;
// without arguments, keep the original applicationName form.
string? applicationName;
string? commandLine;
if (string.IsNullOrWhiteSpace(arguments))
{
applicationName = applicationPath;
commandLine = null;
}
else
{
applicationName = null;
commandLine = "\"" + applicationPath + "\" " + arguments;
}
var launched = CreateProcessAsUser(
duplicatedToken,
applicationName,
commandLine,
IntPtr.Zero,
IntPtr.Zero,
false,
creationFlags,
environmentBlock,
workingDirectory,
ref startupInfo,
out var processInformation);
if (!launched)
{
return new LaunchOutcome(false,
"CreateProcessAsUser failed: "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
}
int? exitCode = null;
bool timedOut = false;
if (waitForExit && processInformation.hProcess != IntPtr.Zero)
{
uint milliseconds = waitTimeout is TimeSpan budget
? (uint)Math.Clamp(budget.TotalMilliseconds, 0, uint.MaxValue - 1)
: Infinite;
if (WaitForSingleObject(processInformation.hProcess, milliseconds)
== WaitTimeout)
{
// Still running. Leave it alone - killing it would abandon
// half-done work, and a file-spooling probe will still write
// its result for the next cycle to pick up.
timedOut = true;
}
else if (GetExitCodeProcess(processInformation.hProcess, out var code))
{
exitCode = unchecked((int)code);
}
}
if (processInformation.hProcess != IntPtr.Zero)
CloseHandle(processInformation.hProcess);
if (processInformation.hThread != IntPtr.Zero)
CloseHandle(processInformation.hThread);
return new LaunchOutcome(true, null, exitCode, timedOut);
}
finally
{
if (environmentBlock != IntPtr.Zero)
DestroyEnvironmentBlock(environmentBlock);
if (duplicatedToken != IntPtr.Zero)
CloseHandle(duplicatedToken);
if (userToken != IntPtr.Zero)
CloseHandle(userToken);
}
}
// Profile directory of the active console user, for example
// C:\Users\ShopFloor. The agent runs as LocalSystem, so expanding
// %USERPROFILE% here would return SYSTEM's own profile under
// config\systemprofile rather than the operator's. The interactive user's
// token is the only reliable source, and this class already knows how to
// obtain it. Returns null with a reason when nobody is signed in.
public static string? TryGetActiveUserProfileDirectory(out string? error)
{
var sessionId = WTSGetActiveConsoleSessionId();
if (sessionId == InvalidSessionId)
{
error = "No active console session (no interactive user is signed in).";
return null;
}
if (!WTSQueryUserToken(sessionId, out var userToken))
{
error = "Could not obtain the interactive user token: "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message;
return null;
}
try
{
uint size = 0;
GetUserProfileDirectory(userToken, null, ref size);
if (size == 0)
{
error = "Could not size the user profile directory: "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message;
return null;
}
var buffer = new StringBuilder((int)size);
if (!GetUserProfileDirectory(userToken, buffer, ref size))
{
error = "Could not resolve the user profile directory: "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message;
return null;
}
error = null;
return buffer.ToString();
}
finally
{
if (userToken != IntPtr.Zero)
CloseHandle(userToken);
}
}
private enum SecurityImpersonationLevel
{
SecurityAnonymous,
SecurityIdentification,
SecurityImpersonation,
SecurityDelegation,
}
private enum TokenType
{
TokenPrimary = 1,
TokenImpersonation,
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct STARTUPINFO
{
public int cb;
public string? lpReserved;
public string? lpDesktop;
public string? lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public ushort wShowWindow;
public ushort cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WTSGetActiveConsoleSessionId();
[DllImport("wtsapi32.dll", SetLastError = true)]
private static extern bool WTSQueryUserToken(uint sessionId, out IntPtr token);
[DllImport("advapi32.dll", SetLastError = true)]
private static extern bool DuplicateTokenEx(
IntPtr existingToken,
uint desiredAccess,
IntPtr tokenAttributes,
SecurityImpersonationLevel impersonationLevel,
TokenType tokenType,
out IntPtr newToken);
[DllImport("userenv.dll", SetLastError = true)]
private static extern bool CreateEnvironmentBlock(
out IntPtr environment, IntPtr token, bool inherit);
[DllImport("userenv.dll", SetLastError = true)]
private static extern bool DestroyEnvironmentBlock(IntPtr environment);
[DllImport("userenv.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool GetUserProfileDirectory(
IntPtr token, StringBuilder? profileDirectory, ref uint size);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateProcessAsUser(
IntPtr token,
string? applicationName,
string? commandLine,
IntPtr processAttributes,
IntPtr threadAttributes,
bool inheritHandles,
uint creationFlags,
IntPtr environment,
string? currentDirectory,
ref STARTUPINFO startupInfo,
out PROCESS_INFORMATION processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
}

View File

@@ -0,0 +1,238 @@
using System.Text.Json.Serialization;
namespace ShopDbEndpointAgent.Core;
// Guards server-provisioned launch paths. Historically the command wire never
// carried a launch path at all - the agent mapped an image name to a known-good
// local path and anything else failed closed. Server-provisioned monitors now
// MAY carry a path a site leader set in the WebUI, which is a deliberate
// relaxation of that invariant. This policy is the replacement safeguard: a
// server path is only honored when it sits under an approved root, actually
// exists, and is an approved executable type. Everything else is rejected and
// the agent falls back to its local launch map (still fail closed).
//
// The approved-root list is org-opinionated with shipped defaults (the Windows
// Program Files roots), overridable per site via AgentConfig.ApprovedLaunchRoots
// so the future Site Agent Configuration tool can widen or narrow it without a
// rebuild. A local appsettings.json launchpaths entry still wins over any
// server path, so a site pin is never silently overridden.
public sealed class LaunchPathPolicy
{
// Only these extensions are launchable. .bat is included because some shop-
// floor apps ship a launcher script (see the eDNC/UDC run.bat pattern).
private static readonly string[] ApprovedExtensions =
{ ".exe", ".bat", ".cmd" };
// Shipped org defaults: the standard 64- and 32-bit install roots. Resolved
// from the environment so a non-C: system drive still matches.
public static IReadOnlyList<string> DefaultApprovedRoots { get; } =
BuildDefaultRoots();
private readonly IReadOnlyList<string> approvedRoots;
public LaunchPathPolicy(IEnumerable<string>? approvedRootsOverride = null)
{
var roots = (approvedRootsOverride ?? Enumerable.Empty<string>())
.Where(root => !string.IsNullOrWhiteSpace(root))
.Select(NormalizeRoot)
.ToList();
approvedRoots = roots.Count > 0 ? roots : DefaultApprovedRoots;
}
// Result of validating a candidate path: the normalized full path when it is
// acceptable, or a human-readable reason when it is rejected.
public bool TryValidate(string? candidate, out string fullPath, out string reason)
{
fullPath = "";
reason = "";
if (string.IsNullOrWhiteSpace(candidate))
{
reason = "empty path";
return false;
}
string resolved;
try
{
resolved = Path.GetFullPath(candidate.Trim());
}
catch (Exception ex)
{
reason = $"not a valid path: {ex.Message}";
return false;
}
// Reject an alternate data stream before anything else looks at the
// path. On .NET Framework an interior colon made GetFullPath throw; on
// net8.0 it does not, so "readme.txt:payload.exe" survives, reports an
// .exe extension, passes the root check, and File.Exists returns true
// for the stream. CreateProcess will run it. Write access to any
// existing file under an approved root would otherwise be enough.
var lastSegment = Path.GetFileName(resolved);
if (lastSegment.Contains(':'))
{
reason = "path names an alternate data stream";
return false;
}
var extension = Path.GetExtension(resolved);
if (!ApprovedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
reason = $"extension '{extension}' is not launchable";
return false;
}
if (!IsUnderApprovedRoot(resolved))
{
reason = "path is outside every approved launch root";
return false;
}
if (!File.Exists(resolved))
{
reason = "file does not exist";
return false;
}
// Everything above is LEXICAL: GetFullPath is pure string normalization
// and never touches the disk, so the root check so far constrains the
// name, not the file. File.Exists follows reparse points, which means a
// junction anywhere under an approved root pointed the check at one
// place and the execution at another. Creating a junction needs no
// privilege, only write access to the parent - and vendor install trees
// under Program Files are not reliably locked down.
//
// So resolve what the path actually refers to and re-check THAT. A
// resolve failure rejects: this is the last gate before the agent runs
// the file.
string finalPath;
try
{
finalPath = ResolveFinalPath(resolved);
}
catch (Exception ex)
{
reason = $"cannot resolve the real path: {ex.Message}";
return false;
}
if (!IsUnderApprovedRoot(finalPath))
{
reason = "path resolves outside every approved launch root";
return false;
}
fullPath = finalPath;
return true;
}
// The path with every symlink and junction on it followed, so the caller
// checks the file that will actually execute. The leaf is resolved first,
// then the directory chain above it - a junction is usually placed on a
// DIRECTORY, so resolving only the leaf would miss the case this exists to
// catch.
private static string ResolveFinalPath(string resolved)
{
string? name;
try
{
name = System.IO.File.ResolveLinkTarget(resolved, returnFinalTarget: true)?.FullName;
}
catch
{
name = null;
}
if (!string.IsNullOrWhiteSpace(name))
{
// ResolveLinkTarget resolves the file itself; run it back through
// GetFullPath so the comparison sees the same shape as the roots.
return Path.GetFullPath(name);
}
// Not a link. Its directory chain still might be, so resolve that.
var directory = Path.GetDirectoryName(resolved);
if (string.IsNullOrEmpty(directory))
return resolved;
var realDirectory = ResolveDirectoryChain(directory);
return Path.GetFullPath(Path.Combine(realDirectory, Path.GetFileName(resolved)));
}
// Walk up the directory chain resolving any junction or symlink on it. A
// junction is usually placed on a DIRECTORY, not on the executable, so
// resolving only the leaf would miss the case this exists to catch.
private static string ResolveDirectoryChain(string directory)
{
// Stop at the volume root. Walking into it and asking whether "C:\" is
// a link throws DirectoryNotFoundException, which the caller turned
// into a rejection - so the first version of this rejected every
// legitimate path as well as the malicious ones. A fail-closed gate
// that also fails the normal case is just an outage. Caught in the VM,
// not here.
var parent = Path.GetDirectoryName(directory);
if (string.IsNullOrEmpty(parent) || parent == directory)
return directory;
var resolvedDirectory = TryResolveLink(directory);
if (resolvedDirectory is not null)
return resolvedDirectory;
var realParent = ResolveDirectoryChain(parent);
return realParent == parent
? directory
: Path.Combine(realParent, Path.GetFileName(directory));
}
// A component's link target, or null when it is not a link OR cannot be
// asked. Never throws: "cannot tell" must not be reported as "is a link",
// and the caller re-checks the assembled path against the roots regardless,
// so an unresolvable component cannot widen what is accepted.
private static string? TryResolveLink(string path)
{
try
{
var target = System.IO.Directory
.ResolveLinkTarget(path, returnFinalTarget: true)?.FullName;
return string.IsNullOrWhiteSpace(target) ? null : target;
}
catch
{
return null;
}
}
private bool IsUnderApprovedRoot(string resolved)
{
foreach (var root in approvedRoots)
{
// Compare against root + separator so "C:\Program Files" does not
// also match "C:\Program FilesEvil\...". Case-insensitive on Windows.
var prefix = root.EndsWith(Path.DirectorySeparatorChar)
? root
: root + Path.DirectorySeparatorChar;
if (resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static string NormalizeRoot(string root)
{
var full = Path.GetFullPath(root.Trim());
return full.TrimEnd(Path.DirectorySeparatorChar);
}
private static IReadOnlyList<string> BuildDefaultRoots()
{
var candidates = new[]
{
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
};
return candidates
.Where(path => !string.IsNullOrWhiteSpace(path))
.Select(NormalizeRoot)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
}

View File

@@ -0,0 +1,52 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ShopDbEndpointAgent.Core;
// The on-disk hand-off between the interactive tray (which captures the
// operator's screen and notes) and the headless service binary (which holds the
// collector key and performs the upload). The tray writes this JSON sidecar to a
// per-run temp folder and invokes `shopdbagent.exe --report <path>`; the agent
// reads it, uploads, and deletes the folder. Keeping the secret in the service
// preserves the "tray never holds a credential" rule already used by --once.
public sealed class OperatorReportPayload
{
[JsonPropertyName("reason")]
public string Reason { get; set; } = "Other (free text)";
[JsonPropertyName("details")]
public string Details { get; set; } = string.Empty;
// Absolute path to the captured PNG, or null when the operator declined a
// screenshot. Written by the tray into the interactive operator's
// %USERPROFILE%\ShopDBScreenshots folder and passed through as an absolute
// path; the agent reads it verbatim and never re-derives the location (as
// SYSTEM its own profile would resolve elsewhere).
[JsonPropertyName("screenshotpath")]
public string? ScreenshotPath { get; set; }
private static readonly JsonSerializerOptions Options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
};
public static void Write(string filePath, string reason, string details,
string? screenshotPath)
{
var payload = new OperatorReportPayload
{
Reason = reason,
Details = details,
ScreenshotPath = screenshotPath,
};
File.WriteAllText(filePath, JsonSerializer.Serialize(payload, Options));
}
public static OperatorReportPayload Read(string filePath)
{
var json = File.ReadAllText(filePath);
return JsonSerializer.Deserialize<OperatorReportPayload>(json, Options)
?? throw new InvalidDataException("empty or invalid report payload");
}
}

View File

@@ -0,0 +1,114 @@
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Site-provided PowerShell "agent utilities" runner. Discovers *.ps1 under the
// configured utilities directory and runs each with the OS powershell.exe,
// parsing its stdout as JSON. This is the documented collection seam: the
// future agent-configuration settings page lets site staff push their own
// scripts down, and their JSON output is merged into the report under
// `scheduledtasks`-adjacent extension keys without the runtime knowing what any
// script means.
//
// Safety posture for this iteration:
// - Disabled unless a utilities directory is configured AND exists.
// - Each script is bounded by a timeout and its failure is isolated.
// - Output must be a single JSON value; non-JSON output is logged and dropped.
// Script SIGNING/allow-listing is a rollout concern (code signing + Intune) and
// is called out in the design doc; do not enable this in production until that
// is in place.
public sealed class PowerShellUtilityRunner : IAgentUtilityRunner
{
private readonly string utilitiesPath;
private readonly ILogger<PowerShellUtilityRunner> logger;
private readonly TimeSpan perScriptTimeout;
public PowerShellUtilityRunner(string utilitiesPath,
ILogger<PowerShellUtilityRunner> logger, TimeSpan? perScriptTimeout = null)
{
this.utilitiesPath = utilitiesPath;
this.logger = logger;
this.perScriptTimeout = perScriptTimeout ?? TimeSpan.FromSeconds(30);
}
public IReadOnlyDictionary<string, object> RunAll(CancellationToken token)
{
var results = new Dictionary<string, object>();
if (string.IsNullOrWhiteSpace(utilitiesPath) || !Directory.Exists(utilitiesPath))
return results;
foreach (var script in Directory.EnumerateFiles(utilitiesPath, "*.ps1"))
{
token.ThrowIfCancellationRequested();
var name = Path.GetFileNameWithoutExtension(script);
try
{
var parsed = RunOne(script, token);
if (parsed is not null)
results[name] = parsed;
}
catch (Exception error)
{
logger.LogWarning(error, "Agent utility {Utility} failed", name);
}
}
return results;
}
private object? RunOne(string scriptPath, CancellationToken token)
{
var start = new ProcessStartInfo
{
FileName = "powershell.exe",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
start.ArgumentList.Add("-NoProfile");
start.ArgumentList.Add("-NonInteractive");
start.ArgumentList.Add("-ExecutionPolicy");
start.ArgumentList.Add("Bypass");
start.ArgumentList.Add("-File");
start.ArgumentList.Add(scriptPath);
using var process = new Process { StartInfo = start };
if (!process.Start())
return null;
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
if (!process.WaitForExit((int)perScriptTimeout.TotalMilliseconds))
{
try { process.Kill(entireProcessTree: true); } catch { }
logger.LogWarning("Agent utility {Script} timed out", scriptPath);
return null;
}
if (process.ExitCode != 0)
{
logger.LogWarning("Agent utility {Script} exit {Code}: {Error}",
scriptPath, process.ExitCode, stderr.Trim());
return null;
}
stdout = stdout.Trim();
if (stdout.Length == 0)
return null;
try
{
using var document = JsonDocument.Parse(stdout);
return JsonSerializer.Deserialize<object>(stdout);
}
catch (JsonException)
{
logger.LogWarning("Agent utility {Script} produced non-JSON output",
scriptPath);
return null;
}
}
}

View File

@@ -0,0 +1,159 @@
namespace ShopDbEndpointAgent.Core;
// Guards which registered scheduled tasks the scheduledtask_run action may
// start. The wire carries a free-text task path (so the WebUI can expose it as
// one field, and a site can widen the set without an agent rebuild); this
// policy is the safeguard that makes that free text safe to honor.
//
// WHY THIS EXISTS AT ALL. The agent runs as SYSTEM. A scheduled task is an
// INDIRECTION: the task path names a definition, and that definition chooses
// what actually runs. So "start the task named X" is only as safe as the set of
// X the agent will accept - an unconstrained task name would be a general-
// purpose "run anything already registered on this box, as whatever context it
// asked for" primitive. That is a materially bigger grant than any other
// allowlisted action, and it would be invisible in the audit trail (the command
// log would read "scheduledtask_run \Foo" no matter what \Foo does).
//
// WHAT THIS DOES AND DOES NOT BUY. A prefix allowlist stops a compromised or
// misconfigured SERVER from starting arbitrary tasks on the fleet - that is the
// threat it actually addresses, and it is the realistic one. It does NOT stop an
// attacker who can already register or rewrite task definitions on the endpoint:
// such an attacker can define a task under an approved prefix and have the agent
// start it. That is accepted, because anyone who can write task definitions on a
// box already has the privilege the task would grant them. Local task definition
// ACLs, not this policy, are the control for that.
//
// The approved-prefix list ships with the Corporate Tools set and is overridable
// per site via AgentConfig.ApprovedTaskPaths, matching the LaunchPathPolicy
// pattern so the future Site Agent Configuration tool can narrow or widen it
// without a rebuild.
public sealed class ScheduledTaskPolicy
{
// Shipped defaults: the Corporate Tools task set. Prefix entries end with a
// backslash and match a whole folder; entries without one match that exact
// task path only. Kept deliberately tight - widen per site, not here.
//
// The ShopDB-* entries are tasks ShopDB itself owns. They are separate task
// definitions from any corporate-initiated equivalent ON PURPOSE: a
// ShopDB-initiated Dell Command scan must write its results somewhere the
// agent's collector can attribute to ShopDB, rather than into whatever
// location a corporate-managed task already writes. Keeping the definitions
// distinct is what makes "who asked for this scan" answerable at all - see
// the note in commands.py about the DCU output path convention.
public static IReadOnlyList<string> DefaultApprovedTaskPaths { get; } = new[]
{
@"\ReportIP",
@"\SFLD-ApplyDSCConfig",
@"\SFLD - Consume Credentials",
@"\ShopDB-DellCommandScan",
@"\ShopDB-DellCommandUpdate",
};
private readonly IReadOnlyList<string> approvedPaths;
public ScheduledTaskPolicy(IEnumerable<string>? approvedPathsOverride = null)
{
var paths = (approvedPathsOverride ?? Enumerable.Empty<string>())
.Where(path => !string.IsNullOrWhiteSpace(path))
.Select(NormalizeTaskPath)
.ToList();
approvedPaths = paths.Count > 0 ? paths : DefaultApprovedTaskPaths;
}
// Validates a candidate task path. On success taskPath is the normalized
// path to hand to Task Scheduler; on failure reason explains the rejection
// so the command result tells the operator WHY rather than just "denied".
public bool TryValidate(string? candidate, out string taskPath, out string reason)
{
taskPath = "";
reason = "";
if (string.IsNullOrWhiteSpace(candidate))
{
reason = "empty task path";
return false;
}
var normalized = NormalizeTaskPath(candidate);
// Traversal in a task path is meaningless to Task Scheduler but very
// meaningful to a prefix check: "\Approved\..\Evil" starts with an
// approved prefix as a STRING while naming something else entirely.
// Reject rather than try to canonicalize it away.
if (normalized.Contains(".."))
{
reason = "task path contains a traversal segment";
return false;
}
// Control characters and quotes have no place in a task path and are the
// shape of an attempt to break out of the argument. The invocation uses
// ArgumentList (no shell, no string concatenation) so this is defense in
// depth rather than the primary control, but a task path that needs
// these is not a task path we should be starting.
foreach (var ch in normalized)
{
if (char.IsControl(ch) || ch == '"' || ch == '&' || ch == '|')
{
reason = "task path contains an illegal character";
return false;
}
}
if (!IsApproved(normalized))
{
reason = "task path is not in the approved task list";
return false;
}
taskPath = normalized;
return true;
}
// Task Scheduler paths are rooted and backslash-separated. Accept the
// operator writing "ReportIP", "\ReportIP", or "/ReportIP" and settle on the
// rooted form, so an allowlist entry does not hinge on how it was typed.
private static string NormalizeTaskPath(string path)
{
var trimmed = (path ?? "").Trim().Replace('/', '\\');
if (trimmed.Length == 0)
return "";
if (!trimmed.StartsWith('\\'))
trimmed = "\\" + trimmed;
// Collapse a trailing separator so "\Folder\" and "\Folder" compare
// equal as exact entries; prefix semantics are handled in IsApproved.
while (trimmed.Length > 1 && trimmed.EndsWith('\\'))
trimmed = trimmed[..^1];
return trimmed;
}
// An entry ending in a separator is a FOLDER prefix and matches anything
// beneath it; any other entry must match the whole task path exactly. The
// exact-match case is why "\Approved" does not silently authorize
// "\ApprovedEvil".
private bool IsApproved(string candidate)
{
foreach (var entry in approvedPaths)
{
if (entry.Length == 0)
continue;
var isFolderPrefix = entry.EndsWith('\\');
if (isFolderPrefix)
{
if (candidate.StartsWith(entry, StringComparison.OrdinalIgnoreCase))
return true;
continue;
}
if (string.Equals(candidate, entry, StringComparison.OrdinalIgnoreCase))
return true;
// Allow the folder form without its trailing separator to still act
// as a folder: "\SFLD" approves "\SFLD\Thing" but never "\SFLDEvil".
if (candidate.StartsWith(entry + "\\", StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent.Core</RootNamespace>
<AssemblyName>ShopDbEndpointAgent.Core</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.Management" Version="8.0.0" />
<PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
<!-- EventWaitHandleAcl: creating the user-context refresh event with an
explicit DACL. The service creates it as LocalSystem, and without a
granted ACE the unelevated tray cannot open it. -->
<PackageReference Include="System.Threading.AccessControl" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,128 @@
using System.Net;
using System.Runtime.InteropServices;
namespace ShopDbEndpointAgent.Core;
// TCP connection state with owning process ids, via GetExtendedTcpTable.
//
// Why P/Invoke instead of shelling out to netstat.exe: parsing netstat text
// output means spawning a process on every telemetry tick and depending on the
// column layout of a localized command. GetExtendedTcpTable is the API netstat
// itself calls, returns process ids directly, and costs a single allocation.
//
// Listeners are the cheap, bounded half of this (a handful of rows, stable over
// time) and ride the normal cadence. The full connection table is unbounded and
// churns constantly, so it stays on-demand.
public static class TcpTableReader
{
private const int AF_INET = 2;
private const uint ERROR_INSUFFICIENT_BUFFER = 122;
private const int TCP_TABLE_OWNER_PID_ALL = 5;
public enum TcpState
{
Closed = 1,
Listen = 2,
SynSent = 3,
SynReceived = 4,
Established = 5,
FinWait1 = 6,
FinWait2 = 7,
CloseWait = 8,
Closing = 9,
LastAck = 10,
TimeWait = 11,
DeleteTcb = 12,
}
public readonly record struct TcpRow(
string LocalAddress,
int LocalPort,
string RemoteAddress,
int RemotePort,
TcpState State,
int ProcessId);
// Full IPv4 TCP table. Throws only on catastrophic interop failure; callers
// treat an empty list as "not observed".
public static List<TcpRow> GetTcpTable()
{
var rows = new List<TcpRow>();
var buffer = IntPtr.Zero;
var size = 0;
try
{
// First call sizes the buffer. It is expected to fail with
// INSUFFICIENT_BUFFER; anything else is a real error.
var result = GetExtendedTcpTable(
IntPtr.Zero, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0);
if (result != ERROR_INSUFFICIENT_BUFFER && result != 0)
return rows;
buffer = Marshal.AllocHGlobal(size);
result = GetExtendedTcpTable(
buffer, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0);
if (result != 0)
return rows;
var entryCount = Marshal.ReadInt32(buffer);
var rowSize = Marshal.SizeOf<MIB_TCPROW_OWNER_PID>();
var cursor = IntPtr.Add(buffer, 4);
for (var index = 0; index < entryCount; index++)
{
var row = Marshal.PtrToStructure<MIB_TCPROW_OWNER_PID>(cursor);
rows.Add(new TcpRow(
FormatAddress(row.localAddress),
FormatPort(row.localPort),
FormatAddress(row.remoteAddress),
FormatPort(row.remotePort),
(TcpState)row.state,
(int)row.owningProcessId));
cursor = IntPtr.Add(cursor, rowSize);
}
}
finally
{
if (buffer != IntPtr.Zero)
Marshal.FreeHGlobal(buffer);
}
return rows;
}
public static List<TcpRow> GetListeners()
=> GetTcpTable().FindAll(row => row.State == TcpState.Listen);
private static string FormatAddress(uint address)
=> new IPAddress(BitConverter.GetBytes(address)).ToString();
// The port arrives as a big-endian value packed into the low two bytes of a
// host-order uint, so the bytes must be swapped rather than simply cast.
private static int FormatPort(uint port)
{
var bytes = BitConverter.GetBytes(port);
return (bytes[0] << 8) | bytes[1];
}
[StructLayout(LayoutKind.Sequential)]
private struct MIB_TCPROW_OWNER_PID
{
public uint state;
public uint localAddress;
public uint localPort;
public uint remoteAddress;
public uint remotePort;
public uint owningProcessId;
}
[DllImport("iphlpapi.dll", SetLastError = true)]
private static extern uint GetExtendedTcpTable(
IntPtr tcpTable,
ref int size,
bool order,
int addressFamily,
int tableClass,
int reserved);
}

View File

@@ -0,0 +1,260 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// The telemetry loop as a hosted BackgroundService. On first contact it sends
// adopt=true (once) so the server's computers collector creates the PC; every
// run after that omits adopt and relies on strict hostname resolution. Between
// runs it waits the configured cadence plus/minus jitter so a fleet does not
// report in lockstep (thundering herd).
//
// The worker never lets one bad run kill the loop: a transient failure is
// already retried inside CollectorClient, and a permanent failure (bad key,
// PC removed server-side) is logged and the loop simply waits for the next
// cadence tick rather than hammering the server.
public sealed class TelemetryWorker : BackgroundService
{
// The one agent utility whose output is forwarded to the server today. The
// name is the .ps1 filename without its extension, which is how
// PowerShellUtilityRunner keys its results.
public const string DellUpdatesUtilityName = "shopdb-dellupdates";
private readonly ICollector collector;
private readonly CollectorClient client;
private readonly IAgentUtilityRunner? utilities;
private readonly AgentConfig config;
private readonly string agentVersion;
private readonly ILogger<TelemetryWorker> logger;
private readonly CollectSignal? collectSignal;
private readonly UserContextSpool? userContextSpool;
private readonly UserContextRefreshChannel? userContextRefresh;
private readonly Random jitter = new();
// How long to wait for the tray to answer a refresh request.
//
// Bounded, and bounded SHORT. Telemetry is this agent's liveness signal: a
// report that arrives with slightly stale mapped drives is a working
// endpoint, while a report that never arrives because we blocked on an
// absent tray is an endpoint that looks dead. The probe itself is a single
// short PowerShell run, so a tray that is alive answers well inside this;
// anything longer means nobody is home.
private static readonly TimeSpan UserContextRefreshTimeout = TimeSpan.FromSeconds(12);
// Poll interval while waiting for the stamp to advance. There is no
// completion signal coming back the other way - the tray's answer IS the
// rewritten spool file - so this is a poll by design, kept coarse enough to
// be free and fine enough not to add visible latency.
private static readonly TimeSpan UserContextPollInterval = TimeSpan.FromMilliseconds(400);
public TelemetryWorker(ICollector collector, CollectorClient client,
AgentConfig config, string agentVersion, ILogger<TelemetryWorker> logger,
IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null,
UserContextSpool? userContextSpool = null,
UserContextRefreshChannel? userContextRefresh = null)
{
this.collector = collector;
this.client = client;
this.config = config;
this.agentVersion = agentVersion;
this.logger = logger;
this.utilities = utilities;
this.collectSignal = collectSignal;
this.userContextSpool = userContextSpool;
this.userContextRefresh = userContextRefresh;
}
// Request a tray-side re-probe and wait, briefly, for the spool to be
// rewritten. Never throws and never fails the run.
//
// The service does NOT collect session data itself and must not start doing
// so: mapped drives belong to the operator's logon token, and a LocalSystem
// probe reads a different, near-empty drive table. See the tombstone in
// Program.cs. This method is the entire service-side involvement - ask, then
// wait for the answer to appear on disk.
private async Task RefreshUserContextAsync(CancellationToken token)
{
if (userContextSpool is null || userContextRefresh is null
|| !OperatingSystem.IsWindows())
{
return;
}
var resultPath = userContextSpool.TryGetResultPath(out _);
if (resultPath is null)
return;
// Captured BEFORE the request so "advanced" is measured against what we
// would otherwise have reported. A missing spool reads as null, and any
// stamp at all then counts as progress - that is the first-probe case.
var before = userContextSpool.TryReadCollectedAt(resultPath);
if (!userContextRefresh.RequestRefresh())
{
// Could not signal at all. Note that the inverse does NOT mean a tray
// is listening: the channel opens-or-creates and does not hold the
// handle, so signalling into an empty machine succeeds by creating
// the event and finding nobody waiting on it. Detecting an absent
// tray is the timeout's job below, not this return value's.
logger.LogDebug(
"Could not request a user-context refresh; reporting the existing spool.");
return;
}
var deadline = DateTime.UtcNow + UserContextRefreshTimeout;
while (DateTime.UtcNow < deadline)
{
try
{
await Task.Delay(UserContextPollInterval, token);
}
catch (OperationCanceledException)
{
return;
}
var after = userContextSpool.TryReadCollectedAt(resultPath);
if (after is not null && (before is null || after > before))
{
logger.LogDebug(
"User-context spool refreshed by the tray (collectedat={Stamp}).",
after);
return;
}
}
// Timed out. Proceed anyway - deliberately. Session data is one card on
// one page; telemetry carries heartbeat, inventory, events and command
// results, and holding all of that hostage to a tray that did not answer
// in twelve seconds trades a small inaccuracy for a total outage.
logger.LogDebug(
"The tray did not refresh the user-context spool within {Timeout}; "
+ "reporting with the existing session data.",
UserContextRefreshTimeout);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Endpoint agent {Version} -> {BaseUrl}, cadence {Cadence}s +/-{Jitter}s",
agentVersion, config.BaseUrl, config.CadenceSeconds, config.JitterSeconds);
bool firstRun = true;
while (!stoppingToken.IsCancellationRequested)
{
bool adopt = firstRun && config.AdoptOnFirstRun;
try
{
await RunOnceAsync(adopt, stoppingToken);
firstRun = false;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (CollectorException error) when (error.IsPermanent)
{
// Bad key or PC removed server-side: surface it, do not retry
// faster than cadence. If it was a first-run adopt, keep trying
// to adopt next tick (the operator may be provisioning the PC).
logger.LogError(error, "Permanent collector error; waiting for next cadence");
}
catch (Exception error)
{
logger.LogError(error, "Telemetry run failed");
}
try
{
if (collectSignal is not null)
{
// Wake early if a command asked for an immediate re-collect,
// otherwise fall through when the cadence delay elapses.
bool woken = await collectSignal.WaitAsync(NextDelay(), stoppingToken);
if (woken)
logger.LogInformation("Off-cadence collect requested after a command");
}
else
{
await Task.Delay(NextDelay(), stoppingToken);
}
}
catch (OperationCanceledException)
{
break;
}
}
logger.LogInformation("Endpoint agent stopped");
}
// Public so a --once console run and future on-demand triggers can reuse the
// exact production collection path.
public async Task<CollectorResponse> RunOnceAsync(bool adopt, CancellationToken token)
{
// Refresh the watch list from server policy BEFORE collecting so a
// policy-attached monitor (added in the WebUI, never in appsettings.json)
// is enumerated and reported this cycle. Server entries merge over the
// local list; a failed fetch returns null and Merge keeps the local list,
// so monitoring never blanks on a transient blip.
var resolved = await client.FetchResolvedMonitorsAsync(
Environment.MachineName, token);
if (resolved is not null)
{
config.Monitors = MonitorConfig.Merge(config.Monitors, resolved);
logger.LogDebug(
"Watch list refreshed from policy: {Processes} process(es), {Services} service(s)",
config.Monitors.Processes.Length, config.Monitors.Services.Length);
}
// Ask the tray to re-probe session data and WAIT for it, before
// collecting. Ordering is the whole point: the collector reads the spool
// file, so a refresh that lands after Collect() is a refresh the server
// does not see until the NEXT cycle. That was the bug - "Send Status"
// and "Refresh Status" both re-sent a spool nobody had rewritten, so a
// drive the operator had just unmapped kept reporting OK for up to half
// an hour and no amount of clicking changed it.
await RefreshUserContextAsync(token);
var report = collector.Collect(agentVersion, adopt);
if (utilities is not null)
{
var extra = utilities.RunAll(token);
if (extra.Count > 0)
logger.LogDebug("Loaded {Count} agent utility result(s)", extra.Count);
// NARROW ON PURPOSE. The runner executes every .ps1 in the utilities
// directory, but only this one known key is attached to the report.
// Forwarding the whole dictionary would mean any script dropped into
// that directory starts uploading its output to the server on every
// cadence - see the signing caveat in PowerShellUtilityRunner's
// header. Widening this to the full map is the intended end state of
// the IAgentUtilityRunner seam, but it needs script signing or an
// allowlist first. Until then, add a key here per utility.
if (extra.TryGetValue(DellUpdatesUtilityName, out var dellUpdates))
report.DellUpdates = dellUpdates;
}
logger.LogInformation(
"Reporting (adopt={Adopt}) user={User} serial={Serial} events={Events}",
adopt, report.LoggedInUser, report.SerialNumber, report.Events?.Length ?? 0);
var result = await client.SendAsync(report, token);
logger.LogInformation("OK action={Action} assetid={AssetId} identity={Identity}",
result.Action, result.AssetId, result.IdentityValue);
foreach (var warning in result.Warnings ?? Array.Empty<string>())
logger.LogWarning("Server warning: {Warning}", warning);
return result;
}
private TimeSpan NextDelay()
{
int spread = Math.Max(0, config.JitterSeconds);
int offset = spread == 0 ? 0 : jitter.Next(-spread, spread + 1);
int seconds = Math.Max(1, config.CadenceSeconds + offset);
return TimeSpan.FromSeconds(seconds);
}
}

View File

@@ -0,0 +1,362 @@
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Collects the session-scoped data that does not exist in the agent's own
// context, by running a short PowerShell probe IN THE CALLING PROCESS'S
// SESSION and writing the result to the user's spool file.
//
// Collects only data that genuinely does not exist in the agent's own context:
// netuse - mapped drives are per logon session; "net use" from
// LocalSystem returns SYSTEM's (empty) mapping table, never the
// operator's Z: to the file server.
// userlisteners- TCP listeners owned by the signed-in user's processes, which
// is what identifies an operator-launched DNC or CMM listener.
// idleseconds - GetLastInputInfo is per session, so session 0 cannot answer
// it. Sampled at probe time only, so treat it as a coarse
// "was anyone at the machine" signal, not live presence.
//
// This runs from `shopdbagent.exe --collect-usercontext`, which the tray - and
// only the tray - invokes, because the tray is already running as the signed-in
// operator. The service does NOT call this: it previously tried to descend into
// the session with CreateProcessAsUser, which is the direction that has to
// duplicate a token, is what endpoint protection watches for, and produced
// nothing on a live endpoint. Nothing here crosses a privilege boundary now;
// the probe simply runs as whoever the tray is.
//
// The script is embedded rather than shipped as a .ps1 so the probe cannot drift
// from the agent binary or be tampered with on disk between cycles.
public sealed class UserContextProbe
{
// The spool directory is injected by BuildProbeScript rather than derived in
// the script, so the writer cannot disagree with the reader. See
// UserContextSpool.PinnedSpoolDirectory for why $env:USERPROFILE was wrong.
private const string SpoolDirectoryToken = "__SHOPDB_SPOOL_DIR__";
private const string ProbeScriptTemplate = """
$ErrorActionPreference = 'Stop'
$result = [ordered]@{}
# Injected by the agent from UserContextSpool.PinnedSpoolDirectory() so the
# writer and the reader resolve the same directory. Deliberately NOT
# $env:USERPROFILE: the tray can inherit another account's environment on a
# locked-down image, which silently wrote this file into the wrong profile.
$spool = '__SHOPDB_SPOOL_DIR__'
$target = Join-Path $spool 'usercontext.json'
$temporary = "$target.tmp"
try {
New-Item -ItemType Directory -Path $spool -Force | Out-Null
$netuse = @()
try {
Get-CimInstance -ClassName Win32_NetworkConnection -ErrorAction Stop |
ForEach-Object {
$netuse += [ordered]@{
localname = $_.LocalName
remotename = $_.RemoteName
status = $_.Status
username = $_.UserName
persistent = [bool]$_.Persistent
}
}
} catch {
$result['netuseerror'] = $_.Exception.Message
}
$result['netuse'] = $netuse
# Second, independent view of the same drives. Win32_NetworkConnection
# is the "net use" table and Get-SmbMapping is the SMB client's own,
# and they disagree: a share can be Unavailable in one and absent or
# OK in the other. Neither is authoritative, so both are reported as
# collected and the server decides what to show. Normalising them
# here would discard exactly the disagreement that is diagnostic.
$smbmapping = @()
try {
Get-SmbMapping -ErrorAction Stop |
ForEach-Object {
$smbmapping += [ordered]@{
localpath = $_.LocalPath
remotepath = $_.RemotePath
status = [string]$_.Status
}
}
} catch {
# Absent on older builds and in some session types. A missing
# source is not a drive fault, so it is recorded and left out
# rather than reported as a failed mapping.
$result['smbmappingerror'] = $_.Exception.Message
}
$result['smbmapping'] = $smbmapping
$listeners = @()
try {
$ownprocesses = @{}
Get-Process -ErrorAction SilentlyContinue | ForEach-Object {
$ownprocesses[$_.Id] = $_.ProcessName
}
Get-NetTCPConnection -State Listen -ErrorAction Stop |
Where-Object { $ownprocesses.ContainsKey($_.OwningProcess) } |
ForEach-Object {
$listeners += [ordered]@{
localaddress = $_.LocalAddress
localport = $_.LocalPort
processid = $_.OwningProcess
processname = $ownprocesses[$_.OwningProcess]
}
}
} catch {
$result['userlistenerserror'] = $_.Exception.Message
}
$result['userlisteners'] = $listeners
# Idle time must be measured inside the user's session:
# GetLastInputInfo is per session, so asking from the agent's
# session 0 would report the service's own idle time, which is
# meaningless. This is what distinguishes "powered on" from
# "someone is actually working at this machine".
try {
# Built as a single-quoted string rather than a here-string: a
# here-string terminator must sit at column 0, which cannot be
# expressed inside an indented C# raw string literal.
$signature = 'using System; using System.Runtime.InteropServices; ' +
'public class ShopDbIdle { ' +
'[StructLayout(LayoutKind.Sequential)] ' +
'struct LASTINPUTINFO { public uint cbSize; public uint dwTime; } ' +
'[DllImport("user32.dll")] ' +
'static extern bool GetLastInputInfo(ref LASTINPUTINFO info); ' +
'public static long IdleSeconds() { ' +
'LASTINPUTINFO info = new LASTINPUTINFO(); ' +
'info.cbSize = (uint)Marshal.SizeOf(info); ' +
'if (!GetLastInputInfo(ref info)) return -1; ' +
'return (long)((uint)Environment.TickCount - info.dwTime) / 1000; } }'
Add-Type -TypeDefinition $signature -ErrorAction Stop
$idle = [ShopDbIdle]::IdleSeconds()
if ($idle -ge 0) { $result['idleseconds'] = $idle }
} catch {
$result['idlesecondserror'] = $_.Exception.Message
}
$result['collectedat'] = (Get-Date).ToUniversalTime().ToString('o')
$result['username'] = $env:USERNAME
# Stamp the token's elevation state. The username alone does not
# settle it: the SAME account elevated and unelevated gets two
# different logon tokens and therefore two different drive tables,
# so a spool from an elevated ShopFloor session passes the account
# check while still reporting every drive as broken.
try {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
$result['elevated'] = [bool] $principal.IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
} catch {
$result['elevatederror'] = $_.Exception.Message
}
# Write to a temporary name and rename into place, so the reader can
# never observe a half-written file and discard a good result.
$result | ConvertTo-Json -Depth 4 -Compress |
Set-Content -Path $temporary -Encoding UTF8 -NoNewline
Move-Item -Path $temporary -Destination $target -Force
} catch {
exit 1
}
exit 0
""";
// Substitute the resolved spool directory into the template. The path goes in
// as a PowerShell single-quoted literal, so an embedded quote is doubled and
// nothing else in the path is interpreted - backslashes and $ are literal
// inside single quotes, which is exactly what a Windows path needs.
private static string BuildProbeScript(string spoolDirectory)
=> ProbeScriptTemplate.Replace(
SpoolDirectoryToken, spoolDirectory.Replace("'", "''"));
// How long the probe is allowed to take. Generous: this no longer runs on
// the telemetry path, so an unresponsive WMI call delays nothing but itself.
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(60);
// powershell.exe's exit code when it rejects its command line and prints
// usage instead of running anything. Named so the failure is recognisable.
private const int PowerShellStartupFailure = -196608;
private readonly ILogger<UserContextProbe> logger;
public UserContextProbe(ILogger<UserContextProbe> logger)
{
this.logger = logger;
}
// Run the probe in THIS process's session and write the spool file.
// Returns true only when the spool file is present on disk afterwards.
//
// Called from the --collect-usercontext entry point, which the tray runs as
// the signed-in operator. Never throws: this is background collection and a
// failure must not take the tray or the agent process down with it.
public bool Collect()
{
var spoolDirectory = UserContextSpool.PinnedSpoolDirectory();
var resultPath = UserContextSpool.PinnedResultPath();
try
{
// Logged before the launch, not after. When this goes wrong the
// interesting question is always "which directory did it target",
// and a failure later in this method must not take the answer with
// it.
logger.LogInformation(
"Running the user-context probe as {User}; spool target {Path}",
Environment.UserName, resultPath);
// The probe writes a .tmp beside the target and moves it into place,
// so the directory has to exist before powershell.exe starts. The
// script creates it too, but doing it here means a permission problem
// is reported by the agent with a real exception rather than showing
// up as an opaque non-zero exit code.
try
{
Directory.CreateDirectory(spoolDirectory);
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
{
logger.LogWarning(error,
"Could not create the user-context spool directory at {Directory}",
spoolDirectory);
return false;
}
// Passed as -EncodedCommand rather than staged as a .ps1 and run with
// -File. The script used to be written into %TEMP% and executed from
// there, which put a dependency on that file still existing, and still
// being readable, at the moment powershell.exe started. On a hardened
// endpoint that window is not safe: endpoint protection quarantines a
// freshly written script in the temp directory between the write and
// the launch, and roaming/redirected profiles can make the path
// unusable outright.
//
// When powershell.exe cannot use its -File argument it prints its usage
// banner and exits -196608 (0xFFFD0000) WITHOUT running a single
// statement. That is a startup failure, not a script failure, so the
// script's own try/catch never runs, nothing is logged about drives,
// and the operator sees a silent "no mapped drives reported" while the
// log only shows the bare exit code. -EncodedCommand carries the script
// in the command line itself, so there is no file to lose and no
// execution-policy or quarantine step between us and the probe.
var encoded = Convert.ToBase64String(
System.Text.Encoding.Unicode.GetBytes(BuildProbeScript(spoolDirectory)));
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "powershell.exe",
Arguments =
"-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "
+ encoded,
WorkingDirectory = Path.GetTempPath(),
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = System.Diagnostics.Process.Start(startInfo);
if (process is null)
{
logger.LogWarning("The user-context probe could not be started.");
return false;
}
if (!process.WaitForExit((int)ProbeTimeout.TotalMilliseconds))
{
logger.LogWarning(
"The user-context probe did not finish within {Seconds} seconds.",
ProbeTimeout.TotalSeconds);
TryKill(process);
return false;
}
if (process.ExitCode != 0)
{
// 0xFFFD0000 is powershell.exe refusing its own command line and
// printing usage: the probe never ran. Called out by name because
// as a bare number it reads like a script error and sends the
// reader looking for a WMI or drive fault that is not there.
var detail = process.ExitCode == PowerShellStartupFailure
? " (powershell.exe rejected its command line; the probe did not run)"
: string.Empty;
logger.LogWarning(
"The user-context probe exited with code {ExitCode}.{Detail}",
process.ExitCode, detail);
return false;
}
// Exit code 0 is necessary but NOT sufficient. The probe used to be
// trusted on its exit code alone, which meant a run that wrote its
// file into a different profile entirely still reported success - the
// log claimed a spool had been written for weeks while nothing was
// ever there. Confirm the file exists where the reader will look.
if (!File.Exists(resultPath))
{
logger.LogWarning(
"The user-context probe reported success but no spool file is "
+ "present at {Path}.", resultPath);
return false;
}
// Byte count alone does not distinguish a healthy spool from a
// well-formed empty one, and "0 drives" is the exact symptom this
// whole path exists to diagnose. Summarising the contents means a
// single log line from any given task run answers whether the probe
// saw the operator's drives, without needing the spool itself.
var summary = SummariseSpool(resultPath);
logger.LogInformation(
"The user-context probe wrote {Bytes} bytes to {Path} as {User}: {Summary}",
new FileInfo(resultPath).Length, resultPath, Environment.UserName, summary);
return true;
}
catch (Exception exception)
{
logger.LogWarning(exception, "The user-context probe failed.");
return false;
}
}
// One-line description of what the probe actually captured, for the run log.
// Best-effort by design: this is diagnostic colour on an already-successful
// run, so a parse problem here must never turn that run into a failure.
private static string SummariseSpool(string resultPath)
{
try
{
using var document = System.Text.Json.JsonDocument.Parse(
File.ReadAllText(resultPath));
var root = document.RootElement;
static int CountOf(System.Text.Json.JsonElement root, string name) =>
root.TryGetProperty(name, out var value)
&& value.ValueKind == System.Text.Json.JsonValueKind.Array
? value.GetArrayLength()
: 0;
var user = root.TryGetProperty("username", out var stamped)
? stamped.GetString()
: "(unstamped)";
return $"user={user} netuse={CountOf(root, "netuse")} "
+ $"smbmapping={CountOf(root, "smbmapping")} "
+ $"listeners={CountOf(root, "userlisteners")}";
}
catch (Exception)
{
return "(could not summarise)";
}
}
private void TryKill(System.Diagnostics.Process process)
{
try
{
process.Kill(entireProcessTree: true);
}
catch (Exception exception)
{
logger.LogDebug(exception, "Could not stop the timed-out user-context probe.");
}
}
}

View File

@@ -0,0 +1,179 @@
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Threading;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// The service-to-tray request channel for an on-demand user-context refresh.
//
// WHY THIS EXISTS AT ALL. Mapped drives are a property of the operator's logon
// token, not of the machine. The agent is a session-0 LocalSystem service, so it
// CANNOT enumerate them - not by impersonation, not by CreateProcessAsUser, not
// by starting the probe task itself. That approach was built once and reverted;
// see the tombstone in Program.cs where UserContextProbe is deliberately kept
// out of the service's DI container. The only process on the box that can see
// the right drive table is one already running inside the operator's session:
// the tray.
//
// So the service does not probe. It ASKS. This channel is the ask, and the
// spool file remains the answer.
//
// WHY A NAMED EVENT rather than a pipe or a socket. The request carries no data
// - it is a single bit, "please re-probe now" - and the reply already has a
// transport (the spool file, whose collectedat stamp the service watches
// advance). A kernel event is the smallest thing that crosses the session-0
// boundary, needs no serialization, no port, no firewall rule, and no cleanup if
// either side dies. A file-watcher trigger would also work but leaves litter on
// disk when the service is killed mid-request.
//
// THE Global\ PREFIX IS LOAD-BEARING. Without it the name is scoped to the
// caller's session, so the service (session 0) and the tray (session 1+) would
// create two unrelated events with the same name and never see each other. That
// failure is silent: both sides "work", nothing is ever refreshed.
//
// THE DACL IS ALSO LOAD-BEARING. The service creates the event as LocalSystem,
// which by default produces an object a non-elevated user cannot even open. The
// tray runs unelevated as ShopFloor by design (an ELEVATED tray is refused by
// the probe, because it would read the wrong drive table). So the creator grants
// Users explicit Synchronize + Modify rights, or the tray gets access-denied on
// open and the refresh silently never happens.
public sealed class UserContextRefreshChannel
{
// Session-global so session 0 and the interactive session share one object.
public const string EventName = @"Global\ShopDbEndpointAgent.UserContextRefresh";
private readonly ILogger logger;
// Takes the base ILogger rather than ILogger<T>: both the service (generic
// host) and the tray (its own file-backed logger named "Tray") construct
// this, and the tray has no DI container to resolve a typed logger from.
public UserContextRefreshChannel(ILogger logger)
{
this.logger = logger;
}
// Signal the tray that a fresh probe is wanted.
//
// Returns false when the event could not be opened or created, which is the
// normal state on a machine with nobody signed in and no tray running. The
// caller treats that as "no refresh available" and reports what it already
// has - a stale mapped-drive list is worth strictly more than a missed
// heartbeat, because telemetry IS the liveness signal.
//
// AutoReset, so exactly one waiting tray is released per request and the
// event does not latch on for every subsequent listener iteration.
[SupportedOSPlatform("windows")]
public bool RequestRefresh()
{
try
{
using var handle = OpenOrCreate(out var created);
if (handle is null)
return false;
handle.Set();
logger.LogDebug(
"Requested a user-context refresh on {Event} (created={Created})",
EventName, created);
return true;
}
catch (Exception error)
{
// Never throws to the caller. This sits directly on the telemetry
// path, and a refresh is an enhancement to the report, not a
// precondition for sending it.
logger.LogDebug(error,
"Could not request a user-context refresh on {Event}", EventName);
return false;
}
}
// Tray side: block until the service asks for a refresh, the timeout expires,
// or the token is cancelled. Returns true only for an actual request.
[SupportedOSPlatform("windows")]
public bool WaitForRequest(TimeSpan timeout, CancellationToken token)
{
try
{
using var handle = OpenOrCreate(out _);
if (handle is null)
return false;
var index = WaitHandle.WaitAny(
new[] { handle, token.WaitHandle }, timeout);
// Index 1 is cancellation, WaitTimeout is the idle path; only index 0
// is a genuine request.
return index == 0;
}
catch (Exception error)
{
logger.LogDebug(error,
"Could not wait on the user-context refresh channel {Event}", EventName);
return false;
}
}
// Open the shared event, creating it if this process is first.
//
// Either side may start first - the service can boot before anyone signs in,
// and the tray can start before the service finishes initializing - so both
// sides use the same create-or-open call rather than designating an owner.
// Whoever wins applies the DACL; the loser opens the existing object.
[SupportedOSPlatform("windows")]
private EventWaitHandle? OpenOrCreate(out bool created)
{
created = false;
try
{
var security = new EventWaitHandleSecurity();
// BUILTIN\Users by SID, not by name: on a non-English Windows the
// group is not called "Users", and this agent ships to whatever
// locale the site runs. Same reasoning as the task registration.
var users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
security.AddAccessRule(new EventWaitHandleAccessRule(
users,
EventWaitHandleRights.Synchronize | EventWaitHandleRights.Modify,
AccessControlType.Allow));
// FullControl for SYSTEM and Administrators so the service retains
// ownership semantics regardless of which side created the object.
var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
security.AddAccessRule(new EventWaitHandleAccessRule(
system, EventWaitHandleRights.FullControl, AccessControlType.Allow));
var admins = new SecurityIdentifier(
WellKnownSidType.BuiltinAdministratorsSid, null);
security.AddAccessRule(new EventWaitHandleAccessRule(
admins, EventWaitHandleRights.FullControl, AccessControlType.Allow));
return EventWaitHandleAcl.Create(
initialState: false,
mode: EventResetMode.AutoReset,
name: EventName,
createdNew: out created,
eventSecurity: security);
}
catch (UnauthorizedAccessException)
{
// The object exists with a DACL that does not admit this caller.
// Retry read-side-only rights: the tray only ever needs to wait, and
// an event created by an older agent build may not grant Modify.
try
{
return EventWaitHandle.OpenExisting(EventName);
}
catch (Exception error)
{
logger.LogDebug(error,
"The user-context refresh event {Event} exists but is not accessible",
EventName);
return null;
}
}
}
}

View File

@@ -0,0 +1,389 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Reads the result file that the tray-run user-context probe writes into the
// signed-in user's ShopDB spool folder.
//
// Why a file and not a return value: the agent is a session-0 LocalSystem
// service, so anything session-scoped (mapped drives from "net use", per-user
// network state) simply does not exist in its own context. The probe runs as the
// operator under the tray, in a completely separate process with no pipe back to
// the service. A file in the user's own profile is the handoff, and the user can
// always write there without elevation.
//
// Freshness is judged from the 'collectedat' stamp inside the file, NOT by
// deleting the file around each read. Writer and reader are now independent
// processes on unrelated schedules, so a delete-then-read would race the tray
// and usually destroy a perfectly good result before it was ever reported. The
// file is therefore left alone and simply ignored once it ages out, which also
// means a logged-off machine stops reporting session data instead of pinning the
// last operator's mapped drives forever.
public sealed class UserContextSpool
{
public const string SpoolFolderName = "ShopDB";
public const string ResultFileName = "usercontext.json";
// The one definition of where the spool lives. Both the probe (writer) and
// this class (reader) call it, so the two can no longer disagree.
//
// The writer used to build its own path from $env:USERPROFILE while the
// reader used the pinned ShopFloor profile. On a locked-down image those are
// not the same directory - the tray is launched through explorer.exe by an
// elevated installer and can inherit another account's environment block, as
// the tray already documents for %TEMP%. The probe then wrote a perfectly
// good file into the wrong profile, exited 0, and the service found nothing:
// no spool ever appeared and no mapped drive was ever reported.
public static string PinnedSpoolDirectory()
=> Path.Combine(FileLoggerProvider.ShopFloorProfileDirectory(), SpoolFolderName);
public static string PinnedResultPath()
=> Path.Combine(PinnedSpoolDirectory(), ResultFileName);
// A probe result is small - a handful of mapped drives and listeners. A file
// larger than this is either corrupt or hostile, and we will not parse it.
private const long MaxResultBytes = 512 * 1024;
// A result older than this is treated as absent. Sized at roughly three tray
// probe intervals (30 min each), so a single missed run never blanks the card
// but genuinely stale session data retires quickly.
//
// This was 24h when the reader deleted the file after each read; the delete
// was doing the real retiring and the age check was only a backstop. Now that
// the spool persists, this value IS the staleness bound, so it is sized
// against the writer's cadence rather than the server's quiet-report window.
//
// Not read from the server: this is an offline-safe local bound, and the
// agent must retire stale session data even when it cannot reach ShopDB.
private static readonly TimeSpan MaxResultAge = TimeSpan.FromMinutes(90);
private readonly ILogger<UserContextSpool> logger;
public UserContextSpool(ILogger<UserContextSpool> logger)
{
this.logger = logger;
}
// Full path to the spool file. Fixed at C:\Users\ShopFloor\ShopDB, because
// every shop-floor endpoint signs in as the same local ShopFloor account.
//
// This used to be resolved through WTSQueryUserToken/GetUserProfileDirectory,
// and that lookup is precisely what fails on a hardened endpoint: querying
// another session's token is a privileged operation that endpoint protection
// blocks. When it failed the service simply reported "no session data" while
// the tray was writing a perfectly good file, so writer and reader silently
// disagreed about the location and the operator saw an empty card.
//
// The token lookup is kept ONLY as a fallback for a machine whose operator
// account is named something else. If both are unavailable there is no
// session data, which costs mapped drives and idle time but never affects
// telemetry, commands or inventory.
public string? TryGetResultPath(out string? reason)
{
var pinned = FileLoggerProvider.ShopFloorProfileDirectory();
if (Directory.Exists(pinned))
{
reason = null;
return PinnedResultPath();
}
var profileDirectory =
InteractiveProcessLauncher.TryGetActiveUserProfileDirectory(out var error);
if (string.IsNullOrWhiteSpace(profileDirectory))
{
reason = $"No {FileLoggerProvider.ShopFloorUserName} profile at {pinned}, "
+ "and the interactive profile could not be resolved either: "
+ (error ?? "unknown error");
return null;
}
reason = null;
return Path.Combine(profileDirectory, SpoolFolderName, ResultFileName);
}
// Remove a result file. Only used to discard one that is corrupt or has aged
// out; a valid result is left in place for the next read.
public void ClearStaleResult(string resultPath)
{
try
{
if (File.Exists(resultPath))
{
File.Delete(resultPath);
logger.LogDebug(
"Removed a stale user-context spool file at {Path}", resultPath);
}
}
catch (Exception exception)
{
// Not fatal on its own, but it does mean we cannot trust whatever we
// read next, so the caller must know.
logger.LogWarning(exception,
"Could not remove the stale user-context spool file at {Path}", resultPath);
}
}
public void EnsureSpoolDirectory(string resultPath)
{
var directory = Path.GetDirectoryName(resultPath);
if (string.IsNullOrWhiteSpace(directory))
return;
try
{
Directory.CreateDirectory(directory);
}
catch (Exception exception)
{
logger.LogWarning(exception,
"Could not create the user-context spool directory at {Directory}", directory);
}
}
// Read the probe result without consuming it. Returns null when there is no
// usable, sufficiently recent result, which the caller reports as "no session
// data this cycle" rather than as empty data - absent and empty mean
// different things here.
public Dictionary<string, object?>? TryReadResult(string resultPath)
{
try
{
if (!File.Exists(resultPath))
return null;
var info = new FileInfo(resultPath);
if (info.Length == 0)
{
logger.LogDebug("The user-context spool file was empty.");
return null;
}
if (info.Length > MaxResultBytes)
{
logger.LogWarning(
"The user-context spool file was {Bytes} bytes, above the {Limit} byte "
+ "limit; discarding it.",
info.Length, MaxResultBytes);
return null;
}
var json = File.ReadAllText(resultPath);
if (string.IsNullOrWhiteSpace(json))
return null;
var parsed = JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
if (parsed is null || parsed.Count == 0)
return null;
if (IsExpired(parsed, resultPath))
return null;
if (IsWrongAccount(parsed))
return null;
if (IsElevatedCollection(parsed))
return null;
return parsed;
}
catch (JsonException exception)
{
// A truncated file is the expected shape of this failure: the probe
// was still writing when we looked. Next cycle gets a clean one.
logger.LogWarning(exception,
"The user-context spool file was not valid JSON; discarding it.");
return null;
}
catch (Exception exception)
{
logger.LogWarning(exception,
"Could not read the user-context spool file at {Path}", resultPath);
return null;
}
}
// Read the spool WITHOUT removing it. The probe overwrites the file on every
// run (write .tmp, move into place), so the newest result always replaces the
// previous one and there is nothing for the reader to clean up.
//
// This used to delete the file after a successful read, to stop a dead tray
// re-reporting the same drives forever. The collectedat age check already
// does that job, and deleting created a worse problem: lastUserContext in the
// collector is in-memory only, so a service restart wiped the cache while the
// spool had already been consumed. The agent then reported NO mapped drives
// until the tray's next probe - and the installer restarts the service and
// the tray on every install, so a fresh install was guaranteed to show an
// empty card at exactly the moment someone looked at it.
//
// The contents are the operator's own session data, visible to them in their
// own context, so leaving the file in place costs nothing.
public Dictionary<string, object?>? ConsumeResult(string resultPath)
=> TryReadResult(resultPath);
// The collectedat stamp on disk, or null when there is no readable stamp.
//
// This is how the service PROVES a refresh actually happened rather than
// assuming it. Signalling the tray is asynchronous and the probe takes a
// second or two, so a caller that simply slept would either send the old
// spool anyway or pad the delay and hope. Watching this value advance past
// the pre-request stamp is the only honest way to know the file in front of
// us is the answer to OUR request and not the previous one.
//
// Deliberately NOT routed through TryReadResult: this must report the stamp
// of whatever is on disk, including a spool that is expired, elevated, or
// from the wrong account. Those are all rejected later for reporting, but
// for ordering we only care whether the file was rewritten - conflating "not
// usable" with "not fresh" would make the service wait out the full timeout
// on every cycle for a machine whose spool is being rejected for some other
// reason.
//
// Also deliberately NOT the file's LastWriteTime, for the reason IsExpired
// gives: a spool can be copied or restored with a roaming profile and carry
// a misleading filesystem timestamp. The stamp inside the file is written by
// the probe itself.
public DateTimeOffset? TryReadCollectedAt(string resultPath)
{
try
{
if (!File.Exists(resultPath))
return null;
var info = new FileInfo(resultPath);
if (info.Length == 0 || info.Length > MaxResultBytes)
return null;
var json = File.ReadAllText(resultPath);
if (string.IsNullOrWhiteSpace(json))
return null;
var parsed = JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
if (parsed is null
|| !parsed.TryGetValue("collectedat", out var raw)
|| raw is null)
{
return null;
}
if (DateTimeOffset.TryParse(
raw.ToString(),
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind,
out var collectedat))
{
return collectedat;
}
return null;
}
catch (Exception exception)
{
// A torn read while the probe is mid-write is the expected failure
// here and is not worth a warning: the caller polls, so the next
// attempt a moment later sees the completed file.
logger.LogDebug(exception,
"Could not read the user-context collectedat stamp at {Path}", resultPath);
return null;
}
}
// Reject a spool that some other account produced.
//
// Pinning the path guarantees the file lands where the reader looks, but it
// cannot guarantee WHO ran the probe. Mapped drives and idle time are per
// logon session, so a probe that ran as an administrator (for example a tray
// that inherited an elevated installer's environment) would enumerate that
// account's drive table and write it into the operator's spool. That is worse
// than reporting nothing, because the card would look populated and correct
// while showing drives the operator does not have.
//
// The probe stamps $env:USERNAME into the file, so the mismatch is checkable.
// Logged at warning, not debug: this is a misconfigured endpoint, not a quiet
// absence of data.
private bool IsWrongAccount(Dictionary<string, object?> parsed)
{
if (!parsed.TryGetValue("username", out var raw) || raw is null)
{
logger.LogWarning(
"The user-context spool has no username stamp; ignoring it because "
+ "the session it describes cannot be confirmed.");
return true;
}
var username = raw.ToString();
if (string.Equals(username, FileLoggerProvider.ShopFloorUserName,
StringComparison.OrdinalIgnoreCase))
{
return false;
}
logger.LogWarning(
"The user-context spool was written by {Actual}, not {Expected}; "
+ "ignoring it. Session data is per logon session, so this would "
+ "report the wrong account's mapped drives.",
username, FileLoggerProvider.ShopFloorUserName);
return true;
}
// An elevated probe runs under a different logon token to the operator's
// interactive one and enumerates a different, usually empty, drive table.
// The agent refuses to collect while elevated, so a spool stamped elevated
// should not exist; this rejects any that predates that guard or arrives
// from a hand-run --collect-usercontext in an admin shell.
//
// Note the asymmetry with IsWrongAccount: a MISSING stamp is accepted here
// rather than rejected. An unstamped spool is simply one written by an older
// agent, and treating that as elevated would blank out session data across a
// fleet mid-upgrade. An unstamped spool has already had to pass the username
// check to reach this point.
private bool IsElevatedCollection(Dictionary<string, object?> parsed)
{
if (!parsed.TryGetValue("elevated", out var raw) || raw is null)
return false;
var elevated = raw is JsonElement element
? element.ValueKind == JsonValueKind.True
: string.Equals(raw.ToString(), "true", StringComparison.OrdinalIgnoreCase);
if (!elevated)
return false;
logger.LogWarning(
"The user-context spool was collected by an ELEVATED process; ignoring "
+ "it. Mapped drives are per logon token, so an elevated probe reports "
+ "the wrong drive table even under the right account. Collection should "
+ "come from the 'ShopDB user-context probe' scheduled task, which runs "
+ "at RunLevel Limited.");
return true;
}
// Age is taken from the stamp the probe wrote, not from the file's last
// write time, because the file can be copied or restored with a roaming
// profile and carry a misleading timestamp. A result with no readable stamp
// is treated as expired: we cannot prove it is current, so we will not claim
// it is.
private bool IsExpired(Dictionary<string, object?> parsed, string resultPath)
{
if (!parsed.TryGetValue("collectedat", out var raw) || raw is null
|| !DateTimeOffset.TryParse(
raw.ToString(),
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind,
out var collectedat))
{
logger.LogDebug(
"The user-context spool file has no usable collectedat stamp; ignoring it.");
return true;
}
var age = DateTimeOffset.UtcNow - collectedat;
if (age <= MaxResultAge)
return false;
logger.LogDebug(
"The user-context spool file is {Hours} hours old, past the {Limit} hour limit.",
Math.Round(age.TotalHours, 1), MaxResultAge.TotalHours);
ClearStaleResult(resultPath);
return true;
}
}

View File

@@ -0,0 +1,148 @@
using ShopDbEndpointAgent.Core;
// Planted-violation suite for LaunchPathPolicy, run on real Windows because the
// cases that matter (junctions, alternate data streams, 8.3 names) are
// filesystem behaviour, not string behaviour. Linux would prove nothing.
//
// Builds its own fixtures under an approved root, so it exercises the same
// shape as a real shop-floor install rather than a synthetic path.
int failures = 0;
void Check(string label, bool expectAccept, string candidate, LaunchPathPolicy policy)
{
var ok = policy.TryValidate(candidate, out var full, out var reason);
var pass = ok == expectAccept;
if (!pass) failures++;
var verdict = pass ? "PASS" : "FAIL";
var got = ok ? $"accepted -> {full}" : $"rejected ({reason})";
Console.WriteLine($"{verdict} {label}");
Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}");
}
var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "LppFixture");
var outside = Path.Combine(Path.GetTempPath(), "LppOutside");
Directory.CreateDirectory(root);
Directory.CreateDirectory(outside);
var policy = new LaunchPathPolicy(new[] { root });
// A real executable inside the approved root.
var good = Path.Combine(root, "good.exe");
File.WriteAllBytes(good, new byte[] { 0x4D, 0x5A });
// The payload the attacker wants run, planted OUTSIDE the approved root.
var payload = Path.Combine(outside, "payload.exe");
File.WriteAllBytes(payload, new byte[] { 0x4D, 0x5A });
// A text file inside the root, to hang an alternate data stream off.
var carrier = Path.Combine(root, "readme.txt");
File.WriteAllText(carrier, "notes");
Console.WriteLine($"approved root : {root}");
Console.WriteLine($"outside dir : {outside}");
Console.WriteLine();
Check("a real exe under the approved root", true, good, policy);
Check("wrong extension", false, carrier, policy);
Check("file that does not exist", false, Path.Combine(root, "nope.exe"), policy);
Check("traversal back out of the root", false,
Path.Combine(root, "..", "..", "Windows", "System32", "cmd.exe"), policy);
Check("UNC path", false, @"\\attacker\share\payload.exe", policy);
Check("sibling directory sharing the root's prefix", false, root + "Evil\\payload.exe", policy);
Check("empty path", false, "", policy);
// --- alternate data stream --------------------------------------------------
try
{
File.WriteAllBytes(carrier + ":payload.exe", new byte[] { 0x4D, 0x5A });
Check("alternate data stream on a file in the root", false,
carrier + ":payload.exe", policy);
}
catch (Exception ex)
{
Console.WriteLine($"SKIP alternate data stream fixture: {ex.Message}");
}
// --- directory junction out of the root ------------------------------------
var junction = Path.Combine(root, "logs");
var mk = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c mklink /J \"{junction}\" \"{outside}\"",
UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true,
});
mk!.WaitForExit();
if (mk.ExitCode == 0)
{
Check("exe reached through a junction pointing out of the root", false,
Path.Combine(junction, "payload.exe"), policy);
}
else
{
Console.WriteLine($"SKIP junction fixture: mklink exit {mk.ExitCode} {mk.StandardError.ReadToEnd().Trim()}");
}
// --- 8.3 short name ---------------------------------------------------------
// 8.3 short names used to be rejected, because the lexical prefix check could
// not match "C:\PROGRA~1" against "C:\Program Files" - fail closed, but only by
// accident of not understanding the path. Resolution now expands it to the real
// directory, which IS under the approved root, so accepting is correct: the
// check that matters runs on the resolved path either way.
var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe");
Check("8.3 short name resolving into the approved root", true, shortRoot, policy);
// --- ScheduledTaskPolicy ----------------------------------------------------
// Pure string logic, unlike LaunchPathPolicy: a task path names an entry in the
// Task Scheduler namespace, not a file, so there is no disk state to plant. The
// cases that matter are the ones where a candidate LOOKS like an approved entry
// as a string but is not the same task.
void CheckTask(string label, bool expectAccept, string candidate, ScheduledTaskPolicy policy)
{
var ok = policy.TryValidate(candidate, out var full, out var reason);
var pass = ok == expectAccept;
if (!pass) failures++;
var verdict = pass ? "PASS" : "FAIL";
var got = ok ? $"accepted -> {full}" : $"rejected ({reason})";
Console.WriteLine($"{verdict} {label}");
Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}");
}
Console.WriteLine();
Console.WriteLine("--- ScheduledTaskPolicy ---");
var taskPolicy = new ScheduledTaskPolicy(new[] { @"\ReportIP", @"\SFLD\" });
CheckTask("exact approved task", true, @"\ReportIP", taskPolicy);
// Task Scheduler is case-insensitive, so the policy must be too, or the same
// task typed differently would be refused.
CheckTask("approved task, different case", true, @"\reportip", taskPolicy);
// The operator should not have to know the rooted form.
CheckTask("approved task without leading separator", true, "ReportIP", taskPolicy);
CheckTask("approved task with forward slashes", true, "/ReportIP", taskPolicy);
// The prefix-suffix confusion this policy exists to prevent: "\ReportIP" must
// NOT authorize "\ReportIPEvil".
CheckTask("sibling task sharing an approved prefix", false, @"\ReportIPEvil", taskPolicy);
CheckTask("unapproved task", false, @"\Evil", taskPolicy);
CheckTask("empty task path", false, "", taskPolicy);
// Folder entries authorize everything beneath them, but not a sibling folder
// whose name merely starts the same way.
CheckTask("task inside an approved folder", true, @"\SFLD\ApplyDSCConfig", taskPolicy);
CheckTask("folder itself", true, @"\SFLD", taskPolicy);
CheckTask("sibling folder sharing the prefix", false, @"\SFLDEvil\Thing", taskPolicy);
// Traversal would let an approved prefix string-match something else entirely.
CheckTask("traversal out of an approved folder", false, @"\SFLD\..\Evil", taskPolicy);
// Defense in depth: the invocation uses ArgumentList, but a task path with
// these characters is not one we should be starting.
CheckTask("argument-injection characters", false, "\\ReportIP\" & calc", taskPolicy);
CheckTask("control character", false, "\\Report\u0000IP", taskPolicy);
// An empty override must fall back to the shipped defaults, never to "allow
// everything".
var defaultPolicy = new ScheduledTaskPolicy(Array.Empty<string>());
CheckTask("shipped default is honored when override is empty", true, @"\ReportIP", defaultPolicy);
CheckTask("empty override does not mean allow-all", false, @"\Evil", defaultPolicy);
Console.WriteLine();
Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET");
return failures == 0 ? 0 : 1;

View File

@@ -0,0 +1,33 @@
# LaunchPathPolicy probe
A planted-violation probe for `LaunchPathPolicy`. It builds real fixtures under
a real approved root - a junction pointing out of it, an alternate data stream,
an 8.3 short name - and asserts what the policy does with each.
## Why it is not a unit test in CI
The cases worth testing are filesystem behaviour, not string behaviour:
junctions, alternate data streams and 8.3 short names do not exist on Linux, and
CI runs on Linux runners. A suite that skipped every meaningful case there would
report green and mean nothing, which is the failure shape this repo has already
been bitten by more than once. So it is a probe you run on Windows, by hand,
when the policy changes.
## Running it
Publish from the dev box and run it in the win11 VM:
dotnet publish agent/ShopDbEndpointAgent.PolicyProbe -c Release -r win-x64 \
--self-contained true -p:PublishSingleFile=true -o /tmp/lpp
Copy `lpp/lpptest.exe` into the VM and run it as an account that may create a
junction under Program Files. Exit code 0 means every expectation held; it
prints one line per case either way.
## What it caught
The first version of the link-resolving hardening recursed to the volume root
and asked whether `C:\` was a link. That throws, and the caller treated a
resolve failure as a rejection - so the policy rejected every legitimate launch
path as well as the malicious ones. It compiled cleanly and read correctly. Only
running it on Windows showed the agent would have failed closed on everything.

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Planted-violation probe for LaunchPathPolicy. See README.md next to this
file: it is run BY HAND on Windows, not by CI, because the cases it
exists to catch are filesystem behaviour and CI runs on Linux.
net8.0, not net8.0-windows: the policy itself only touches System.IO, and
a plain target cross-publishes from the Linux dev box. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>lpptest</AssemblyName>
<RootNamespace>LppTest</RootNamespace>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<Compile Include="../ShopDbEndpointAgent.Core/LaunchPathPolicy.cs" Link="LaunchPathPolicy.cs" />
<!-- ScheduledTaskPolicy is pure string logic with no filesystem state to
plant, so unlike LaunchPathPolicy it would run anywhere. It is linked
here anyway to keep both allowlist policies provable from one harness. -->
<Compile Include="../ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs" Link="ScheduledTaskPolicy.cs" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,45 @@
using System.Threading;
namespace ShopDbEndpointAgent.Tray;
// Interactive user-session tray app (SCAFFOLD).
// headless Windows Service: the service owns telemetry, this owns the operator
// surface - a system-tray icon and the 5-click-max ServiceNow ticket flow
// (DESIGN-DECISIONS-agent.md section 6). This iteration wires the tray and a
// stubbed ticket dialog; the ServiceNow submission (direct vs server-proxied)
// is deferred until that decision is made server-side.
internal static class Program
{
// Machine-wide single-instance guard. The installer starts the tray from an
// HKLM Run value for every user at logon, and an operator can also launch it
// from the Start Menu shortcut, so a second copy is easy to spawn. A named
// mutex in the Global namespace keeps exactly one tray per session: a later
// launch that cannot own the mutex exits immediately instead of stacking a
// duplicate icon. Global\ scopes across sessions on the same machine.
private const string SingleInstanceMutexName = @"Global\ShopDbEndpointAgentTray";
[STAThread]
private static void Main(string[] args)
{
// Headless icon export for the installer: `--export-icon <path>` writes
// the branded .ico and exits without starting the tray, so install.ps1
// can point the Start Menu shortcut at a real brand icon. Kept in this
// exe so the icon has a single source (TrayIcon) and no binary is
// checked into the repo.
if (args.Length >= 2 &&
string.Equals(args[0], "--export-icon", StringComparison.OrdinalIgnoreCase))
{
TrayIcon.Export(args[1]);
return;
}
using var singleInstance = new Mutex(true, SingleInstanceMutexName, out bool owned);
if (!owned)
{
return;
}
ApplicationConfiguration.Initialize();
Application.Run(new TrayContext());
}
}

View File

@@ -0,0 +1,34 @@
using System.Drawing.Imaging;
namespace ShopDbEndpointAgent.Tray;
// Captures the full virtual desktop (all monitors) to a PNG on disk, porting
// the operator PowerShell proof of concept:
// CopyFromScreen over SystemInformation.VirtualScreen, then Bitmap.Save as
// PNG. Kept in the tray project because only the interactive tray session can
// see the operator's desktop; the headless service runs in session 0 and
// would capture a black frame.
internal static class ScreenCapture
{
// Grab every monitor in one shot. VirtualScreen spans the whole desktop so
// a multi-head shop-floor PC is captured intact rather than just the
// primary display. Returns the saved file path.
public static string CaptureToPng(string filePath)
{
var bounds = SystemInformation.VirtualScreen;
using var bitmap = new Bitmap(bounds.Width, bounds.Height,
PixelFormat.Format32bppArgb);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0,
bounds.Size, CopyPixelOperation.SourceCopy);
}
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
bitmap.Save(filePath, ImageFormat.Png);
return filePath;
}
}

View File

@@ -0,0 +1,88 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>ShopDbEndpointAgent.Tray</RootNamespace>
<AssemblyName>shopdb-endpoint-tray</AssemblyName>
<!-- 1.1.0: the tray now collects session-scoped user context. It invokes
shopdbagent.exe with the collect-usercontext verb on a timer (once
shortly after logon, then periodically) and the agent service reads the
resulting spool file. The tray owns this because it is the only ShopDB
process already running as the operator: mapped drives, user-owned
listeners, and input idle time do not exist in the service's session 0.
Requires agent 1.8.0 or newer: an older agent does not recognize the
verb and falls through to starting a full host, so the tray and agent
must be deployed together. -->
<!-- 1.0.0: first explicitly stamped tray version. The tray and the agent
service version independently (they are separate exes and can be pushed
apart), so the tray carries its own <Version> and the agent reports it
as trayversion alongside its own agentversion (AGENT-CONTRACT.md 2). -->
<!-- 1.1.1: the tray now logs. Every user-context failure path was silent -
a missing shopdbagent.exe returned bare, and the probe launch was
wrapped in an empty catch - so "no mapped drives" produced no evidence
anywhere on the machine. The tray writes
C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log (the operator's
own profile; it cannot write to ProgramData or Program Files) covering
tray startup, agent-exe resolution, probe launch and probe exit code. -->
<!-- 1.2.0: the tray stops claiming success it did not verify. The probe
completion message printed a hardcoded spool path regardless of what
happened, so a probe that exited 0 without writing anything read as a
clean collection; it now reports the real pinned path and distinguishes
spool-present, exit-0-but-no-file, and non-zero exit. Startup probe
delay is chosen from session uptime: the 2-minute delay still applies at
logon (drive mappings are not established yet), but a tray started into
an already-running session - which is what an installer does - probes
after 15 seconds instead of leaving a fresh install blind for the full
30-minute cycle. A log sink that cannot open its directory raises a
one-time balloon, since it cannot report its own failure through the
log. Requires agent 1.9.0: the two carry the shared pinned spool path
and must be deployed together. -->
<!-- 1.3.0: the tray is no longer the authoritative user-context collector.
The 'ShopDB user-context probe' scheduled task now owns collection,
because it is the only mechanism that runs in the operator's own logon
session at RunLevel Limited; the tray keeps its own probe cadence as
belt-and-braces and writes the same spool. The installer no longer
starts the tray directly (explorer.exe de-elevated it but did not
change ACCOUNT, so an admin install produced an admin tray whose
probe saw the wrong drive table). Autostart at logon is the Run key;
install-time startup on a machine someone is already signed in to is
a transient 'ShopDB tray first launch' scheduled task, which is the
only way to enter the operator's existing session unelevated. Probe
exit code 2 - the agent refusing to collect while elevated - is
reported by name instead of as a bare code. Requires agent 1.10.1. -->
<!-- 1.4.0: the tray answers on-demand refresh requests. A background
listener waits on Global\ShopDbEndpointAgent.UserContextRefresh and
runs the probe when the service asks, which is what makes a WebUI
"Refresh Status" click produce current mapped drives instead of the
last cadence's answer - the service cannot collect this itself at any
price, since drive letters live on the operator's logon token, so the
request has to be serviced HERE. Each probe is marshalled onto the UI
thread: the cadence timer and a service request could otherwise both
pass the in-flight guard and race the same spool file. "Send Status"
now awaits a probe before invoking the once verb, because that verb
reads the spool and would otherwise publish the previous probe's
answer to an operator who just changed something and is watching
for it. The probe
body is one shared method used by all three callers rather than three
copies of the exit-code handling. A tray that cannot open the channel
logs and keeps its timer, so the failure mode is "refresh is not
immediate", not "session data stops working". Requires agent 1.11.0. -->
<Version>1.4.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ShopDbEndpointAgent.Core\ShopDbEndpointAgent.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,110 @@
namespace ShopDbEndpointAgent.Tray;
// The operator issue reporter. Collects a free-text description and an optional
// screenshot request, then hands the result back to TrayContext which performs
// the capture (after this dialog closes, so it never appears in the shot) and
// the upload via the service binary.
//
// Only "Other (free text)" is offered today: the fixed-reason routing from the
// original design is not wired to a ticketing backend, so a single free-text
// path is the one flow that works end to end. The dropdown is kept (disabled,
// preselected) so the richer reason list can return without reworking the form.
internal sealed class TicketForm : Form
{
// Only the free-text reason is functional. Kept as a one-item list so the
// control and its layout stay in place for the future reason routing.
private static readonly string[] Reasons =
{
"Other (free text)",
};
private readonly ComboBox reasonBox;
private readonly TextBox detailBox;
private readonly CheckBox screenshotBox;
// Collected result, read by TrayContext after DialogResult.OK.
public string Reason => reasonBox.SelectedItem?.ToString() ?? "Other (free text)";
public string Details => detailBox.Text?.Trim() ?? string.Empty;
public bool IncludeScreenshot => screenshotBox.Checked;
public TicketForm()
{
Text = "Report an issue";
FormBorderStyle = FormBorderStyle.FixedDialog;
StartPosition = FormStartPosition.CenterScreen;
MinimizeBox = false;
MaximizeBox = false;
ClientSize = new Size(420, 260);
var reasonLabel = new Label
{
Text = "What is happening?",
Left = 16,
Top = 16,
Width = 388,
};
reasonBox = new ComboBox
{
Left = 16,
Top = 40,
Width = 388,
DropDownStyle = ComboBoxStyle.DropDownList,
// Disabled until the fixed-reason routing exists; free text is the
// only functional path, so the selection is fixed on it.
Enabled = false,
};
reasonBox.Items.AddRange(Reasons);
reasonBox.SelectedIndex = 0;
var detailLabel = new Label
{
Text = "Details",
Left = 16,
Top = 76,
Width = 388,
};
detailBox = new TextBox
{
Left = 16,
Top = 100,
Width = 388,
Height = 80,
Multiline = true,
};
screenshotBox = new CheckBox
{
Text = "Attach a screenshot of my screen",
Left = 16,
Top = 188,
Width = 388,
Checked = true,
};
var submit = new Button
{
Text = "Submit",
Left = 244,
Top = 220,
Width = 76,
DialogResult = DialogResult.OK,
};
var cancel = new Button
{
Text = "Cancel",
Left = 328,
Top = 220,
Width = 76,
DialogResult = DialogResult.Cancel,
};
Controls.AddRange(new Control[]
{
reasonLabel, reasonBox, detailLabel, detailBox, screenshotBox,
submit, cancel,
});
AcceptButton = submit;
CancelButton = cancel;
}
}

View File

@@ -0,0 +1,663 @@
using System.Diagnostics;
using System.Reflection;
using Microsoft.Extensions.Logging;
using ShopDbEndpointAgent.Core;
namespace ShopDbEndpointAgent.Tray;
// Owns the tray icon and its context menu for the app's lifetime. Kept minimal
// and headless-friendly: no main window, the icon is the whole UI surface until
// the operator opens the ticket dialog.
//
// "Send Status now" reuses the exact production collection path by shelling out
// to the installed shopdbagent.exe with --once. That keeps the collector key and
// all collection logic in the service binary: the tray never holds a secret and
// never duplicates wiring, it just asks the agent to report on demand so the
// ShopDB monitor page reflects the box immediately instead of at next cadence.
internal sealed class TrayContext : ApplicationContext
{
// The headless agent/service executable. The tray ships next to it in the
// install directory, so same-dir resolution is the primary path.
private const string AgentExeName = "shopdbagent.exe";
private const string DefaultInstallDir = @"C:\Program Files\ShopDB\EndpointAgent";
private readonly NotifyIcon trayIcon;
private readonly Icon brandIcon;
private bool sending;
// Session-scoped collection. The service is LocalSystem and cannot see the
// operator's mapped drives, user-owned listeners, or input idle time, so the
// tray - which is already the operator - collects them and leaves the result
// in a spool file the service reads on its own cadence.
//
// Deliberately longer than the telemetry cadence: this data changes rarely
// (a mapped drive is mapped at logon and stays mapped), and each run spawns
// a short PowerShell process on a machine an operator is actively using.
private static readonly TimeSpan UserContextInterval = TimeSpan.FromMinutes(30);
// Logon is the worst moment to add work: drive mappings and profile setup
// are still settling, and a probe fired now would capture an incomplete
// picture while competing with everything else starting up.
private static readonly TimeSpan UserContextStartupDelay = TimeSpan.FromMinutes(2);
// ...but that reasoning only applies at logon. When the tray starts into a
// session that has been up for a while - which is what an installer restart
// or a manual relaunch does - nothing is settling and the full delay just
// extends the window where the server has no session data. The installer
// kills and restarts the tray on every install, so the 2-minute delay was
// being paid at exactly the moment someone was watching the card to see
// whether the install had worked.
private static readonly TimeSpan UserContextEstablishedDelay = TimeSpan.FromSeconds(15);
// How long the session must have been up to count as established. Comfortably
// past the logon storm, well short of any realistic install-to-check gap.
private static readonly TimeSpan EstablishedSessionUptime = TimeSpan.FromMinutes(5);
private readonly System.Windows.Forms.Timer userContextTimer;
private bool userContextStarted;
private bool collectingUserContext;
// Lets the service ask this tray to re-probe on demand, so a "Refresh
// Status" click in the WebUI produces genuinely current session data instead
// of whatever the last 30-minute tick happened to capture. The service
// cannot collect this itself at any price - mapped drives live on the
// operator's logon token - so the only way for an on-demand refresh to be
// accurate is for the request to end up here, in the operator's session.
private readonly UserContextRefreshChannel? refreshChannel;
private readonly CancellationTokenSource refreshCancellation = new();
// How long each wait blocks before looping. The wait returns false on
// timeout and the loop simply waits again, so this is not a deadline for
// anything - it just bounds how long the handle stays open, which lets a
// channel that was somehow broken get re-opened on the next pass instead of
// leaving the tray deaf until it restarts.
private static readonly TimeSpan RefreshWaitTimeout = TimeSpan.FromMinutes(5);
// The UI thread's context, captured in the constructor. The listener runs on
// a background thread, but every probe attempt is marshalled back here so
// that collectingUserContext is only ever read or written from one thread.
// Without this the timer tick and a service request could both pass the
// guard and launch two probes at the same spool file.
private readonly SynchronizationContext? uiContext;
// The tray logs into the operator's own profile
// (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log), NOT into the
// service's ProgramData directory. The operator cannot write there, and the
// file sink fails silently when it cannot create its directory, so tray-side
// failures previously left no trace anywhere. Keeping the two apart means a
// tray problem is diagnosable from a file the operator can actually reach.
private readonly FileLoggerProvider logProvider;
private readonly ILogger logger;
public TrayContext()
{
logProvider = new FileLoggerProvider(
FileLoggerProvider.UserProfileLogDirectory(),
filePrefix: "trayagent");
logger = logProvider.CreateLogger("Tray");
var startupDelay = ResolveStartupDelay();
logger.LogInformation(
"Tray {Version} started; user-context probe in {Delay}, then every {Interval}.",
typeof(TrayContext).Assembly.GetName().Version?.ToString() ?? "unknown",
startupDelay, UserContextInterval);
var menu = new ContextMenuStrip();
menu.Items.Add("Send Status now", null, OnSendStatus);
menu.Items.Add("Report an issue...", null, OnReportIssue);
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("About ShopDB agent", null, OnAbout);
menu.Items.Add("Exit", null, OnExit);
// Branded tray icon drawn from the ShopDB WebUI favicon (see TrayIcon).
brandIcon = TrayIcon.Create();
trayIcon = new NotifyIcon
{
Icon = brandIcon,
Text = "ShopDB Endpoint Agent",
Visible = true,
ContextMenuStrip = menu,
};
trayIcon.DoubleClick += OnSendStatus;
// A dead log sink cannot report itself through the log. Complaining via
// the logger would write the one message that explains the silence into
// the sink that is swallowing messages, so the balloon is the only
// channel that survives. Shown once at startup and never repeated: this
// is a "your diagnostics are blind" notice for whoever is standing at the
// machine, not a recurring nag about something the operator cannot fix.
if (logProvider.SinkProblem is not null)
{
trayIcon.ShowBalloonTip(
10000,
"ShopDB Endpoint Agent",
"Tray logging is unavailable, so tray diagnostics will not be "
+ $"recorded. {logProvider.SinkProblem}",
ToolTipIcon.Warning);
}
userContextTimer = new System.Windows.Forms.Timer
{
Interval = (int)startupDelay.TotalMilliseconds,
};
userContextTimer.Tick += OnUserContextTimer;
userContextTimer.Start();
uiContext = SynchronizationContext.Current;
// Best-effort. A tray that cannot open the channel still probes on its
// timer, so the failure mode is "refresh is not immediate", not "session
// data stops working" - which is why this is caught and logged rather
// than allowed to take the tray down at startup.
try
{
refreshChannel = new UserContextRefreshChannel(
logProvider.CreateLogger(nameof(UserContextRefreshChannel)));
StartRefreshListener();
}
catch (Exception exception)
{
logger.LogWarning(exception,
"Could not open the user-context refresh channel; on-demand "
+ "refresh requests from the service will be ignored and session "
+ "data will only update on the {Interval} timer.",
UserContextInterval);
}
}
// Waits for service refresh requests for the life of the tray.
//
// A dedicated background thread rather than a Task: this blocks on a kernel
// wait handle essentially forever, which is exactly the workload that should
// not sit on a thread-pool thread. IsBackground keeps it from holding the
// process open at exit.
private void StartRefreshListener()
{
var thread = new Thread(() =>
{
var token = refreshCancellation.Token;
while (!token.IsCancellationRequested)
{
try
{
if (!refreshChannel!.WaitForRequest(RefreshWaitTimeout, token))
continue;
logger.LogInformation(
"The service requested a user-context refresh.");
// Marshalled, not awaited: the service is not waiting on a
// reply through this channel, it is watching the spool file's
// collectedat stamp. Our only job is to get the probe started
// on the UI thread and go back to waiting.
var post = uiContext;
if (post is not null)
post.Post(_ => _ = CollectUserContextAsync(), null);
else
_ = CollectUserContextAsync();
}
catch (OperationCanceledException)
{
return;
}
catch (Exception exception)
{
// Never let a bad wait kill the listener - if this thread
// exits, on-demand refresh is silently dead for the rest of
// the session with no other symptom.
logger.LogWarning(exception,
"The user-context refresh listener failed; retrying.");
try
{
Thread.Sleep(TimeSpan.FromSeconds(5));
}
catch (Exception)
{
return;
}
}
}
})
{
IsBackground = true,
Name = "ShopDB user-context refresh listener",
};
thread.Start();
}
// Fires once after the startup delay, then re-arms at the steady-state
// interval. Failures are swallowed on purpose: session data is a
// nice-to-have, and an operator must never see a dialog or a crashed tray
// because a probe could not read a drive mapping.
private async void OnUserContextTimer(object? sender, EventArgs e)
{
if (!userContextStarted)
{
userContextStarted = true;
userContextTimer.Interval = (int)UserContextInterval.TotalMilliseconds;
}
await CollectUserContextAsync();
}
// Runs the probe once and returns when the spool has been rewritten.
//
// Shared by all three callers - the cadence timer, an on-demand service
// request, and "Send Status now" - so there is exactly one place that knows
// how to invoke the probe and how to interpret its exit codes. Returns true
// only when a spool file is actually on disk afterwards, which is what lets
// Send Status decide whether it is worth waiting on.
private async Task<bool> CollectUserContextAsync()
{
if (collectingUserContext)
{
// Another probe is already in flight against the same spool path.
// Its result is at most a second or two old and satisfies whatever
// this caller wanted, so coalescing is correct here, not a shortcut.
logger.LogDebug(
"A user-context probe is already running; skipping this request.");
return false;
}
var agentExe = ResolveAgentExe();
if (agentExe is null)
{
// Previously a bare return. This is the single most likely reason for
// "no mapped drives" with nothing at all in any log, so it must say so.
logger.LogWarning(
"Skipping the user-context probe: {Agent} was not found beside the tray "
+ "({Beside}) or at {Installed}.",
AgentExeName, AppContext.BaseDirectory, DefaultInstallDir);
return false;
}
collectingUserContext = true;
try
{
logger.LogInformation("Running the user-context probe: {Agent}", agentExe);
var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext");
// Check the disk rather than announcing what the exit code implies.
// This line used to print a hardcoded path whenever the child exited
// 0, so it reported "spool written to C:\Users\ShopFloor\ShopDB" for
// weeks while the probe was actually writing into a different profile
// and the service was finding nothing.
var resultPath = UserContextSpool.PinnedResultPath();
if (exitCode == 0 && File.Exists(resultPath))
{
logger.LogInformation(
"The user-context probe finished; spool present at {Spool}.",
resultPath);
return true;
}
else if (exitCode == 0)
{
logger.LogWarning(
"The user-context probe reported success but no spool file is "
+ "present at {Spool}.", resultPath);
}
else if (exitCode == 2)
{
// The agent refused because it was elevated. Called out by name
// because as a bare exit code it reads like a probe fault and
// sends the reader looking for a drive or WMI problem that is
// not there - the fix is to start the tray unelevated, or let
// the scheduled task do the collecting.
logger.LogWarning(
"The user-context probe refused to run because this tray is "
+ "ELEVATED. Mapped drives are per logon token, so an elevated "
+ "probe would report the wrong drive table. Start the tray "
+ "unelevated, or rely on the 'ShopDB user-context probe' "
+ "scheduled task.");
}
else
{
logger.LogWarning(
"The user-context probe exited with code {ExitCode}; expected a "
+ "spool at {Spool}.", exitCode, resultPath);
}
return false;
}
catch (Exception exception)
{
// No longer silent. The agent logs its own detail, but if the process
// could not even be started the agent never ran and never logged.
logger.LogWarning(exception, "Could not run the user-context probe.");
return false;
}
finally
{
collectingUserContext = false;
}
}
private async void OnReportIssue(object? sender, EventArgs e)
{
string reason;
string details;
bool includeScreenshot;
using (var dialog = new TicketForm())
{
if (dialog.ShowDialog() != DialogResult.OK)
return;
reason = dialog.Reason;
details = dialog.Details;
includeScreenshot = dialog.IncludeScreenshot;
}
var agentExe = ResolveAgentExe();
if (agentExe is null)
{
ShowBalloon("Report an issue", "Could not find " + AgentExeName +
". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error);
return;
}
// Capture AFTER the dialog closes so the report form is never in the
// shot. Stage into the interactive operator's own profile
// (%USERPROFILE%\ShopDBScreenshots), NOT %TEMP%: on this locked-down
// image the tray process's TEMP resolves to a different account's
// AppData that ShopFloor cannot write, and AppData/Temp are aggressively
// ACL'd/redirected. The profile root is owned by the operator and, by
// inherited ACL, readable by SYSTEM (the agent service) so the handoff
// works. Files are kept for the operator to show a DT/specialist later;
// pruning is the agent's future server-driven Capture Cleanup job.
var storeDir = OperatorReportStoreDir();
var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
string? payloadPath = null;
try
{
Directory.CreateDirectory(storeDir);
string? screenshotPath = null;
if (includeScreenshot)
{
screenshotPath = Path.Combine(storeDir, "screenshot-" + stamp + ".png");
ScreenCapture.CaptureToPng(screenshotPath);
}
payloadPath = Path.Combine(storeDir, "report-" + stamp + ".json");
OperatorReportPayload.Write(payloadPath, reason, details, screenshotPath);
ShowBalloon("Report an issue", "Sending your report to ShopDB...",
ToolTipIcon.Info);
int exitCode = await RunAgentAsync(agentExe, "--report \"" + payloadPath + "\"");
if (exitCode == 0)
ShowBalloon("Report an issue", "Your report was sent to ShopDB.",
ToolTipIcon.Info);
else
ShowBalloon("Report an issue",
"Report send failed (exit " + exitCode +
"). See the agent log under ProgramData\\ShopDB.",
ToolTipIcon.Warning);
}
catch (Exception error)
{
ShowBalloon("Report an issue", "Could not send the report: " +
error.Message, ToolTipIcon.Error);
}
finally
{
// Only the transient JSON sidecar is removed; the screenshot stays
// for the operator. The agent also deletes the sidecar on success,
// so this just covers the failure path.
if (payloadPath is not null)
TryDeleteFile(payloadPath);
}
}
// The operator-visible folder screenshots and the report sidecar are staged
// into, under the interactive user's profile. Resolved in the tray (running
// as that user) and passed to the agent as an absolute path; the agent must
// never re-derive it, since as SYSTEM its profile would resolve elsewhere.
private static string OperatorReportStoreDir()
=> Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"ShopDBScreenshots");
private static void TryDeleteFile(string filePath)
{
try
{
if (File.Exists(filePath))
File.Delete(filePath);
}
catch
{
// Best-effort; a leftover sidecar is harmless and the agent also
// removes it on a successful upload.
}
}
// Runs shopdbagent.exe --once out of process so the operator can push a
// fresh report without waiting for the next cadence tick. The exit code is
// the agent's own contract: 0 = reported OK, non-zero = failed (bad key,
// host removed, network) and the detail is in the rolling agent log.
private async void OnSendStatus(object? sender, EventArgs e)
{
if (sending)
return;
var agentExe = ResolveAgentExe();
if (agentExe is null)
{
ShowBalloon("Send Status", "Could not find " + AgentExeName +
". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error);
return;
}
sending = true;
trayIcon.Text = "ShopDB Endpoint Agent (sending status...)";
ShowBalloon("Send Status", "Sending this PC's status to ShopDB...",
ToolTipIcon.Info);
try
{
// Probe BEFORE reporting, and await it. The operator clicking this
// has just changed something - remapped a drive, signed in - and
// expects the result to show up. --once reads the spool file, so a
// probe started after it, or not awaited, would publish the previous
// probe's answer and the operator would click again and see the same
// stale card.
//
// Not fatal if it fails: a report with older session data still
// carries the heartbeat, inventory and events, which is most of what
// this button is for.
await CollectUserContextAsync();
int exitCode = await RunAgentAsync(agentExe, "--once");
if (exitCode == 0)
ShowBalloon("Send Status", "Status sent to ShopDB.",
ToolTipIcon.Info);
else
ShowBalloon("Send Status",
"Status send failed (exit " + exitCode +
"). See the agent log under ProgramData\\ShopDB.",
ToolTipIcon.Warning);
}
catch (Exception error)
{
ShowBalloon("Send Status", "Could not run the agent: " +
error.Message, ToolTipIcon.Error);
}
finally
{
sending = false;
trayIcon.Text = "ShopDB Endpoint Agent";
}
}
private static Task<int> RunAgentAsync(string agentExe, string arguments)
{
var startInfo = new ProcessStartInfo
{
FileName = agentExe,
Arguments = arguments,
WorkingDirectory = Path.GetDirectoryName(agentExe) ?? Environment.CurrentDirectory,
UseShellExecute = false,
CreateNoWindow = true,
};
// The tray and the agent are both single-file, self-contained apps. When
// the tray host started, the .NET single-file bootstrapper set DOTNET_*
// and bundle-extraction variables in this process's environment. With
// UseShellExecute=false the child inherits those variables, so the
// freshly installed agent tries to bootstrap against the tray's stale
// extraction state and dies during host startup (exit 0xE0434352) before
// any managed code - or the file log - runs. Stripping these inherited
// markers gives the child the same clean host environment it gets when
// launched from a shell, and it also removes the need to restart the tray
// after every agent rebuild. The agent re-derives whatever it needs.
ScrubInheritedDotnetHostVariables(startInfo);
var completion = new TaskCompletionSource<int>();
var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
process.Exited += (_, _) =>
{
completion.TrySetResult(process.ExitCode);
process.Dispose();
};
if (!process.Start())
{
process.Dispose();
completion.TrySetException(
new InvalidOperationException("failed to start " + agentExe));
}
return completion.Task;
}
// Remove the .NET host / single-file bundle markers this tray process
// inherited at startup so a spawned single-file child bootstraps cleanly
// instead of adopting the tray's extraction state. Names are matched by a
// DOTNET_/COREHOST_ prefix (covers DOTNET_BUNDLE_EXTRACT_BASE_DIR,
// DOTNET_ROOT, the internal startup hooks, etc.) so this stays correct as
// the runtime adds new host variables.
private static void ScrubInheritedDotnetHostVariables(ProcessStartInfo startInfo)
{
var toRemove = startInfo.Environment.Keys
.Where(key =>
key.StartsWith("DOTNET_", StringComparison.OrdinalIgnoreCase) ||
key.StartsWith("COREHOST_", StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var key in toRemove)
startInfo.Environment.Remove(key);
}
// Full delay at logon, short delay when the session is already established.
//
// Environment.TickCount64 is machine uptime, not session uptime, which is a
// deliberate simplification: the two differ only when someone signs out and
// back in without rebooting, and treating that as "established" is harmless -
// the profile is already built and the drives are already mapped in that
// case too. A failure to read it falls back to the conservative long delay.
private TimeSpan ResolveStartupDelay()
{
try
{
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
if (uptime >= EstablishedSessionUptime)
{
logger.LogInformation(
"Session has been up {Uptime}; using the established-session probe delay.",
uptime);
return UserContextEstablishedDelay;
}
}
catch (Exception exception)
{
logger.LogDebug(exception,
"Could not read system uptime; using the logon probe delay.");
}
return UserContextStartupDelay;
}
// Same directory first (the installer ships the tray next to the agent),
// then the known Program Files install directory as a fallback.
private static string? ResolveAgentExe()
{
var beside = Path.Combine(AppContext.BaseDirectory, AgentExeName);
if (File.Exists(beside))
return beside;
var installed = Path.Combine(DefaultInstallDir, AgentExeName);
if (File.Exists(installed))
return installed;
return null;
}
private void ShowBalloon(string title, string text, ToolTipIcon icon)
{
trayIcon.BalloonTipTitle = title;
trayIcon.BalloonTipText = text;
trayIcon.BalloonTipIcon = icon;
trayIcon.ShowBalloonTip(4000);
}
private void OnAbout(object? sender, EventArgs e)
=> MessageBox.Show(
"ShopDB Endpoint Agent tray.\n" +
"Telemetry runs as the ShopDB Endpoint Agent Windows service.\n" +
"Send Status now reports this PC to ShopDB on demand.\n\n" +
"Tray version: " + TrayVersion() + "\n" +
"Agent version: " + AgentVersion(),
"About", MessageBoxButtons.OK, MessageBoxIcon.Information);
// The tray's own assembly version, the same value stamped from the csproj
// <Version> at build time.
private static string TrayVersion()
=> Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
// The installed agent service exe's file version, read from wherever the
// tray resolves it (beside the tray, then the default install dir). The
// service and tray can legitimately be different versions, so this is shown
// as its own line rather than assumed equal to the tray. Degrades to a
// clear note when the agent exe is not found or its version is unreadable.
private static string AgentVersion()
{
var agentExe = ResolveAgentExe();
if (agentExe is null)
return "not found";
try
{
return FileVersionInfo.GetVersionInfo(agentExe).FileVersion ?? "unknown";
}
catch (FileNotFoundException)
{
return "not found";
}
}
private void OnExit(object? sender, EventArgs e)
{
trayIcon.Visible = false;
ExitThread();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
userContextTimer.Stop();
userContextTimer.Dispose();
// Cancel and let the listener unwind on its own. The channel opens
// and closes its handle inside each wait rather than holding one, so
// there is nothing here to dispose.
refreshCancellation.Cancel();
refreshCancellation.Dispose();
trayIcon.Dispose();
brandIcon.Dispose();
}
base.Dispose(disposing);
}
}

View File

@@ -0,0 +1,97 @@
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
namespace ShopDbEndpointAgent.Tray;
// Builds the tray icon from the ShopDB WebUI favicon (frontend/public/favicon.svg:
// a #4181ff rounded square with a white "S") by drawing it with GDI+ at runtime.
// Drawing it here rather than shipping a binary .ico keeps the brand in one
// place (these values mirror the SVG) and avoids adding an SVG rasterizer or a
// checked-in binary to the build.
internal static class TrayIcon
{
// Favicon colors (frontend/public/favicon.svg).
private static readonly Color Background = ColorTranslator.FromHtml("#4181ff");
private static readonly Color Foreground = Color.White;
// Renders the branded icon at a tray-friendly size. 32px covers standard and
// most high-DPI tray scaling; the OS downsamples as needed.
public static Icon Create(int size = 32)
{
using var bitmap = new Bitmap(size, size, PixelFormat.Format32bppArgb);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.SmoothingMode = SmoothingMode.AntiAlias;
graphics.TextRenderingHint =
System.Drawing.Text.TextRenderingHint.AntiAlias;
graphics.Clear(Color.Transparent);
// Rounded square (SVG rect rx=14 on a 64 viewport -> ~0.22 radius).
float inset = size * 0.03f;
var rect = new RectangleF(inset, inset,
size - inset * 2, size - inset * 2);
float radius = size * 0.22f;
using (var path = RoundedRect(rect, radius))
using (var fill = new SolidBrush(Background))
graphics.FillPath(fill, path);
// White "S", centered.
using var font = new Font("Arial", size * 0.58f,
FontStyle.Bold, GraphicsUnit.Pixel);
using var text = new SolidBrush(Foreground);
using var format = new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center,
};
graphics.DrawString("S", font, text, rect, format);
}
// Icon.FromHandle borrows the GDI HICON, so clone into a managed Icon
// and free the handle to avoid leaking one per launch.
IntPtr hicon = bitmap.GetHicon();
try
{
using var borrowed = Icon.FromHandle(hicon);
return (Icon)borrowed.Clone();
}
finally
{
DestroyIcon(hicon);
}
}
// Writes the branded icon to an .ico file so a Start Menu / desktop shortcut
// can show the brand instead of the generic exe icon. Reuses the same GDI+
// drawing as the tray so there is still one source of brand truth and no
// checked-in binary. 64px suits shortcut/large-icon views; the OS downscales.
public static void Export(string path, int size = 64)
{
using var icon = Create(size);
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
using var stream = new FileStream(path, FileMode.Create, FileAccess.Write);
icon.Save(stream);
}
private static GraphicsPath RoundedRect(RectangleF rect, float radius)
{
float diameter = radius * 2;
var path = new GraphicsPath();
path.AddArc(rect.Left, rect.Top, diameter, diameter, 180, 90);
path.AddArc(rect.Right - diameter, rect.Top, diameter, diameter, 270, 90);
path.AddArc(rect.Right - diameter, rect.Bottom - diameter, diameter,
diameter, 0, 90);
path.AddArc(rect.Left, rect.Bottom - diameter, diameter, diameter, 90, 90);
path.CloseFigure();
return path;
}
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyIcon(IntPtr handle);
}

View File

@@ -0,0 +1,8 @@
<Solution>
<Project Path="ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj" />
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
<Project Path="ShopDbAdoptionTool/ShopDbAdoptionTool.csproj" />
<!-- Run by hand on Windows, never by CI. See its README. -->
<Project Path="ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj" />
</Solution>

View File

@@ -0,0 +1,376 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ShopDbEndpointAgent.Core;
// Headless Windows Service host for the ShopDB endpoint agent. Composes the
// Core runtime (config -> collector -> client -> worker) behind the generic
// host, adds durable file logging and a WER-style fault monitor, and supports:
// (default) run as a Windows Service (AddWindowsService)
// --console run interactively in the foreground for debugging
// --once collect and report exactly once, then exit (smoke test / cron)
// --report <path> upload one operator issue report (JSON sidecar written by
// the tray), then exit. Keeps the collector key in the
// service so the tray never holds a secret.
//
// Secrets are never read here: AgentConfigProvider layers the registry
// (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret
// appsettings.json. The executable is intentionally named shopdbagent.exe so it
// never collides with third-party agent.exe processes/services (for example the
// MTConnect agent this same build can monitor).
var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase));
var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase));
var collectUserContext = args.Any(a =>
string.Equals(a, "--collect-usercontext", StringComparison.OrdinalIgnoreCase));
var reportPayloadPath = ArgValue(args, "--report");
var agentVersion = Assembly.GetExecutingAssembly()
.GetName().Version?.ToString() ?? "0.0.0";
// FIRST, ahead of everything else. This verb runs as the signed-in operator (the
// tray invokes it) and must not touch a single privileged resource on the way in.
//
// It used to sit below the bootstrap logger, the fault monitor, and the config
// load, which meant the operator-context process opened the SYSTEM-owned
// ProgramData log directory, attempted an EventLog source registration that only
// an administrator can perform, and read CollectorKey out of HKLM - all before it
// discovered it was only being asked to write a file into the user's own profile.
// On an EPM-managed host (CyberArk) those are exactly the calls a broker
// intercepts, and the registry read put the collector secret in a process that
// has no business holding it.
//
// It also has no config dependency by design: it only writes into the caller's
// own profile and never talks to the server, so it must still work on a machine
// whose CollectorKey has not been provisioned yet - otherwise session data would
// be missing on precisely the endpoints that are hardest to diagnose.
if (collectUserContext)
{
// First statement in this branch. The scheduled task runs this exe directly
// in the operator's interactive session, and shopdbagent.exe is a console
// app, so without this a black window flashes up on every collection cycle.
// Done before any logging or probing so the window is gone immediately
// rather than lingering for the life of the probe.
ConsoleWindow.Hide();
// Logs to the OPERATOR's profile, not the service's ProgramData directory.
// A non-privileged user cannot write to the SYSTEM-owned log directory, and
// the file sink degrades to a no-op when it cannot create its directory, so
// pointing this at the service location meant every probe failure was
// discarded unread - which is exactly how a broken probe looked identical to
// a probe that was never invoked.
using var probeLogProvider = new FileLoggerProvider(
FileLoggerProvider.UserProfileLogDirectory(),
filePrefix: "trayagent");
using var probeLoggerFactory = LoggerFactory.Create(
logging => logging.AddProvider(probeLogProvider));
var probeLogger = probeLoggerFactory.CreateLogger<UserContextProbe>();
ReportSinkProblem(probeLogProvider, "user-context probe");
// Refuse to collect from an elevated process. Drive letters are a property
// of the logon token, not the machine: an elevated process gets a different
// token to the operator's interactive one and enumerates a DIFFERENT, almost
// always empty, drive table. Measured on a live endpoint, the same two
// shares reported Unavailable from the elevated shell and OK from the
// operator's, in both Win32_NetworkConnection and Get-SmbMapping.
//
// Refusing here rather than filtering later is deliberate. An elevated probe
// does not fail - it succeeds and reports every mapped drive as broken,
// which is indistinguishable from a genuine outage at the far end. Better to
// write nothing and say why than to publish a confident wrong answer.
if (IsProcessElevated())
{
probeLogger.LogError(
"Refusing to collect user context: this process is elevated. Mapped drives " +
"are per-logon-token, so an elevated probe reports the wrong drive table. " +
"Run the '{Task}' scheduled task (RunLevel Limited) instead of invoking " +
"--collect-usercontext from an admin shell.",
"ShopDB user-context probe");
Console.Error.WriteLine(
"shopdbagent: refusing to collect user context from an elevated process; " +
"mapped drives would be wrong. Use the scheduled task.");
return 2;
}
var probe = new UserContextProbe(probeLogger);
return probe.Collect() ? 0 : 1;
}
// True when the current process holds the elevated (high-integrity) token.
static bool IsProcessElevated()
{
if (!OperatingSystem.IsWindows())
{
return false;
}
try
{
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity);
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
}
catch
{
// Cannot tell. Treat as not elevated so an unreadable token does not
// suppress collection on a host where the probe would have worked; the
// spool's own account stamp is still checked on the way back in.
return false;
}
}
// Bootstrap logger first so config loading (including the registry read that
// CyberArk/EPM may gate) is fully diagnosable before the host starts.
using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory());
var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup");
ReportSinkProblem(bootstrapFileLogger, "service");
var faultMonitor = new AgentFaultMonitor(bootstrapLogger);
faultMonitor.Install();
var config = AgentConfigProvider.Load(logger: bootstrapLogger);
var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
? FileLoggerProvider.DefaultDirectory()
: config.LogPath;
if (!config.IsUsable())
{
bootstrapLogger.LogError(
"Config incomplete: provision CollectorKey and BaseUrl in " +
"HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl/apikey in appsettings.json for dev). " +
"See the registry lines above to tell 'missing' from 'access denied'.");
return 1;
}
var baseUrlProblem = config.BaseUrlProblem();
if (baseUrlProblem is not null)
{
bootstrapLogger.LogError("Config rejected: {Problem}", baseUrlProblem);
return 1;
}
try
{
if (runOnce)
return await RunOnceAsync(config, agentVersion, logDirectory, faultMonitor);
if (!string.IsNullOrWhiteSpace(reportPayloadPath))
return await RunReportAsync(config, reportPayloadPath, logDirectory, faultMonitor);
await RunHostAsync(config, agentVersion, logDirectory, forceConsole);
return 0;
}
catch (Exception error)
{
faultMonitor.ReportFatal("host", error);
return 1;
}
static async Task RunHostAsync(AgentConfig config, string agentVersion,
string logDirectory, bool forceConsole)
{
var builder = Host.CreateApplicationBuilder();
if (!forceConsole)
builder.Services.AddWindowsService(options =>
options.ServiceName = "ShopDbEndpointAgent");
ConfigureLogging(builder.Logging, logDirectory);
RegisterCoreServices(builder.Services, config, agentVersion);
builder.Services.AddHostedService(sp => sp.GetRequiredService<TelemetryWorker>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<CommandWorker>());
await builder.Build().RunAsync();
}
static async Task<int> RunOnceAsync(AgentConfig config, string agentVersion,
string logDirectory, AgentFaultMonitor faultMonitor)
{
using var services = BuildOnceServices(config, agentVersion, logDirectory);
var worker = services.GetRequiredService<TelemetryWorker>();
var logger = services.GetRequiredService<ILogger<TelemetryWorker>>();
try
{
var result = await worker.RunOnceAsync(config.AdoptOnFirstRun, CancellationToken.None);
logger.LogInformation("Single report complete: action={Action}", result.Action);
return 0;
}
catch (Exception error)
{
faultMonitor.ReportFatal("--once", error);
return 1;
}
}
// Uploads one operator issue report described by a tray-written JSON sidecar,
// then deletes the payload folder. Runs out of process from the tray so the
// collector key stays in the service binary. The exit code is the tray's
// contract: 0 = uploaded, non-zero = failed (detail in the agent log).
static async Task<int> RunReportAsync(AgentConfig config, string payloadPath,
string logDirectory, AgentFaultMonitor faultMonitor)
{
using var services = BuildOnceServices(config, "0.0.0", logDirectory);
var client = services.GetRequiredService<CollectorClient>();
var logger = services.GetRequiredService<ILogger<CollectorClient>>();
try
{
var payload = OperatorReportPayload.Read(payloadPath);
var response = await client.UploadOperatorReportAsync(
payload.Reason, payload.Details, payload.ScreenshotPath,
Environment.MachineName, CancellationToken.None);
logger.LogInformation("Operator report uploaded: id={ReportId}",
response?.OperatorReportId);
return 0;
}
catch (Exception error)
{
faultMonitor.ReportFatal("--report", error);
return 1;
}
finally
{
// Remove only the transient JSON sidecar. The screenshot lives in the
// operator's %USERPROFILE%\ShopDBScreenshots folder and is deliberately
// KEPT so the operator can show it to a DT/specialist later; pruning it
// is the future server-driven Capture Cleanup job, not this path. (The
// sidecar previously sat in a throwaway temp folder we deleted whole;
// now it shares the operator's folder, so a recursive delete here would
// wipe saved screenshots.)
try
{
if (File.Exists(payloadPath))
File.Delete(payloadPath);
}
catch { /* best-effort; the tray also removes the sidecar */ }
}
}
// Read the value following a flag (e.g. --report C:\path\report.json). Returns
// null when the flag is absent or has no following token.
static string? ArgValue(string[] args, string flag)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase))
return args[i + 1];
}
return null;
}
static ServiceProvider BuildOnceServices(AgentConfig config, string agentVersion,
string logDirectory)
{
var services = new ServiceCollection();
services.AddLogging(logging => ConfigureLogging(logging, logDirectory));
RegisterCoreServices(services, config, agentVersion);
return services.BuildServiceProvider();
}
static void RegisterCoreServices(IServiceCollection services, AgentConfig config,
string agentVersion)
{
services.AddSingleton(config);
// Shared wake used by the command worker to trigger an immediate telemetry
// collect after a command changes endpoint state (e.g. MTConnect toggle).
services.AddSingleton<CollectSignal>();
if (!string.IsNullOrWhiteSpace(config.UtilitiesPath) &&
Directory.Exists(config.UtilitiesPath))
{
services.AddSingleton<IAgentUtilityRunner>(sp =>
new PowerShellUtilityRunner(config.UtilitiesPath,
sp.GetRequiredService<ILogger<PowerShellUtilityRunner>>()));
}
// Persisted per-channel event watermark so each Windows event record is
// reported exactly once, instead of re-sending the newest window of every
// channel on every cadence tick.
services.AddSingleton(sp => new EventWatermarkStore(
sp.GetRequiredService<ILogger<EventWatermarkStore>>()));
// Session-scoped collection. The agent is LocalSystem, so mapped drives and
// user-owned listeners are invisible to it. The tray, which already runs as
// the operator, writes the spool file; the service only reads it.
//
// UserContextProbe is deliberately NOT registered here. The service must
// never run the probe: descending into the session with CreateProcessAsUser
// is the reverted design, and leaving a resolvable dependency in the
// container is an invitation to reintroduce it. The probe is constructed
// only on the --collect-usercontext path, which the tray invokes.
services.AddSingleton(sp => new UserContextSpool(
sp.GetRequiredService<ILogger<UserContextSpool>>()));
// The ask-the-tray side of the same split. Registering this is NOT a
// loophole around the paragraph above: the service still cannot collect
// session data, it can only request that the process which can does so, and
// then read the file that appears. The probe stays out of this container.
services.AddSingleton(sp => new UserContextRefreshChannel(
sp.GetRequiredService<ILogger<UserContextRefreshChannel>>()));
services.AddSingleton<ICollector>(sp => new EndpointCollector(
sp.GetRequiredService<ILogger<EndpointCollector>>(),
config,
sp.GetService<IAgentUtilityRunner>(),
sp.GetRequiredService<EventWatermarkStore>(),
sp.GetRequiredService<UserContextSpool>()));
services.AddSingleton(sp => new CollectorClient(
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
services.AddSingleton(sp => new TelemetryWorker(
sp.GetRequiredService<ICollector>(),
sp.GetRequiredService<CollectorClient>(),
config,
agentVersion,
sp.GetRequiredService<ILogger<TelemetryWorker>>(),
sp.GetService<IAgentUtilityRunner>(),
sp.GetRequiredService<CollectSignal>(),
sp.GetRequiredService<UserContextSpool>(),
sp.GetRequiredService<UserContextRefreshChannel>()));
// Command channel: outbound long-poll for allowlisted WebUI commands, run
// through the fail-closed executor. Hosted alongside telemetry but on its
// own loop so a held-open poll never delays a report.
services.AddSingleton(sp => new CommandClient(
config, sp.GetRequiredService<ILogger<CommandClient>>()));
services.AddSingleton(sp => new CommandExecutor(
sp.GetRequiredService<ILogger<CommandExecutor>>(),
config));
services.AddSingleton(sp => new CommandWorker(
sp.GetRequiredService<CommandClient>(),
sp.GetRequiredService<CommandExecutor>(),
sp.GetRequiredService<ILogger<CommandWorker>>(),
sp.GetRequiredService<CollectSignal>()));
}
static void ConfigureLogging(ILoggingBuilder logging, string logDirectory)
{
logging.ClearProviders();
logging.AddProvider(new FileLoggerProvider(logDirectory));
logging.SetMinimumLevel(LogLevel.Information);
}
// Say so, loudly, when a file sink could not open its directory.
//
// FileLoggerProvider never throws on construction and silently drops lines when
// its directory is unusable. That is the right behaviour - logging must not take
// the agent down - but it made two different failures indistinguishable: a probe
// that ran and failed, and a probe whose log had nowhere to go. The provider
// records the reason; this is the part that was missing, the caller actually
// asking for it. stderr is the only sink guaranteed to exist at this point, so
// that is where the complaint goes.
static void ReportSinkProblem(FileLoggerProvider provider, string which)
{
if (provider.SinkProblem is null)
return;
Console.Error.WriteLine(
$"shopdbagent: the {which} log sink is unusable and log lines will be "
+ $"dropped - {provider.SinkProblem}");
}

View File

@@ -0,0 +1,179 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
<AssemblyName>shopdbagent</AssemblyName>
<!-- 1.10.1: the collect-usercontext verb hides its console window. The agent
is a console exe, which the tray used to mask by launching the probe with
CreateNoWindow; the scheduled task runs the exe directly in the operator's
session, so a black window appeared on every 30-minute collection. -->
<!-- 1.10.0: user context is collected by a scheduled task in the operator's
own logon session, not by a process the elevated installer launches.
Drive letters are per logon token: an elevated probe enumerates a
different, usually empty, drive table, so an install performed by an
admin produced a spool reporting every mapped drive as Unavailable while
the operator's own session showed OK. The installer no longer starts the
tray via explorer.exe (which de-elevates but does NOT change account) and
instead registers 'ShopDB user-context probe' against BUILTIN\Users at
RunLevel Limited, verifies it by reading the task back, and records the
outcome in the install ledger. The collect-usercontext verb now refuses
to run elevated (exit 2) rather than writing a confident wrong answer,
the probe stamps its token elevation, and the service rejects any spool
stamped elevated. Probe runs log the drive and listener counts. -->
<!-- 1.9.0: the spool is finally written where the service looks for it. The
probe script resolved its output directory from $env:USERPROFILE while
the reader used the pinned C:\Users\ShopFloor path; under a brokered or
re-parented tray launch those differ, so the probe reported success and
wrote the file somewhere the service never read. Both sides now derive
the path from one helper (UserContextSpool.PinnedResultPath). Collect()
verifies the file exists on disk before reporting success, because a
zero exit code from powershell.exe proved nothing about the write.
Minor rather than patch because the spool handoff itself changed shape
(AGENT-CONTRACT.md 5): the spool is now PERSISTED and overwritten each
run rather than consumed on read. The collector cache is in-memory only,
so consume-on-read plus a service restart blanked mapped drives until the
next tray cycle. Max age tightened 24h -> 90m now that staleness, not
deletion, is the retirement mechanism. A spool whose username stamp is
absent or not ShopFloor is rejected outright - wrong-session drive data
is worse than none.
The collect-usercontext branch moved to the top of Program.cs: it used to
open the SYSTEM-owned log directory, register an EventLog source, and
read CollectorKey out of HKLM before discovering it only needed to write
a file into the caller's own profile - all as the operator, and all
exactly what an EPM broker intercepts. Log filenames unified on
<date>-<component>.log across service, tray, and installer. A log sink
that cannot open its directory now says so on stderr instead of
discarding every line unread. Pairs with tray 1.2.0. -->
<!-- 1.8.1: user-context logging and spool location are split from the
service's. The probe runs as the signed-in operator but logged into
%ProgramData%\ShopDB\EndpointAgent\logs, which is SYSTEM-owned and not
writable from a user session; the file sink swallows that failure, so a
probe that failed and a probe that never ran looked identical - no log
line either way. The probe and the tray now log to
C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log, which the
operator owns. The spool path is pinned to C:\Users\ShopFloor\ShopDB
instead of being resolved through WTSQueryUserToken (a privileged call
endpoint protection blocks, after which writer and reader disagreed
silently about the location). The service now CONSUMES the spool -
removing it after a successful parse - so a dead tray stops re-reporting
stale drives instead of pinning them forever. The probe itself is
launched with -EncodedCommand rather than a staged .ps1 run via -File:
a quarantined or missing temp script makes powershell.exe print usage
and exit -196608 without running a statement. -->
<!-- 1.8.0: user-context collection is inverted. The service no longer
descends into the operator's session with CreateProcessAsUser to run the
probe; that path needs to duplicate a token, is what endpoint protection
watches for, and produced no spool file at all on a live endpoint. The
tray, which is already running as the operator, now invokes
shopdbagent.exe with the collect-usercontext verb on its own cadence and
the service only reads the spool. Requires the 1.1.0 tray: an older tray
the new verb, so usercontext stays null until the tray is updated.
Spool freshness moved from delete-around-read to the in-file collectedat
stamp with a max age, because writer and reader are now independent
processes and a delete-then-read would race the tray and discard good
results. -->
<!-- 1.7.0: curated events are harvested against a persisted per-channel
Windows record-id watermark and carry eventrecordid, so one real event
is reported and stored exactly once instead of once per cadence tick
for as long as it stayed in the newest-25 window. Adds WER 1000/1002,
event XML fields, listening ports, an on-demand network_connections
command, pendingreboot, disk freepercent, and populates cpupercent/
memorypercent/idleseconds (previously declared but always null).
Session-scoped data (mapped drives, user-owned listeners, idle time)
comes from a probe run in the interactive user's session via a spool
file, because none of it exists in the agent's LocalSystem session 0.
run_script user-context waits are now bounded; the previous Infinite
wait could pin the command worker on a hung script.
1.6.3: operator issue reports stage under the interactive operator's
%USERPROFILE%\ShopDBScreenshots (resolved in the ShopFloor tray session)
instead of %TEMP%, which on locked-down endpoints redirected to another
user's AppData\Local\Temp and left the SYSTEM-context report upload
reading an inaccessible path. Screenshots use flat timestamped names and
are KEPT after a successful upload for operator review; only the transient
JSON sidecar is pruned, so the report path no longer recursively deletes
the shared folder. AgentFaultMonitor.ReportFatal now mirrors fatal report
failures to the rolling file log so unelevated tray-launched runs surface
a cause instead of exiting 1 silently.
1.6.2: command channel retires pooled TLS connections faster than the
server keep-alive (SocketsHttpHandler PooledConnectionIdleTimeout 1s,
PooledConnectionLifetime 2m). Fixes intermittent "unexpected EOF or 0
bytes from the transport stream" on the long-poll claim when the pool
reused a socket the server had already closed between hold windows.
Telemetry was unaffected; this only touched CommandClient.
1.6.1: republished against the current source so both site packages
(Wilmington and West Jefferson) carry the same vetted build. No agent
behavior change; the server now computes warn-only drift from the
service/process state the agent already reports each cycle.
1.6.0: a policy-attached process monitor may carry a launch path set by
a site leader in the WebUI; it rides the /monitors/resolved payload and
the agent honors it only after LaunchPathPolicy validates it (approved
root, exists, launchable extension). A local appsettings.json launchpaths
entry still wins; an unvalidated path is dropped and process_start fails
closed.
1.5.0: the agent fetches its watch list from server policy each
telemetry cycle (GET /api/endpoints/monitors/resolved) and merges it
over local appsettings.json, so a policy-attached process/service
monitor is enumerated and reported without editing the box. Fail-closed:
a failed fetch keeps the local list.
1.4.1: run_script/update_app stage into a fixed, Defender-excludable
folder (C:\ProgramData\ShopDB\EndpointAgent) instead of the LocalSystem
temp path, and run_script user-context waits for exit and reports the
real exit code instead of fire-and-forget "launched".
1.4.0: run_script fetches a ShopDB-hosted script by digest and runs it
in the script's configured context (system or the interactive user),
replacing the local scriptpaths allowlist; update_app resolves an app-
level or version-level install source.
1.2.0: process_start launches via a per-image path allowlist
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
1.1.0: generic, data-driven service_*/process_* actions carry their
target over the wire, so new policies/apps need no agent rebuild. -->
<!-- 1.11.0: on-demand user-context refresh. "Refresh Status" (collect_now)
and the tray's "Send Status" re-sent whatever was already in the spool,
so a drive the operator had just unmapped kept reporting OK until the
next 30-minute probe and no amount of clicking changed it. The service
still does not - and must not - collect session data itself; it signals
Global\ShopDbEndpointAgent.UserContextRefresh and waits up to 12s for the
spool's collectedat stamp to ADVANCE before collecting, which is the only
proof the file in front of it answers this request and not the previous
one. The wait is bounded and non-fatal by design: telemetry is the
liveness signal, so a timeout reports slightly stale session data rather
than skipping the report. UserContextSpool.TryReadCollectedAt reads the
stamp outside the usability gates, so a spool rejected as expired or
wrong-account does not burn the full timeout every cycle. Minor because
the spool handoff gains a second channel (AGENT-CONTRACT.md 5) and
because the installer now registers the probe task to the ShopFloor USER
rather than BUILTIN\Users: a group-principal task cannot be started by a
member of that group, so Start-ScheduledTask returned access denied and
the task only ever ran on its own trigger. Requires tray 1.4.0 - the tray
is the side that listens, so an older tray simply never answers. -->
<Version>1.11.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.EventLog" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ShopDbEndpointAgent.Core\ShopDbEndpointAgent.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
{
"baseurl": "",
"apikey": "REPLACE_WITH_COLLECTOR_KEY",
"cadenceseconds": 900,
"jitterseconds": 60,
"adoptonfirstrun": true,
"utilitiespath": "",
"logpath": "",
"monitors": {
"processes": [],
"services": []
},
"launchpaths": {}
}

View File

@@ -0,0 +1,119 @@
<#
.SYNOPSIS
ShopDB agent utility: reports pending Dell Command | Update items.
.DESCRIPTION
Discovered and executed by PowerShellUtilityRunner on the telemetry cadence.
Emits a single JSON object on stdout, which the agent attaches to the report
as `dellupdates`. Never throws and never writes anything but JSON: a failure
here must not break a telemetry run, so every error path still emits a valid
object with a `status` explaining why the list is empty.
THIS SCRIPT DOES NOT SCAN. It only reads the log a previous scan wrote. The
scan itself is fired by the \ShopDB-DellCommandScan scheduled task, which is
what pins dcu-cli.exe's -outputLog to the path below. Keeping the two apart
is deliberate: a telemetry collector that shelled out to dcu-cli would block
the cadence for minutes and hammer Dell's servers every 15 minutes.
OUTPUT CONTRACT (keep in sync with plugins/endpoints/plugin.py):
status unknown | ok | error - "unknown" means no scan has ever run,
which is NOT the same as "ok" with an empty list (known clean).
scannedat ISO-8601 UTC timestamp of the scan log's last write, or null.
count total pending updates found.
updates up to $MaxUpdates entries of { name, severity, version }.
truncated true when count exceeded $MaxUpdates.
Dell's own long description text is deliberately NOT carried: it is
sentence-length marketing prose per update, which would add roughly an order
of magnitude to every report for no operational value. Name plus severity is
what an operator decides on.
#>
$ErrorActionPreference = 'Stop'
# Cap the list rather than the payload. A machine that is years behind can have
# dozens of pending updates, and an unbounded list would bloat every report on
# the cadence. Mirrors the MaxReportedConnections precedent in CommandExecutor:
# a truncated answer is more useful than a rejected payload.
$MaxUpdates = 25
# Must match the -outputLog path set in the \ShopDB-DellCommandScan task
# definition. ShopDB-initiated scans write HERE rather than to Dell's default
# location, which is what keeps them distinguishable from the corporate-managed
# DCU tasks that also run on these machines.
$ScanLog = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\dellcommand\scan.xml'
function Write-Result {
param($Status, $ScannedAt, $Count, $Updates, $Truncated)
# Compress so the payload carries no pretty-printing whitespace. Depth 3
# covers the nested updates array.
[pscustomobject]@{
status = $Status
scannedat = $ScannedAt
count = $Count
updates = @($Updates)
truncated = [bool]$Truncated
} | ConvertTo-Json -Depth 3 -Compress
}
try {
if (-not (Test-Path -LiteralPath $ScanLog)) {
# No scan has run yet. This is the normal state for a freshly adopted
# endpoint, not an error, but it must stay distinct from "scanned and
# found nothing" or the UI would claim a machine is up to date when
# nobody has ever checked.
Write-Result -Status 'unknown' -ScannedAt $null -Count 0 -Updates @() -Truncated $false
return
}
$logFile = Get-Item -LiteralPath $ScanLog
$scannedAt = $logFile.LastWriteTimeUtc.ToString('o')
[xml]$report = Get-Content -LiteralPath $ScanLog -Raw
# dcu-cli's scan report shape has varied across DCU versions, so select the
# update nodes by name anywhere in the tree rather than by a fixed path.
$nodes = @($report.SelectNodes('//update'))
if ($nodes.Count -eq 0) {
$nodes = @($report.SelectNodes('//Update'))
}
$updates = foreach ($node in $nodes) {
# Attribute casing also varies by version; take whichever is present and
# fall back rather than emitting a null-filled row.
$name = $node.name
if (-not $name) { $name = $node.Name }
if (-not $name) { $name = $node.display }
if (-not $name) { continue }
$severity = $node.urgency
if (-not $severity) { $severity = $node.Urgency }
if (-not $severity) { $severity = $node.criticality }
$version = $node.version
if (-not $version) { $version = $node.Version }
[pscustomobject]@{
name = [string]$name
severity = if ($severity) { [string]$severity } else { 'unknown' }
version = if ($version) { [string]$version } else { $null }
}
}
$updates = @($updates)
$count = $updates.Count
$truncated = $count -gt $MaxUpdates
if ($truncated) {
$updates = $updates[0..($MaxUpdates - 1)]
}
Write-Result -Status 'ok' -ScannedAt $scannedAt -Count $count `
-Updates $updates -Truncated $truncated
}
catch {
# Emit a valid object rather than failing: the runner would log a non-JSON
# utility and drop it, which looks identical to "never ran" on the server.
# An explicit error status tells an operator the scan log exists but could
# not be read, which is a different problem from no scan at all.
Write-Result -Status 'error' -ScannedAt $null -Count 0 -Updates @() -Truncated $false
}

View File

@@ -0,0 +1,123 @@
# Persistent, non-privileged BuildKit for the publish-images workflow.
#
# WHY this exists: the arc-runner-set runner pods have the docker/buildx CLIs
# but no Docker daemon and cannot run a privileged DinD sidecar under cluster
# policy. So image builds are delegated to this standalone buildkitd Service.
# The workflow (.github/workflows/publish-images.yml) points Buildx at it with
# driver: remote
# endpoint: ${{ secrets.BUILDKIT_ENDPOINT }}
# where BUILDKIT_ENDPOINT is set to:
# tcp://buildkit.buildkit.svc.cluster.local:1234
#
# This runs rootless buildkitd, so it needs NO privileged: true. It only needs
# a few unconfined-ish knobs the rootless image documents. A persistent
# Deployment (not a per-job container) means the build cache in the PVC survives
# across runs, so the heavy frontend npm build is only paid in full once.
#
# Cameron: apply once with `kubectl apply -f deploy/ci/buildkit-remote.yaml`.
# This changes cluster infra only. It does NOT modify any application code or
# any branch of the repo.
---
apiVersion: v1
kind: Namespace
metadata:
name: buildkit
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: buildkit
namespace: buildkit
labels:
app: buildkit
spec:
replicas: 1
selector:
matchLabels:
app: buildkit
template:
metadata:
labels:
app: buildkit
annotations:
# Required by rootless buildkitd to unshare user/network namespaces
# without full privilege. These are the values from the upstream
# rootless example; they are far weaker than privileged: true.
container.apparmor.security.beta.kubernetes.io/buildkitd: unconfined
spec:
containers:
- name: buildkitd
image: moby/buildkit:v0.16.0-rootless
args:
- --addr
- unix:///run/user/1000/buildkit/buildkitd.sock
- --addr
- tcp://0.0.0.0:1234
# TLS is terminated in-cluster; the Service is only reachable from
# runner pods on the internal network. If you want mTLS between the
# runner and buildkitd, mount certs here and add --tlscacert etc,
# then set the endpoint secret to the tcp:// address with certs.
- --oci-worker-no-process-sandbox
readinessProbe:
exec:
command:
- buildctl
- debug
- workers
initialDelaySeconds: 5
periodSeconds: 30
livenessProbe:
exec:
command:
- buildctl
- debug
- workers
initialDelaySeconds: 5
periodSeconds: 30
securityContext:
# Rootless: no privilege escalation, runs as uid 1000. The seccomp
# and AppArmor unconfined settings below are what rootless needs in
# place of privileged: true.
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
seccompProfile:
type: Unconfined
ports:
- containerPort: 1234
name: buildkit
volumeMounts:
- name: buildkit-cache
mountPath: /home/user/.local/share/buildkit
volumes:
- name: buildkit-cache
persistentVolumeClaim:
claimName: buildkit-cache
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: buildkit-cache
namespace: buildkit
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
# Layer + build cache for the app image. Bump if the cache fills.
storage: 20Gi
---
apiVersion: v1
kind: Service
metadata:
name: buildkit
namespace: buildkit
spec:
selector:
app: buildkit
ports:
- name: buildkit
port: 1234
targetPort: 1234
# ClusterIP only: reachable from runner pods, not exposed outside the cluster.
type: ClusterIP

View File

@@ -4,7 +4,9 @@
"backups", "backups",
"computers", "computers",
"employees", "employees",
"endpoints",
"geenforce", "geenforce",
"guides",
"knowledgebase", "knowledgebase",
"machines", "machines",
"measuringtools", "measuringtools",

View File

@@ -101,6 +101,17 @@
</rewrite> </rewrite>
SHOPDB-CLIENTIP-END --> SHOPDB-CLIENTIP-END -->
<security>
<requestFiltering>
<!-- IIS request filtering caps a request body at 30,000,000 bytes by
default and answers 413 before waitress sees it. The agent package
upload accepts 128 MB (MAX_CONTENT_LENGTH is 256 MB), and the tray
binary alone is ~69 MB, so the IIS cap has to be raised to match or
every agent upload fails on Windows while working under Docker. -->
<requestLimits maxAllowedContentLength="268435456" />
</requestFiltering>
</security>
</system.webServer> </system.webServer>
<!-- <!--

View File

@@ -32,10 +32,14 @@ x-app-env: &app-env
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
ZABBIX_URL: ${ZABBIX_URL:-} ZABBIX_URL: ${ZABBIX_URL:-}
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
# Channel label for a distributable installer. Empty on the stable (main)
# bundle, 'DEV' on the edge (dev-be) bundle, which lights up the sidebar
# badge. Sourced from .env, which build-channel-installer.ps1 pre-fills.
SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-}
services: services:
db: db:
image: mysql:8.0 image: mysql:8.4
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -48,7 +52,12 @@ services:
ports: ports:
- "127.0.0.1:${MYSQL_PORT:-3306}:3306" - "127.0.0.1:${MYSQL_PORT:-3306}:3306"
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] # 127.0.0.1, not localhost: localhost means the unix socket, and the
# entrypoint's init pass answers on the socket while running the server
# with --skip-networking. A socket ping therefore reports healthy DURING
# init, and the api/migrate services start against a server that is about
# to restart. Over TCP the probe stays red until the real server listens.
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -72,6 +81,11 @@ services:
flask seed permissions && flask seed permissions &&
flask seed settings && flask seed settings &&
flask seed reference-data flask seed reference-data
# plugin upgrade-all rewrites plugins.json, so migrate needs the same
# instance volume api uses. Without it the enabled-plugin list is written
# into a container that exits immediately afterwards.
volumes:
- instance_data:/app/instance
api: api:
image: shopdb-flask:${IMAGE_TAG:-0.7.0} image: shopdb-flask:${IMAGE_TAG:-0.7.0}
@@ -85,6 +99,11 @@ services:
<<: *app-env <<: *app-env
ports: ports:
- "${API_PORT:-5001}:5001" - "${API_PORT:-5001}:5001"
# See docker-compose.yml for what lives here. Same reasoning: /app/instance
# is written state and does not survive a container recreate on its own.
volumes:
- instance_data:/app/instance
volumes: volumes:
db_data: db_data:
instance_data:

175
docker-compose.backup.yml Normal file
View File

@@ -0,0 +1,175 @@
# shopdb-flask BACKUP overlay - opt-in, per-site.
#
# WHY a separate overlay: backups are not part of the base stack every site must
# run. A facility turns them on by adding this file to its compose invocation:
#
# docker compose -f docker-compose.yml -f docker-compose.backup.yml \
# --profile backup up -d
#
# That brings up THREE cooperating pieces:
# - backup-store : a resident, isolated restic REST server (the third
# container). Holds every snapshot in its own volume and is
# reachable ONLY over the compose network - never published
# to the host - so the app can touch backups solely through
# the backup/restore utility, not with a shell or db creds.
# - backup-watcher : a resident poller that actuates the "Back up now" sentinel
# the api drops, running the one-shot against backup-store.
# This is what makes the in-app button produce a real backup
# given the api container cannot run docker.
# - backup : the same one-shot as a manual `run --rm backup` for sites
# that prefer a host scheduler (cron / Task Scheduler).
#
# WHAT each run captures, as one recoverable set:
# - db.sql.gz : mysqldump --single-transaction of the whole DB (users,
# auth, ledger, every plugin table). InnoDB-consistent,
# non-blocking.
# - instance/ : restic snapshot of /app/instance (deduped, encrypted),
# the files MySQL rows point at.
# - manifest.json: core+plugin Alembic revisions and image tag at capture
# time - the stamp that makes a restore a CHECKED operation.
#
# Retention (restic-native): --keep-daily 5 --keep-weekly 4 --keep-monthly 6,
# satisfying "any day in the last 5" plus a cheap long-horizon tail.
#
# DESTINATION defaults to the third container via RESTIC_REPOSITORY:
# third container : rest:http://backup-store:8000/ (default, ships with app)
# AWS S3 : s3:s3.amazonaws.com/shopdb-<site>-backups
# Azure Blob : azure:shopdbbackups:<site>
# SMB share : mount the share on the host, point at a local path
# Credentials come from the site's env/secret store, never committed - same
# pattern as the collector key.
# The THIRD container: an isolated backup store the app can only reach through
# the backup/restore utility, never with a shell or the db credentials. It runs
# restic's REST server over the compose network; the only thing that speaks to
# it is the backup watcher/one-shot via RESTIC_REPOSITORY=rest:http://... . Its
# data lives in its own named volume (backupstore_data), so it is container
# agnostic - later a pull/migration utility can point at this same REST endpoint
# from anywhere without touching the app or the database.
services:
backup-store:
image: restic/rest-server:0.13.0
profiles: ["backup"]
restart: unless-stopped
environment:
# Private (no public /create) so repos are provisioned by the backup
# client's `restic init`, and append-only OFF here because the same
# utility both writes and prunes; isolation comes from this being the
# only service on the network with access, not from client trust.
DISABLE_AUTHENTICATION: "true"
OPTIONS: "--no-auth"
volumes:
- backupstore_data:/data
# Deliberately NOT published to the host: reachable only over the compose
# network by service name, so nothing outside the stack can touch backups.
expose:
- "8000"
healthcheck:
test: ["CMD", "sh", "-c", "wget -q -O - http://127.0.0.1:8000/ >/dev/null 2>&1 || true"]
interval: 30s
timeout: 5s
retries: 3
# Resident watcher: actuates the "Back up now" sentinel the api drops. This is
# the piece that makes the UI button run a real backup, since the api itself
# cannot run docker. It shares the base image with the one-shot and calls the
# same backup.sh.
backup-watcher:
image: shopdb-backup:local
build:
context: .
dockerfile: scripts/backup/Dockerfile.backup
profiles: ["backup"]
restart: unless-stopped
depends_on:
db:
condition: service_healthy
backup-store:
condition: service_started
environment:
# DB access - same credentials the api uses, reaching db over the compose
# network by service name.
MYSQL_HOST: db
MYSQL_DATABASE: shopdb_flask
MYSQL_USER: shopdb
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
# Destination is the third container by default: restic's REST backend on
# the isolated backup-store. A site can still override RESTIC_REPOSITORY
# in .env to send offsite instead, but out of the box backups land in the
# dedicated container.
RESTIC_REPOSITORY: ${RESTIC_REPOSITORY:-rest:http://backup-store:8000/}
RESTIC_PASSWORD: ${RESTIC_PASSWORD:?RESTIC_PASSWORD must be set}
# Optional cloud creds, passed through when set (empty otherwise).
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AZURE_ACCOUNT_NAME: ${AZURE_ACCOUNT_NAME:-}
AZURE_ACCOUNT_KEY: ${AZURE_ACCOUNT_KEY:-}
# Site tag stamped into every snapshot + manifest.
SHOPDB_SITE: ${SHOPDB_SITE:-shopdb}
SHOPDB_IMAGE_TAG: ${SHOPDB_IMAGE:-source}
SHOPDB_BACKUPS_ROOT: /backups
BACKUP_POLL_SECONDS: ${BACKUP_POLL_SECONDS:-10}
volumes:
# The watcher services both backup (read) and restore (write) requests, so
# the instance tree is mounted read-write here. Restore has to lay the
# files half of a set back down next to the DB rows that reference it; the
# backup half still only ever reads. The manual one-shot below stays :ro.
- instance_data:/app/instance
# Alembic chain, read-only, so the manifest can stamp the schema revision
# the running code is at.
- ./migrations:/app/migrations:ro
- ./plugins:/app/plugins:ro
# Shared backups volume: holds the fast local db.sql.gz + manifest and the
# .backup-requested sentinel the api drops. The api mounts this same volume
# (see docker-compose.yml) so the UI can enumerate sets and request runs.
- backup_data:/backups
- ./scripts/backup:/opt/backup:ro
entrypoint: ["/opt/backup/watch.sh"]
# Manual one-shot, unchanged usage: `docker compose ... run --rm backup`.
# Kept for host-scheduler / cron sites that prefer a scheduled pass over the
# in-app button. Shares everything with the watcher but runs once and exits.
backup:
image: shopdb-backup:local
build:
context: .
dockerfile: scripts/backup/Dockerfile.backup
profiles: ["backup"]
restart: "no"
depends_on:
db:
condition: service_healthy
backup-store:
condition: service_started
environment:
MYSQL_HOST: db
MYSQL_DATABASE: shopdb_flask
MYSQL_USER: shopdb
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
RESTIC_REPOSITORY: ${RESTIC_REPOSITORY:-rest:http://backup-store:8000/}
RESTIC_PASSWORD: ${RESTIC_PASSWORD:?RESTIC_PASSWORD must be set}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AZURE_ACCOUNT_NAME: ${AZURE_ACCOUNT_NAME:-}
AZURE_ACCOUNT_KEY: ${AZURE_ACCOUNT_KEY:-}
SHOPDB_SITE: ${SHOPDB_SITE:-shopdb}
SHOPDB_IMAGE_TAG: ${SHOPDB_IMAGE:-source}
SHOPDB_BACKUPS_ROOT: /backups
volumes:
- instance_data:/app/instance:ro
- ./migrations:/app/migrations:ro
- ./plugins:/app/plugins:ro
- backup_data:/backups
- ./scripts/backup:/opt/backup:ro
entrypoint: ["/opt/backup/backup.sh"]
# Give the api read/write on the shared backups volume so the UI can list sets
# and drop the "Back up now" sentinel the watcher actuates. This extends the
# api service defined in docker-compose.yml (merged by compose).
api:
volumes:
- backup_data:/backups
volumes:
backup_data:
backupstore_data:

View File

@@ -0,0 +1,27 @@
# Channel overlay for side-by-side stable / bleeding-edge stacks.
#
# The base docker-compose.yml is channel-agnostic. This overlay adds only the
# pieces that differ between a stable (main) and an edge (dev-be) stack so the
# two can run at once on one dev workstation without colliding:
#
# - SHOPDB_ENV_LABEL is passed into the api container. When non-empty the
# public settings endpoint surfaces it as env_label and the sidebar renders
# a red DEV badge. Stable leaves it unset, so nothing renders.
# - Ports fall back to the SAME defaults as the base file, so running this
# overlay alone changes nothing; the bootstrap script overrides API_PORT /
# MYSQL_PORT for the edge stack so both channels bind distinct host ports.
#
# The stacks are isolated by compose project name (-p shopdb-stable /
# -p shopdb-edge), which namespaces containers, networks, and the named
# volumes, so each channel keeps its own database and instance state.
#
# Usage (driven by scripts/dev-bootstrap.ps1, shown here for reference):
# docker compose -p shopdb-edge \
# -f docker-compose.yml -f docker-compose.channel.yml up -d
services:
api:
environment:
# Empty on stable. The edge stack sets SHOPDB_ENV_LABEL=DEV in its .env
# (written by the bootstrap script) to light up the badge.
SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-}

95
docker-compose.pull.yml Normal file
View File

@@ -0,0 +1,95 @@
# shopdb-flask PULL stack for dev workstations (stable / edge channels).
#
# WHY this exists: pilots behind an inspecting proxy cannot reliably BUILD the
# image from source (npm's many small fetches get reset mid-install). Instead
# CI (.github/workflows/publish-images.yml) builds the image on clean runners
# and publishes it to GHCR; this file just PULLS and RUNS it. One resumable
# image pull survives the proxy where hundreds of npm requests do not.
#
# Nothing is built here (no `build:`), mirroring docker-compose.airgap.yml:
# - api/migrate use `image:` (pulled from GHCR), never `build: .`.
# - NO ./plugins bind mount; the image carries every plugin baked in.
# - a one-shot `migrate` runs db upgrade + plugin upgrade-all + seed before
# api starts, so `up -d` alone brings up a working site.
#
# Driven by scripts/dev-bootstrap.ps1, which sets SHOPDB_IMAGE (the channel
# image ref), SHOPDB_ENV_LABEL, API_PORT and MYSQL_PORT per channel and picks a
# compose project name (-p shopdb-stable / -p shopdb-edge) so the two channels
# run side by side without colliding.
x-app-env: &app-env
FLASK_APP: wsgi.py
FLASK_ENV: production
DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
# Empty on stable; 'DEV' on edge, which lights the sidebar badge.
SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-}
services:
db:
image: mysql:8.4
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
MYSQL_DATABASE: shopdb_flask
MYSQL_USER: shopdb
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
volumes:
- db_data:/var/lib/mysql
ports:
- "127.0.0.1:${MYSQL_PORT:-3306}:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
# One-shot schema + seed. Runs to completion and exits; api waits for it.
# Every step is idempotent, so it is safe to run on every `up`.
migrate:
image: ${SHOPDB_IMAGE:?SHOPDB_IMAGE must be set to the channel image ref}
pull_policy: always
restart: "no"
depends_on:
db:
condition: service_healthy
environment:
<<: *app-env
command:
- sh
- -c
- >
flask db upgrade &&
flask plugin upgrade-all &&
flask seed permissions &&
flask seed settings &&
flask seed reference-data
volumes:
- instance_data:/app/instance
api:
image: ${SHOPDB_IMAGE:?SHOPDB_IMAGE must be set to the channel image ref}
pull_policy: always
restart: unless-stopped
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
environment:
<<: *app-env
ports:
# Loopback only, same as the DB below. This stack generates its own
# dev secrets and may hold a real imported dataset; it has no business
# listening on every interface of a laptop on a shop-floor network.
- "127.0.0.1:${API_PORT:-5001}:5001"
volumes:
- instance_data:/app/instance
volumes:
db_data:
instance_data:

View File

@@ -17,7 +17,7 @@
services: services:
db: db:
image: mysql:8.0 image: mysql:8.4
# utf8mb4 server-wide so the auto-created MYSQL_DATABASE is utf8mb4, not the # utf8mb4 server-wide so the auto-created MYSQL_DATABASE is utf8mb4, not the
# image default. Keeps every site's schema on the same charset/collation. # image default. Keeps every site's schema on the same charset/collation.
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
@@ -36,7 +36,12 @@ services:
ports: ports:
- "127.0.0.1:${MYSQL_PORT:-3306}:3306" - "127.0.0.1:${MYSQL_PORT:-3306}:3306"
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] # 127.0.0.1, not localhost: localhost means the unix socket, and the
# entrypoint's init pass answers on the socket while running the server
# with --skip-networking. A socket ping therefore reports healthy DURING
# init, and the api/migrate services start against a server that is about
# to restart. Over TCP the probe stays red until the real server listens.
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -56,10 +61,82 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
ZABBIX_URL: ${ZABBIX_URL:-} ZABBIX_URL: ${ZABBIX_URL:-}
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
COLLECTOR_API_KEY: ${COLLECTOR_API_KEY:-}
COLLECTOR_API_KEY_ENDPOINTS: ${COLLECTOR_API_KEY_ENDPOINTS:-}
# This file is the source-build dev stack (wilm/dev-be); stable and edge
# ship through the pull/airgap compose files, not this one. So default the
# badge label to DEV here - a raw source build is by definition not a
# release - while still allowing an override from .env.
SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-DEV}
ports: ports:
- "${API_PORT:-5001}:5001" - "${API_PORT:-5001}:5001"
# Terminate TLS in gunicorn with the mounted self-signed cert so the browser
# sees a secure context. navigator.clipboard and other secure-context-only
# web APIs refuse to run over plain http on a non-localhost origin, so the
# test box (accessed by FQDN) needs real https. A site swaps in its own cert
# under ./certs or fronts this with its IIS/nginx proxy.
command:
- gunicorn
- --bind
- 0.0.0.0:5001
# Threaded workers, not the blocking sync default. An agent that fails
# the TLS handshake retries in a loop, and with 4 sync workers a handful
# of those pinned every worker: real UI requests queued 20+ seconds while
# the CPU sat idle. A stuck connection now holds one THREAD, and 32
# (4 x 8) have to be exhausted before a person waits.
#
# Found in production at the other site (their commit db75ca7). Their
# server, our agent - so the same client behaviour will reach any site
# running the endpoint agent, and the fix belongs in the product rather
# than on one branch.
- --worker-class
- gthread
- --workers
- "4"
- --threads
- "8"
# 30, not 60: a hung connection frees its thread in half the time, which
# is what decides whether a retry storm is absorbed or queues.
- --timeout
- "30"
- --access-logfile
- "-"
- --error-logfile
- "-"
- --certfile
- /app/certs/server.crt
- --keyfile
- /app/certs/server.key
- wsgi:app
healthcheck:
# Match the TLS listener: probe over https and accept the self-signed cert
# (-k). The Dockerfile default probes http and would stay red once gunicorn
# only speaks TLS.
test: ["CMD", "curl", "--silent", "--insecure", "https://localhost:5001/api/auth/login",
"-X", "POST", "-H", "Content-Type: application/json", "-d", "{}"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
volumes: volumes:
- ./plugins:/app/plugins:ro - ./plugins:/app/plugins:ro
# Standalone operator scripts shipped inside generated bundles (the
# endpoint-agent uninstaller). Read-only, same pattern as plugins: the
# site-agent packaging route reads uninstall-shopdbagent.ps1 from here at
# download time, so it must be visible in the container.
- ./scripts:/app/scripts:ro
# Self-signed TLS material for the dev/test https listener. Read-only;
# generated on the host under ./certs and never baked into the image.
- ./certs:/app/certs:ro
# /app/instance is WRITTEN state, not code: plugins.json (which plugins
# this site has enabled), uploaded floor plans, branding, model and
# application images, employee photos, warranty proofs, slides,
# printed-part files, and the Dell OAuth token. Without this volume a
# `docker compose build api && up -d api` recreates the container and
# takes all of it with it, so the site comes back with its plugins
# disabled and MySQL rows pointing at files that no longer exist.
- instance_data:/app/instance
volumes: volumes:
db_data: db_data:
instance_data:

View File

@@ -22,12 +22,12 @@ at all yet.
| Artifact | Where it is today | | Artifact | Where it is today |
|---|---| |---|---|
| GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. | | GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. |
| `Report-AssetToShopDB.ps1` | **Not in this repository.** It lives on the reference site's imaging share and is provided on request. It is planned to move to `plugins/computers/client/` so it versions with the collector contract it implements. | | `Report-AssetToShopDB.ps1` | `plugins/computers/client/`, so it versions with the collector contract it implements. It names no site: the server comes from `HKLM:\SOFTWARE\GE\ShopDB` `BaseUrl` (which Install-GEEnforce.ps1 writes) or `-ApiUrl`, and the NIC it reports is the one carrying the default route unless the site names its ranges via `-AllowedRanges` or the `CollectorRanges` registry value. |
| EventSaver (`EventSaver.scr`, `EventSaver.ini`, `EventSaver.cs`) | **Not in this repository.** Provided on request; the source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. See [EVENTSAVER.md](EVENTSAVER.md). | | EventSaver (`EventSaver.cs`, `EventSaver.ini`) | `plugins/slides/client/`. The source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. The compiled `EventSaver.scr` is a release asset, not a file in the repository. Neither file names a site. See [EVENTSAVER.md](EVENTSAVER.md). |
Ask the maintainers for the two that are not here. A site that would rather not A site that would rather not run a binary it cannot rebuild should take
run a binary it cannot rebuild should take EventSaver's source and compile it EventSaver's source and compile it locally - the build needs no SDK and is one
locally - the build needs no SDK and is one command. command.
--- ---
@@ -52,8 +52,13 @@ is simply absent; the server upserts on `hostname` and leaves the rest alone.
| Input | Where it comes from | | Input | Where it comes from |
|---|---| |---|---|
| Server URL | `-ApiUrl https://<your-shopdb>/api/collector/computers` | | Server URL | `-ApiUrl`, or `HKLM:\SOFTWARE\GE\ShopDB` value `BaseUrl`. A script downloaded from Settings has your URL already in it |
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey` | | API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey`. Never stamped into the script |
| Routable ranges | Optional. `-AllowedRanges '10.20.0.0/23,10.21.4.0/26'`, the `CollectorRanges` registry value, or the `computers_routableranges` setting. Unset reports the NIC carrying the default route, which is right at most sites |
**Shortcut: download it pre-configured.** Settings > Computers > Asset reporter
generates this script with your server's URL and ranges already in the parameter
defaults, and shows its SHA-256. The key is deliberately not included.
### Minting the key on your ShopDB ### Minting the key on your ShopDB
@@ -142,13 +147,27 @@ Configuration ShopdbCollector
Ensure = 'Present' Ensure = 'Present'
} }
# Optional. Only for a site whose bays carry both a controller NIC and a
# corporate one AND whose default route is not the corporate NIC. Leave
# this resource out otherwise - the script picks the default-route NIC.
Registry CollectorRanges
{
Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB'
ValueName = 'CollectorRanges'
ValueData = '10.20.0.0/23,10.21.4.0/26'
ValueType = 'String'
Ensure = 'Present'
}
Script ReportingTask Script ReportingTask
{ {
GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } } GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } }
TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) }
SetScript = { SetScript = {
# No -ApiUrl: BaseUrl above is where the script reads it from,
# and one source beats two that can disagree.
$action = New-ScheduledTaskAction -Execute 'powershell.exe' ` $action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1" -ApiUrl "https://shopdb.example.net/api/collector/computers"' -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 7am $trigger = New-ScheduledTaskTrigger -Daily -At 7am
Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action ` Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action `
-Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force -Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force
@@ -256,10 +275,17 @@ showing the last set instead of going black.
### The two things that catch people out ### The two things that catch people out
**The compiled-in fallback is not yours.** If `EventSaver.ini` is missing or **There is no compiled-in fallback - ship the ini.** If `EventSaver.ini` is
both `url` and `folder` are blank, the binary falls back to a folder path missing, or both `url` and `folder` are blank, EventSaver has no source to read
compiled into `EventSaver.cs`, which is the reference site's file server. Ship and shows nothing. That is deliberate: the source used to carry the reference
the ini. A missing ini is not a neutral default. site's file server as its default `Folder`, so a missing ini silently pointed a
new site at someone else's share. It is empty now (ADR-015), and failing
visibly beats displaying another site's slides.
Check any binary you were handed rather than built. A `.scr` compiled before
2026-08-18 still has that path baked in, and it will use it the moment the ini
goes missing. Build from the source in `plugins/slides/client/` - see BUILD
instructions in [EVENTSAVER.md](EVENTSAVER.md).
**If you enforce the ini by hash, your own edits revert.** The reference **If you enforce the ini by hash, your own edits revert.** The reference
manifest pins `EventSaver.ini` with `DetectionMethod: Hash`, which is what makes manifest pins `EventSaver.ini` with `DetectionMethod: Hash`, which is what makes

View File

@@ -96,13 +96,20 @@ Two auth patterns dominate the reads:
Every mutating endpoint (POST / PUT / PATCH / DELETE) requires a JWT and is Every mutating endpoint (POST / PUT / PATCH / DELETE) requires a JWT and is
gated by `require_role` or `require_permission`; none are public. gated by `require_role` or `require_permission`; none are public.
The map write surface splits along that line. Repositioning markers
(`/api/mappositions/*`) needs `assets.edit` - the same permission the old
per-asset `PUT /api/assets/<id>` always needed, so bulk editing grants nobody
anything new. Creating buildings and levels or uploading a blueprint
(`POST|PUT|PATCH|DELETE /api/maplevels/*`) needs `role:admin`, because a level's
dimensions are the coordinate space every marker on it is expressed in.
Two GETs deliberately break the optional-auth convention and require a Two GETs deliberately break the optional-auth convention and require a
permission, because what they return is not asset metadata but a file that permission, because what they return is not asset metadata but a file that
carries value or liability of its own: carries value or liability of its own:
| Endpoint | Requires | Why | | Endpoint | Requires | Why |
|---|---|---| |---|---|---|
| `GET /api/applications/package/<filename>` | `applications.view` | Licensed vendor installers. An anonymous URL is a redistribution channel. | | `GET /api/applications/package/<filename>` | `applications.download` | Licensed vendor installers. A separate permission from `applications.view` on purpose: seeing that an application exists is not the same as being handed the vendor's installer, and an anonymous URL is a redistribution channel. The script-body download is gated the same way. |
| `GET /api/warranty/proof/<filename>` | `warranty.view` | Invoices and certificates carry pricing and a service tag. | | `GET /api/warranty/proof/<filename>` | `warranty.view` | Invoices and certificates carry pricing and a service tag. |
Their sibling upload/delete routes are `applications.edit` / `warranty.edit` Their sibling upload/delete routes are `applications.edit` / `warranty.edit`
@@ -115,7 +122,10 @@ like any other mutation.
| `POST /api/auth/login` | Obtain a JWT. | | `POST /api/auth/login` | Obtain a JWT. |
| `GET /api/setup/needs-admin` | First-run check: does the instance have zero users. | | `GET /api/setup/needs-admin` | First-run check: does the instance have zero users. |
| `POST /api/setup/create-admin` | First-run only; creates the first admin, then 403s forever. | | `POST /api/setup/create-admin` | First-run only; creates the first admin, then 403s forever. |
| `GET /api/settings/map-blueprint/<filename>` | Serve the floor-map blueprint image. | | `GET /api/settings/map-blueprint/<filename>` | Serve a floor-map blueprint image. Pre-0.11.0 single-map path, retained; new uploads are served per level below. |
| `GET /api/maplevels` | Buildings and their levels, each with blueprint paths, native pixel size and marker count. Public because the printer-installer map draws a floor plan before anyone logs in; it exposes level names and image paths, nothing about assets. |
| `GET /api/maplevels/<levelid>` | One level's name, building, blueprints and size. |
| `GET /api/maplevels/<levelid>/blueprint/<filename>` | Serve a level's blueprint image, with sandbox headers so an SVG floor plan cannot execute as script. |
| `GET /api/settings/branding/<filename>` | Serve site branding assets (logo, etc.). | | `GET /api/settings/branding/<filename>` | Serve site branding assets (logo, etc.). |
| `GET /api/settings` and `GET /api/settings/<key>` | Read-only, and only the public allowlist: the `branding` and `map` categories, a few named site keys, plus any key a plugin declares `public` in `get_settings_defaults` (e.g. `printedparts_label_prefix`, which the logged-out parts kiosk renders). Every other key answers 404 to an anonymous caller. | | `GET /api/settings` and `GET /api/settings/<key>` | Read-only, and only the public allowlist: the `branding` and `map` categories, a few named site keys, plus any key a plugin declares `public` in `get_settings_defaults` (e.g. `printedparts_label_prefix`, which the logged-out parts kiosk renders). Every other key answers 404 to an anonymous caller. |
| `GET /api/models/image/<filename>` | Serve a model image. | | `GET /api/models/image/<filename>` | Serve a model image. |
@@ -125,14 +135,23 @@ like any other mutation.
| `GET /api/plugins/enabled` | List enabled plugins (no claims used). | | `GET /api/plugins/enabled` | List enabled plugins (no claims used). |
| `GET /api/dashboarddefaults/display-role` | Display role resolution (see section 1). | | `GET /api/dashboarddefaults/display-role` | Display role resolution (see section 1). |
| `GET /api/dashboarddefaults/visitor-location` | Lobby business-unit resolution (see section 1). | | `GET /api/dashboarddefaults/visitor-location` | Lobby business-unit resolution (see section 1). |
| `GET /api/employees/search`, `/lookup/<sso>`, `/lookup` | Employee directory lookups (kiosk / sign-in flows). | | ~~`GET /api/employees/search`, `/lookup/<sso>`, `/lookup`~~ | NOT public. These need `employees.view` and answer 401 anonymously. A kiosk resolves a badge through its own gated endpoint, not through these. |
| `GET /api/employees/photo/<filename>` | Serve an employee photo. | | `GET /api/employees/photo/<filename>` | Serve an employee photo. |
| `GET /api/notifications` (and `/types`, `/<id>`, `/active`, `/calendar`, `/dashboard/summary`, `/employee/<sso>`, `/shopfloor`) | Read shop-floor notifications for the public display and kiosks. | | `GET /api/notifications/shopfloor`, `GET /api/notifications/employee/<sso>` | The two the public display and kiosks actually read. The rest of `/api/notifications` (the list, `/types`, `/<id>`, `/active`, `/calendar`, `/dashboard/summary`) needs `notifications.view` and answers 401 anonymously. |
| `GET /api/slides/feed` | Slide feed for the lobby display and screensaver. | | `GET /api/slides/feed` | Slide feed for the lobby display and screensaver. |
| `GET /api/slides/img/<surface>/<filename>` | Serve a slide image. | | `GET /api/slides/img/<surface>/<filename>` | Serve a slide image. |
| `GET /api/printedparts/image/<filename>` | Serve a printed-part image. | | `GET /api/printedparts/image/<filename>` | Serve a printed-part image. |
| `GET /api/printedparts/kiosk/item/<itemcode>` | Kiosk part lookup (deliberately open; a kiosk carries no JWT). | | `GET /api/printedparts/kiosk/item/<itemcode>` | Kiosk part lookup. Carries no JWT, but is NOT open: the routes 404 unless `printedparts_kiosk_enabled` is on AND the request carries the site's `X-Kiosk-Token`. |
| `POST /api/printedparts/kiosk/take` | Kiosk part checkout (deliberately open, per decision record). | | `POST /api/printedparts/kiosk/take` | Kiosk part checkout, behind the same flag and device token. |
| `GET /api/usb/kiosk/device/<device_id>` | USB kiosk device lookup, behind `usb_kiosk_enabled` plus `X-Kiosk-Token`. |
| `POST /api/usb/kiosk/checkout`, `/checkin` | USB kiosk custody moves, same gate. |
**"No authentication" is not "open."** Both kiosks are off by default and refuse
every request without the site's device token; with the flag off they answer 404
rather than 403, so a kiosk a site has not turned on does not announce itself.
The token is a credential for a screen, not authorization for a person: it says
which cabinet is asking, and the badge on the request says who is standing at
it.
--- ---

View File

@@ -74,7 +74,7 @@ they would have to go and look up.
| Field | Meaning | | Field | Meaning |
|-------|---------| |-------|---------|
| `backupkind` | Which kind was most recently confirmed. `null` = no backup at all. | | `backupkind` | The kind the verdict is about - the STALEST measurable one, not the freshest. `null` = no backup at all. |
| `backuplastseen` | When it was last CONFIRMED still current (ISO). Tooltip only. | | `backuplastseen` | When it was last CONFIRMED still current (ISO). Tooltip only. |
| `backupcollectedat` | When the config was last CAPTURED (ISO). Only moves on a real change. Tooltip only. | | `backupcollectedat` | When the config was last CAPTURED (ISO). Only moves on a real change. Tooltip only. |
| `backupok` | `true` good, `false` stale, `null` nothing to judge. | | `backupok` | `true` good, `false` stale, `null` nothing to judge. |
@@ -86,6 +86,32 @@ backup taken 12 Aug 5:20 PM is still current."* - the first date proves the
check is running, the second says what is actually stored. Collapsing them was check is running, the second says what is actually stored. Collapsing them was
what made a healthy machine look neglected. what made a healthy machine look neglected.
### A host with several kinds is judged by its WORST one
A marker bay runs both an NTLARS backup and a part-marker backup. Reporting the
freshest of them would let a healthy kind mask a dead one: the part-marker
capture could stop for a month and the host would stay green on the strength of
its NTLARS row. So the row is reduced twice - the freshest revision per
`(host, kind)`, and then the **stalest** of those kinds decides the verdict.
Stalest **among kinds that can be judged**. A kind whose newest revision has
never been confirmed - `lastseenat` NULL, which means a backfilled row or one
written before the column existed - is not stale, it is UNMEASURABLE, and it
must not win the comparison. Treating NULL as the worst value looked right and
was wrong: a host with a healthy NTLARS backup plus one never-confirmed row
showed NO BADGE at all, because the verdict for an unconfirmed kind is `None`.
A working backup rendering as nothing is the one outcome the badge must never
produce. When EVERY kind is unmeasurable the host still reports which kind it
has, with a `null` verdict and no badge.
This is the same rule `staleness.py` applies per chain: a chain becomes
measurable the first time its PC posts, and says nothing until then.
Known consequence: a device retired from a PC leaves its last revision behind,
so that host stays stale until the revision is pruned. That is the honest
failure direction - it complains about a backup that genuinely is not running
any more, rather than going quiet about one.
`backupok` is deliberately **tri-state**. `null` means there is no revision for `backupok` is deliberately **tri-state**. `null` means there is no revision for
that host, or the check is disabled - and it renders as no badge, never green. that host, or the check is disabled - and it renders as no badge, never green.
"Never seen" must not read as healthy. "Never seen" must not read as healthy.

View File

@@ -8,6 +8,8 @@ a real caller (the GE-Enforce fleet agent) to it.
- Server code: `shopdb/core/api/collector.py` - Server code: `shopdb/core/api/collector.py`
- Computers schema + upsert: `plugins/computers/plugin.py` - Computers schema + upsert: `plugins/computers/plugin.py`
(`get_collector_schema` / `apply_collector_payload`) (`get_collector_schema` / `apply_collector_payload`)
- Printers schema + replace (observed print queues): `plugins/printers/plugin.py`
(same two hooks)
- Contract rationale: `docs/adr/ADR-006-collector-contract.md` - Contract rationale: `docs/adr/ADR-006-collector-contract.md`
--- ---
@@ -199,6 +201,8 @@ a column.
|---|---|---| |---|---|---|
| `hostname` (required) | string | Identity. Matches `Computer.hostname` (case-insensitive), then falls back to `Asset.assetnumber`. New asset created if no match. | | `hostname` (required) | string | Identity. Matches `Computer.hostname` (case-insensitive), then falls back to `Asset.assetnumber`. New asset created if no match. |
| `machinenumber` | string | Identifies the MACHINE this PC drives, never the PC. A new PC always takes `assetnumber = hostname`, and an existing PC's `assetnumber` is left alone. The number resolves a machine asset and builds a PC -> machine `controls` relationship; an unknown number warns rather than creating a machine. The placeholder `9999` and empty string link nothing. See "Machine links" below. | | `machinenumber` | string | Identifies the MACHINE this PC drives, never the PC. A new PC always takes `assetnumber = hostname`, and an existing PC's `assetnumber` is left alone. The number resolves a machine asset and builds a PC -> machine `controls` relationship; an unknown number warns rather than creating a machine. The placeholder `9999` and empty string link nothing. See "Machine links" below. |
| `deviceid` | string | Identifies the DEVICE hanging off this PC, read from `C:\Enrollment\asset-id.txt`. ONE file for every bay with no NTLARS/eDNC `MachineNo` registry to identify it. It names the asset and nothing else - it does NOT say what kind of device it is, because the pc-type already does - so the same file serves a part marker, a Keyence, a Genspect or anything declared later. SEPARATE from `machinenumber` on purpose: "which bay is this" and "which device is this" are different facts, and `machinenumber` is what GE-Enforce `TargetMachineNumbers` gates on, so naming a device there would silently stop every bay-gated manifest entry from matching. An unknown value warns rather than minting, and a value resolving to the wrong KIND of asset is refused with the asset named. See "PC -> device links" below. |
| `measuringtoolid` | string | The 0.12.0 name for the same thing, before the file was generalised, read from `C:\Enrollment\measuringtool-id.txt`. Accepted as an alias so a bay already staged with that file keeps reporting; `deviceid` wins when both arrive. Prefer `asset-id.txt` for anything new. |
| `pctype` | string | `gea-shopfloor-*` imaging type -> `Computer.computertypeid` via the configurable `pctypemap` settings. Unmapped value -> warning, not error. | | `pctype` | string | `gea-shopfloor-*` imaging type -> `Computer.computertypeid` via the configurable `pctypemap` settings. Unmapped value -> warning, not error. |
| `pcsubtype` | string | Accepted but not stored yet -> warning. | | `pcsubtype` | string | Accepted but not stored yet -> warning. |
| `serialnumber` | string | `Asset.serialnumber`. | | `serialnumber` | string | `Asset.serialnumber`. |
@@ -210,8 +214,6 @@ a column.
| `modelnumber` | string | `Computer.modelnumberid`, scoped to the vendor when known. Model row auto-created if missing. | | `modelnumber` | string | `Computer.modelnumberid`, scoped to the vendor when known. Model row auto-created if missing. |
| `osname` | string | `Computer.osid`. Controlled vocab: looked up in `operatingsystems`, NOT auto-created. Unknown value -> warning (row still written, `osid` left unset). | | `osname` | string | `Computer.osid`. Controlled vocab: looked up in `operatingsystems`, NOT auto-created. Unknown value -> warning (row still written, `osid` left unset). |
| `installedsoftware` | array of `{name, version}` | `ComputerInstalledApp` rows for applications shopdb already tracks. Unknown app name -> warning, skipped. | | `installedsoftware` | array of `{name, version}` | `ComputerInstalledApp` rows for applications shopdb already tracks. Unknown app name -> warning, skipped. |
| `defaultprinter` | string | The default printer's identifier (windows name / share / hostname / port IP). Resolved to a printer asset and linked PC -> printer as a `defaultprinter` relationship. Unresolved -> warning. |
| `printers` | array of strings | All installed network printer identifiers. Each resolves to a printer asset and is linked PC -> printer as a `connectedto` relationship (the default is skipped here since it already links as `defaultprinter`). Unresolved entries -> warning. |
Schema source of truth: `get_collector_schema` in `plugins/computers/plugin.py`. Schema source of truth: `get_collector_schema` in `plugins/computers/plugin.py`.
If you change the payload, change it there and re-check this table. If you change the payload, change it there and re-check this table.
@@ -261,31 +263,68 @@ so control still follows from controlling the marker, without two markers
contesting a link only one can hold. Backups from a marker PC file against the contesting a link only one can hold. Backups from a marker PC file against the
marker rather than the operation. marker rather than the operation.
### PC -> printer relationship sync ### PC -> device links (`asset-id.txt`)
When a payload carries `defaultprinter` and/or `printers`, the collector syncs Some pc-types mean the PC drives an attached DEVICE: a CMM, Keyence, Genspect or
`AssetRelationship` rows so a PC page shows its printers and a printer page shows wax-trace instrument, or a part marker. `SUBORDINATE_DEVICE_MAP` in
the PCs that use it (both render in the shared Relationships card). `plugins/computers/pctypemap.py` declares which, and a site can add its own
through a `subordinatedevice_<pctype>` setting without a code change.
- Resolution: each identifier is matched, first hit wins, against the printer's `C:\Enrollment\asset-id.txt` names that device. One file for every bay that has
`windowsname`, `hostname`, `sharename`, its asset number/name, then any active no NTLARS/eDNC `MachineNo` registry to identify it, holding one line: the
printer communications IP. Case-insensitive except the IP (exact). An device's `assetnumber`. It does NOT record what kind of device it is - the
identifier that resolves to nothing adds a warning and is skipped; it never pc-type already does - so a bay declared later needs no new file and no client
fails the whole push. change.
- Link types: the default printer links with `defaultprinter` (directional, PC
is the source); every other reported printer links with `connectedto` Resolution runs most-stable-identity first, and ADOPTS before it mints:
(symmetric). A printer that is both default and in `printers` links only as
the default. 1. **`deviceid`** - the asset named in `asset-id.txt`.
- Idempotent: re-reporting the same set creates no duplicate rows (an existing 2. A prior collector link from this PC, reactivated.
matching row is reactivated if it was archived, otherwise left as is). 3. An existing device this PC already controls that the collector did NOT
- Stale-link archive: on every push, collector-created links to printers no create - a legacy `MT-####` row, or one somebody made by hand. Adopting
longer reported are set inactive. Collector-created rows are tagged in stamps the label so it is ours from then on.
`assetrelationships.label = 'collector:printers'`; only tagged rows are ever 4. For a measuring tool only: the reported machine number, when it resolves to
archived, so links you create by hand in the UI are never touched. A payload one. This is the CMM case, where `cmmid.txt` already reports `CMM4`.
that omits BOTH printer keys leaves all existing printer links untouched 5. Mint, only when none of the above matched.
(report an empty `printers: []` to clear the auto links instead).
- Response: the collector response carries `printerlinkcount` and a **Why the file exists, and why it is step 1.** Every other identity here is
`printerlinks` list of `{assetid, relationshiptype}` for the links kept. derived from the PC. Step 2 looks for a link from THIS PC asset; step 5 mints
`<PC number>-<SUFFIX>`, and a PC's asset number is its hostname. All of them
survive a re-image and NONE of them survives a PC SWAP: a new hostname is a new
PC asset with no prior link and a predicted number that has never existed, so
the same physical device gets a second record while the first keeps its config
and backup history under a dead PC's name. That is how 43 legacy `MT-####`
tools ended up shadowed by minted twins, three records deep in places. Treat
anything minted as a placeholder to be reconciled.
Two guards on the file's contents:
- An **unresolvable** value warns and links nothing, rather than inventing a
phantom device nobody can account for.
- A value resolving to the **wrong kind** of asset is refused, naming the asset
it hit. A machine number pasted into `asset-id.txt` would otherwise file that
MACHINE under a device label the collector also owns - a link that reads as an
instrument, or as a marker, everywhere downstream.
A part marker additionally stays `partof` the operation from `machinenumber`,
because several markers serve one operation. Naming the marker in `asset-id.txt`
changes which marker asset the PC controls; it does not change which operation
that marker files under.
### PC -> printer relationship sync (REMOVED)
**A collector report has never been allowed to set a printer assignment, and as
of 2026-08-24 it no longer can.** `defaultprinter` and `printers` used to be
accepted here and written as `defaultprinter`/`connectedto` relationship rows.
That contradicted the rule stated under "Observed is not assigned" below, and
this document carried both claims at once while the code followed the wrong one.
If you are still sending either key: **your push still succeeds** - they are
ignored, and the response carries a warning naming them. Nothing else about your
payload changed. To report what a PC actually has, use
`POST /api/collector/printers`; to turn that into an assignment, a person calls
`POST /api/printers/assignments/seed-from-observed/<assetid>` after reviewing the
comparison.
### pc-type mapping (configurable per site) ### pc-type mapping (configurable per site)
@@ -306,6 +345,7 @@ The fleet's classic-ASP reporter posts form fields to
|---|---| |---|---|
| `hostname` | `hostname` | | `hostname` | `hostname` |
| `machineNo` | `machinenumber` | | `machineNo` | `machinenumber` |
| `deviceId` | `deviceid` |
| `pcType` | `pctype` | | `pcType` | `pctype` |
| `serialNumber` | `serialnumber` | | `serialNumber` | `serialnumber` |
| `loggedInUser` | `loggedinuser` | | `loggedInUser` | `loggedinuser` |
@@ -321,6 +361,137 @@ classic reporter posts a full `networkInterfaces` array; the collector accepts
only one `ipaddress`, so pick the corp/routable NIC (see the corp-range gate in only one `ipaddress`, so pick the corp/routable NIC (see the corp-range gate in
the PowerShell below). the PowerShell below).
### Printers plugin field mapping (observed print queues)
`POST /api/collector/printers`. The other half of the printer story. ShopDB has
always known what a bay SHOULD have - `GET /api/printers/for-host/<hostname>`,
applied by `Set-ShopdbPrinters.ps1` - and has never known what it actually has.
This payload is that missing half. Same dispatcher, same auth, same audit row as
the computers collector; only the payload and the plugin differ.
- Schema + apply: `get_collector_schema` / `apply_collector_payload` in
`plugins/printers/plugin.py`.
- Client: `plugins/printers/client/Report-PrintersToShopDB.ps1` (SYSTEM context,
`Type=PS1` / DetectionMethod `Always` manifest entry, logs to
`C:\Logs\Shopfloor\report-printers-YYYYMMDD.log`, always exits 0).
- Storage: `printerobservedqueues`, one row per observed queue, owned by the
printers plugin. It is a separate table from the assignment on purpose - see
"Observed is not assigned" below.
The identity field is `hostname`. Unlike the computers payload this one is NOT
patch-style: `queues` is required, and the reported set replaces everything
previously recorded for that host.
| Payload field | Type | Server behaviour (`apply_collector_payload`) |
|---|---|---|
| `hostname` (required) | string | Identity of the report. `COMPUTERNAME` or its FQDN; matched case-insensitively. Rows are keyed by the NAME, so a bay that reports before its PC record exists still records everything - the report resolves to an asset the moment that record appears. An unknown hostname is a warning, never an error. |
| `queues` (required) | array of objects | Every real print queue on the host. This REPLACES the host's previous set. An empty array is a valid report meaning "this bay has no queues" and clears the host's rows. A missing key is rejected (400), because one client bug that dropped the field would otherwise erase the fleet's observed state host by host. |
| `queues[].queuename` (required) | string | Windows printer name. A queue with no name is skipped with a warning; a repeated name within one report is dropped with a warning (Windows cannot hold two queues of one name). |
| `queues[].drivername` | string | Driver name verbatim, as the INF spells it. Compared against the assignment's driver to detect drift. |
| `queues[].portname` | string | Windows port name. Also tried as a match key, since a port created outside the client is usually named after the host address. |
| `queues[].portaddress` | string | `PrinterHostAddress` of a TCP/IP port - an IP or FQDN. The PRIMARY key for matching a queue to a printer asset. Omit it for a non-TCP port (USB, WSD, redirected); such a queue is still worth reporting and matches on name alone. |
| `queues[].isdefault` | boolean | True on the one queue that is the user's default. If several arrive true, the first is kept and the rest are cleared with a warning - a bay has exactly one default, and keeping both would leave a seed picking one at random. |
| `queues[].isshared` | boolean | True when the queue is shared off this PC. Recorded, not acted on. |
| `observedat` | ISO-8601 datetime | Accepted and ignored, as is any other client timestamp. The server stamps `observedat` at ingest, one value for the whole report, so a bay with a wrong clock cannot report itself fresh or stale. |
Response `data` is the standard collector shape: `action` is always `updated`
(this endpoint replaces rows and creates no asset - calling an identical
re-report `noop` would hide that the bay is still checking in), `assetid` is the
resolved PC or `null`, `extra.queuecount` is how many rows were stored, and
`warnings` carries the soft problems above.
Schema source of truth: `get_collector_schema` in `plugins/printers/plugin.py`.
If you change the payload, change it there and re-check this table.
```
POST /api/collector/printers
X-API-Key: SECRET
Content-Type: application/json
{
"hostname": "WORKSTATION01",
"queues": [
{
"queuename": "Bay Label Printer",
"drivername": "Generic / Text Only",
"portname": "IP_192.0.2.40",
"portaddress": "192.0.2.40",
"isdefault": true
},
{
"queuename": "Office Laser",
"drivername": "HP Universal Printing PCL 6",
"portname": "IP_192.0.2.41",
"portaddress": "192.0.2.41",
"isdefault": false
}
]
}
```
#### The latest report replaces the previous one
This is CURRENT STATE, not history. Each report deletes every row previously
recorded for that hostname and writes the reported set in its place, inside the
dispatcher's transaction. Nothing accumulates, "what does this bay have" is
never a question about time, and a queue removed from a bay disappears from
ShopDB on the next cycle without anyone tidying up.
That has one sharp edge, and it belongs to the client: an empty `queues` array
is a legitimate report that wipes the host's rows. A client whose enumeration
FAILED must therefore send nothing at all rather than an empty list. Reporting
nothing loses one cycle; reporting `[]` after a WMI hiccup deletes real state
and reads as a bay that lost its printers. `Report-PrintersToShopDB.ps1` tracks
this with an `$enumerated` flag and exits without posting when both the cmdlet
and the WMI fallback failed.
#### Observed is not assigned
Stated plainly, because it is the whole point of keeping two tables:
**A collector report NEVER becomes an assignment.** Nothing on this path writes
a `usesprinter` or `defaultprinter` row. The moment a drifted bay's observed
state is treated as correct, enforcement stops meaning anything - a bay that
installed the wrong printer would make itself right simply by reporting it.
Observed state becomes assigned state only when a person asks for it, through
`POST /api/printers/assignments/seed-from-observed/<assetid>` (requires
`printers.edit`), after reviewing the comparison. That route refuses rather than
guesses: it will not seed from an ambiguous host, will not seed queues that
match no printer unless told to, and will not write an empty set.
#### Matching, and what "unknown" means
Nothing is matched at ingest - the raw observed strings are stored as reported,
and resolution to a printer asset happens at READ time, so a printer added to
ShopDB tomorrow matches yesterday's report without the bay reporting again.
At read time each queue is matched by PORT ADDRESS first (an IP or FQDN names
one device unambiguously), then by queue name. There is no fuzzier fallback: a
queue that matches nothing is reported as `unknown` rather than guessed, because
a wrong match seeds a wrong assignment, which is worse than no assignment.
#### Reading it back
| Endpoint | Purpose |
|---|---|
| `GET /api/printers/observed/<hostname>` | What the host last reported, every queue classified against the assignment it resolves to (`matching`, `drifted`, `extra`, `missing`, `unknown`), with `observedat`, a summary, and a `seedcandidate` preview. Read-only. Needs `printers.view`. |
| `POST /api/printers/assignments/seed-from-observed/<assetid>` | The one path from observed to assigned, human-triggered. Needs `printers.edit`. Normally posted against the MACHINE, so the assignment survives a reimage. |
#### Key delivery for the printers reporter
The reporter reads its server and its key from the registry the enforcement
client already provisions (`HKLM:\SOFTWARE\GE\ShopDB`, values `BaseUrl` and
`CollectorKey`), or from `-ApiUrl` / `-ApiKey` in the manifest entry's `Args`.
Nothing is baked into the script or the manifest JSON on the share - the same
rule as the computers reporter, for the same reason.
Server side, the key resolves per-plugin first: set `COLLECTOR_API_KEY_PRINTERS`
if you scope keys per collector, or rely on the shared `COLLECTOR_API_KEY`. A
site that has scoped the computers key per-plugin and set no printers key gets
401 on this endpoint until one of the two is in place. A `collector.ingest`
managed token works here exactly as it does for computers.
--- ---
## 2. Breaking change: querystring `api_key` removed ## 2. Breaking change: querystring `api_key` removed
@@ -466,6 +637,24 @@ function Get-ShopdbMachineNumber {
return $machineNumber return $machineNumber
} }
function Get-ShopdbDeviceId {
# The DEVICE hanging off this PC, for any bay with no MachineNo registry.
# Names the asset and not its type - the pc-type already says that.
# Deliberately its own file: machinenumber is what GE-Enforce
# TargetMachineNumbers gates on, so naming a device there would silently
# stop bay-gated entries from matching.
foreach ($f in @('C:\Enrollment\asset-id.txt',
'C:\Enrollment\measuringtool-id.txt')) { # 2nd = pre-0.13
if (Test-Path $f) {
try {
$v = (Get-Content $f -First 1 -ErrorAction Stop).Trim()
if ($v) { return $v }
} catch {}
}
}
return ''
}
function Get-ShopdbCorpIPv4 { function Get-ShopdbCorpIPv4 {
# Pick the corp/AESFMA NIC IP. Same allowed-range gate as # Pick the corp/AESFMA NIC IP. Same allowed-range gate as
# Report-AssetToShopDB.ps1 - update the ranges if the site re-VLANs. # Report-AssetToShopDB.ps1 - update the ranges if the site re-VLANs.
@@ -525,6 +714,7 @@ function Send-ShopdbCollectorReport {
if (-not $hostname) { $hostname = $env:COMPUTERNAME } if (-not $hostname) { $hostname = $env:COMPUTERNAME }
$machineNumber = Get-ShopdbMachineNumber $machineNumber = Get-ShopdbMachineNumber
$deviceId = Get-ShopdbDeviceId
$ipAddress = Get-ShopdbCorpIPv4 $ipAddress = Get-ShopdbCorpIPv4
$serialNumber = '' $serialNumber = ''
@@ -563,33 +753,17 @@ function Send-ShopdbCollectorReport {
try { $pcSubType = (Get-Content -LiteralPath 'C:\Enrollment\pc-subtype.txt' -First 1 -ErrorAction Stop).Trim() } catch {} try { $pcSubType = (Get-Content -LiteralPath 'C:\Enrollment\pc-subtype.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
} }
# --- Installed printers (Win32_Printer). The Default flag marks the one # --- No printer enumeration here, on purpose. This example used to collect
# default printer. We report each printer's port name (an IP or a queue # Win32_Printer and send defaultprinter/printers, which wrote ASSIGNMENT
# host for network printers) and fall back to the share/printer name, which # rows from a report - the one thing this path must never do. Report what a
# the collector resolves flexibly against printer windowsname/hostname/IP. --- # PC has to POST /api/collector/printers instead (Report-PrintersToShopDB.ps1
$defaultPrinter = '' # in the printers plugin does exactly that, and posts NOTHING when the
$printerIds = @() # enumeration fails, because an empty list would erase the host's rows). ---
try {
$printers = Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop
foreach ($p in $printers) {
if ($p.Local) { continue } # skip local-only (XPS/PDF/OneNote)
# Prefer the port name (IP or queue host); fall back to ShareName,
# then the printer Name.
$identity = $p.PortName
if (-not $identity) { $identity = $p.ShareName }
if (-not $identity) { $identity = $p.Name }
if (-not $identity) { continue }
$printerIds += $identity
if ($p.Default) { $defaultPrinter = $identity }
}
$printerIds = @($printerIds | Select-Object -Unique)
} catch { Write-CollectorLog "WARN printer read failed: $($_.Exception.Message)" }
# --- Build payload. Field names MUST match get_collector_schema exactly. --- # --- Build payload. Field names MUST match get_collector_schema exactly. ---
$payload = @{ hostname = $hostname } $payload = @{ hostname = $hostname }
if ($defaultPrinter) { $payload['defaultprinter'] = $defaultPrinter }
if ($printerIds.Count) { $payload['printers'] = $printerIds }
if ($machineNumber) { $payload['machinenumber'] = $machineNumber } if ($machineNumber) { $payload['machinenumber'] = $machineNumber }
if ($deviceId) { $payload['deviceid'] = $deviceId }
if ($pcType) { $payload['pctype'] = $pcType } if ($pcType) { $payload['pctype'] = $pcType }
if ($pcSubType) { $payload['pcsubtype'] = $pcSubType } if ($pcSubType) { $payload['pcsubtype'] = $pcSubType }
if ($serialNumber) { $payload['serialnumber'] = $serialNumber } if ($serialNumber) { $payload['serialnumber'] = $serialNumber }
@@ -703,8 +877,13 @@ fleet's key is scoped to the computers collector only.
| `No collector registered for plugin computers` | 404 | The computers plugin is disabled or not loaded on that instance. Enable it. | | `No collector registered for plugin computers` | 404 | The computers plugin is disabled or not loaded on that instance. Enable it. |
| `hostname is required` / `No data provided` | 400 | Empty body, non-JSON body, or missing identity field. Check `-ContentType 'application/json'` and that `hostname` is set. | | `hostname is required` / `No data provided` | 400 | Empty body, non-JSON body, or missing identity field. Check `-ContentType 'application/json'` and that `hostname` is set. |
| `Internal error processing collector payload` | 500 | Generic by design - the server does not leak the cause to the caller. The real error (DB, unexpected exception) is in the server log (`Collector upsert failed for computers`). Check there. | | `Internal error processing collector payload` | 500 | Generic by design - the server does not leak the cause to the caller. The real error (DB, unexpected exception) is in the server log (`Collector upsert failed for computers`). Check there. |
| `No collector registered for plugin printers` | 404 | The printers plugin is disabled or not loaded on that instance. Enable it. A lean site without it simply does not collect observed queues. |
| `queues is required; send an empty array for a host with no queues` | 400 | The printers payload omitted `queues` entirely. Absent and empty are not the same thing here - see "The latest report replaces the previous one". |
| A bay's observed queues all vanished | 200 | It reported `queues: []`, which legitimately clears the host. Check the client log for `posting NOTHING` (a failed enumeration correctly sends nothing) versus `reporting an empty set`. If the bay really has queues, the enumeration on that host is the thing to fix. |
| Observed queues show as `unknown` | 200 | The queue matched no printer asset by port address or by name. Add the printer to ShopDB (with its IP) and re-read - matching happens at read time, so the bay does not need to report again. Never seeded automatically, by design. |
| `warnings` present but `action` is created/updated | 200 | Soft issues only; the row WAS written. Common: unmapped `pctype` (fix the pctypemap setting), unknown `osname` (add it to the `operatingsystems` vocab), unknown app name, `pcsubtype` not stored. No action needed unless the warning matters to you. | | `warnings` present but `action` is created/updated | 200 | Soft issues only; the row WAS written. Common: unmapped `pctype` (fix the pctypemap setting), unknown `osname` (add it to the `operatingsystems` vocab), unknown app name, `pcsubtype` not stored. No action needed unless the warning matters to you. |
Client-side log for the fleet reporter: `C:\Logs\Shopfloor\collector-YYYYMMDD.log`. Client-side log for the fleet reporter: `C:\Logs\Shopfloor\collector-YYYYMMDD.log`;
for the printer-queue reporter: `C:\Logs\Shopfloor\report-printers-YYYYMMDD.log`.
Server-side: the Flask app log (the collector logs upsert failures and per-plugin Server-side: the Flask app log (the collector logs upsert failures and per-plugin
schema failures there). schema failures there).

View File

@@ -138,7 +138,8 @@ read back through the API.
| `setup_complete` | `false` | Set true once the first-run wizard finishes; gates the `/setup` route. | | `setup_complete` | `false` | Set true once the first-run wizard finishes; gates the `/setup` route. |
| `employee_directory_mode` | `selfhosted` | `selfhosted` (tables in this app) or `external` (a separate HR database, see `EMPLOYEE_DB_*`). | | `employee_directory_mode` | `selfhosted` | `selfhosted` (tables in this app) or `external` (a separate HR database, see `EMPLOYEE_DB_*`). |
| `usb_directory_mode` | `selfhosted` | `selfhosted` or `external` (a separate `cmmc_usb` database, see `CMMC_USB_DB_*`). | | `usb_directory_mode` | `selfhosted` | `selfhosted` or `external` (a separate `cmmc_usb` database, see `CMMC_USB_DB_*`). |
| `site_base_url` | (empty) | Public base URL (scheme + host) for QR codes and absolute links. Blank = use the browsing origin. | | `audit_retentiondays` | `0` | Delete audit entries older than this many days. 0 keeps everything forever. Saving a period deletes nothing by itself: purge from this page or run `flask audit purge --apply`. |
| `site_base_url` | (empty) | Public base URL for QR codes and absolute links. Scheme + host, optionally including the subpath a site is served under (`https://host/shopdb`). Blank = use the browsing origin. |
| `facility_name` | (empty) | Facility name in the dashboard header. Blank = frontend falls back to `ShopDB`. | | `facility_name` | (empty) | Facility name in the dashboard header. Blank = frontend falls back to `ShopDB`. |
| `pc_access_domain` | `device.geaerospace.net` | Domain appended to a PC hostname for remote-access links. Blank = hostname as-is. | | `pc_access_domain` | `device.geaerospace.net` | Domain appended to a PC hostname for remote-access links. Blank = hostname as-is. |
| `employeeid_pattern` | `^\d{9}$` | Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never 500s. | | `employeeid_pattern` | `^\d{9}$` | Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never 500s. |
@@ -196,12 +197,21 @@ The batch generator at `/print/asset-label-batch/<assettype>` (reached from the
### map ### map
**SUPERSEDED as of 0.11.0.** A blueprint and its pixel dimensions are properties
of a LEVEL now, not of the site (ADR-017), because a site can have more than one
building and a building more than one floor. Manage them in
**Settings > Buildings and levels**, or through `/api/maplevels`.
These four keys are still present so that downgrading finds the blueprint it had,
and the levels migration copied their values onto the first level. Editing them
changes nothing on the map.
| Key | Default | Notes | | Key | Default | Notes |
|-----|---------|-------| |-----|---------|-------|
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Floor Map. | | `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Superseded by `maplevels.blueprintlight`. Retained for downgrade only. |
| `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (dark theme). | | `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Superseded by `maplevels.blueprintdark`. |
| `map_width` | `3300` | Blueprint native width in pixels. | | `map_width` | `3300` | Superseded by `maplevels.mapwidth`. |
| `map_height` | `2550` | Blueprint native height in pixels. | | `map_height` | `2550` | Superseded by `maplevels.mapheight`. |
### integrations ### integrations
@@ -278,23 +288,32 @@ Three flows use it:
### audit ### audit
| Key | Default | Notes | Retention lives in the `site` section as `audit_retentiondays`. It was
|-----|---------|-------| documented here as `audit_retention_days` as well, which was a second key that
| `audit_retention_days` | `90` | Days to retain audit logs (0 = keep forever). | nothing read - the settings page wrote to it and retention stayed off. One key
now, and the page writes that one.
### auth ### auth
**SAML is not implemented yet.** These keys exist so a site can be configured
ahead of the feature, and the settings page says so on screen. Nothing reads
them today: there is no SAML login route and no assertion is accepted, so
sign-in is local accounts only whatever is set here. In particular
`saml_allow_local_login` does NOT disable password login - the toggle is locked
on until there is another way in, because a control that appears to close the
local door while it stays open is worse than no control.
| Key | Default | Notes | | Key | Default | Notes |
|-----|---------|-------| |-----|---------|-------|
| `saml_enabled` | `false` | Enable SAML SSO. | | `saml_enabled` | `false` | Reserved. Enable SAML SSO once it ships. |
| `saml_idp_metadata_url` | (empty) | SAML IdP metadata URL. | | `saml_idp_metadata_url` | (empty) | Reserved. SAML IdP metadata URL. |
| `saml_entity_id` | (empty) | SAML SP entity id. | | `saml_entity_id` | (empty) | Reserved. SAML SP entity id. |
| `saml_acs_url` | (empty) | SAML Assertion Consumer Service URL. | | `saml_acs_url` | (empty) | Reserved. SAML Assertion Consumer Service URL. |
| `saml_allow_local_login` | `true` | Allow local username/password login when SAML is on. | | `saml_allow_local_login` | `true` | Reserved, and locked on. Local login cannot be turned off while it is the only way in. |
| `saml_auto_create_users` | `true` | Auto-create users on first SAML login. | | `saml_auto_create_users` | `true` | Reserved. Auto-create users on first SAML login. |
| `saml_admin_group` | (empty) | SAML group name that grants the admin role. | | `saml_admin_group` | (empty) | Reserved. SAML group name that grants the admin role. |
**Personal API tokens.** Besides login JWTs and SAML, a user may create **Personal API tokens.** Besides login JWTs, a user may create
personal API tokens (PATs) for scripts and integrations, from Settings > API personal API tokens (PATs) for scripts and integrations, from Settings > API
Tokens (or `POST /api/apitokens`). A PAT is sent like a JWT Tokens (or `POST /api/apitokens`). A PAT is sent like a JWT
(`Authorization: Bearer shopdb_pat_...`), authenticates as its owning user (`Authorization: Bearer shopdb_pat_...`), authenticates as its owning user
@@ -313,6 +332,21 @@ intersected with the owner's live permissions at use time, and suspends the
admin bypass, so it is denied on role-gated (admin-only) endpoints and on import admin bypass, so it is denied on role-gated (admin-only) endpoints and on import
mode. Use an unscoped token for admin-only work and imports. mode. Use an unscoped token for admin-only work and imports.
### tools
| Key | Default | Notes |
|-----|---------|-------|
| `securesend_allow_guests` | `false` | Let people with no account create Secure Send links. Public (readable before login) so the create page can show a guest the form instead of a sign-in wall. Off by default: a guest secret has no creator, so the audit trail can say when and from where but never who. Receiving a secret never needs an account either way. |
Turning this on makes creation an unauthenticated write, bounded at 10 links per
address per 10 minutes. A signed-in user still needs the `tools.secrets`
permission, so a member without it cannot create one while a guest can - opening
the door to guests is a decision about the door, not about revoking what a role
holds. The setting fails closed if it cannot be read.
See [SECURE-SEND.md](SECURE-SEND.md) for the whole feature, including why the
server cannot recover a secret and what the size limit is doing.
### identifiers (dynamic) ### identifiers (dynamic)
One boolean key per asset identifier per asset type, keyed One boolean key per asset identifier per asset type, keyed

View File

@@ -40,6 +40,16 @@ Recorded in the comment block in `shopdb/__init__.py`:
| 0.18.0 | Added `DISPLAY_ROLES`, `DISPLAY_ROLE_PATHS` and `normalize_display_role`, and finally exported `DashboardDefault`, which 0.17.0 imported but left out of `__all__`. The role vocabulary became the kiosk's own - `Dashboard`, `Lobby`, `3DPrintRoom` - so a plugin holding its own copy of that map reads core's instead of drifting from it | additive surface (minor) | | 0.18.0 | Added `DISPLAY_ROLES`, `DISPLAY_ROLE_PATHS` and `normalize_display_role`, and finally exported `DashboardDefault`, which 0.17.0 imported but left out of `__all__`. The role vocabulary became the kiosk's own - `Dashboard`, `Lobby`, `3DPrintRoom` - so a plugin holding its own copy of that map reads core's instead of drifting from it | additive surface (minor) |
| 0.19.0 | **BREAKING.** `get_dashboard_widgets` returns DATA AND SHAPE, not a component name. The old shape (`name` + `component` + `size`) named a Vue component per widget, which cannot survive a lean build - a plugin's component may never be staged into the frontend bundle (ADR-013) - and five plugins were declaring widgets that pointed at components nobody had written. A card now declares `id` / `title` / `endpoint` / `render` / `severity` / `permission` / `empty` / `position`; see PLUGIN-HOOKS.md. Also added `send_upload` so a plugin serving user-supplied bytes gets the headers that keep an SVG from running as script | **contract change (minor, pre-1.0)** | | 0.19.0 | **BREAKING.** `get_dashboard_widgets` returns DATA AND SHAPE, not a component name. The old shape (`name` + `component` + `size`) named a Vue component per widget, which cannot survive a lean build - a plugin's component may never be staged into the frontend bundle (ADR-013) - and five plugins were declaring widgets that pointed at components nobody had written. A card now declares `id` / `title` / `endpoint` / `render` / `severity` / `permission` / `empty` / `position`; see PLUGIN-HOOKS.md. Also added `send_upload` so a plugin serving user-supplied bytes gets the headers that keep an SVG from running as script | **contract change (minor, pre-1.0)** |
| 0.20.0 | The `Asset` model gained `levelid` (which floor plan its `mapx`/`mapy` are pixels of) and `mapverifiedat`; `Location` gained `levelid` too, and `resolve_asset_position` now returns the `levelid` belonging to whichever source supplied the coordinates. A plugin that writes a map position MUST write the level with it - a position without one renders as "level unknown" rather than being drawn on the default drawing, because a marker on the wrong floor plan looks correct (ADR-017) | additive surface (minor) | | 0.20.0 | The `Asset` model gained `levelid` (which floor plan its `mapx`/`mapy` are pixels of) and `mapverifiedat`; `Location` gained `levelid` too, and `resolve_asset_position` now returns the `levelid` belonging to whichever source supplied the coordinates. A plugin that writes a map position MUST write the level with it - a position without one renders as "level unknown" rather than being drawn on the default drawing, because a marker on the wrong floor plan looks correct (ADR-017) | additive surface (minor) |
| 0.21.0 | Added the `get_guest_grantable_permissions` hook: a plugin names the WRITE permissions a site may grant to the seeded `guest` role, which is who anyone without a token now holds. Reads (`.view`/`.read`) are grantable without a plugin saying anything; anything else is refused. Also exposed `require_any_permission` on `shopdb.api` for a read two different rights should reach | additive optional hook + surface (minor) |
| 0.22.0 | Added the `register_import_hook` surface: a plugin owns a VIRTUAL COLUMN on an importable CSV table - a header the bulk importer accepts but never writes - plus a post-write row hook that turns the value into a plugin concept. The computers plugin uses it for `machineassetnumber` on `computers.csv`, which builds the PC->machine controls link, so a site writes the machine number in the PC's own row instead of hand-building a separate relationships sheet. Core never learns what the column means. `RowWarning` is exported with it so a hook can report a soft failure without aborting the import | additive surface (minor) |
| 0.23.0 | Added `mint_service_token`, `list_service_tokens` and `revoke_service_tokens` so a plugin can provision, list and revoke a single-scope managed service token without importing core token internals. The endpoints plugin uses them for a UI-managed, regenerable agent key scoped to `collector.ingest.endpoints` alone - narrow on purpose, so rotating or revoking it cannot disturb the computers or GE-Enforce collectors, and revocation is an instant kill switch on agent ingest | additive surface (minor) |
| 0.24.0 | Added `collector_authorized(pluginname)` so a plugin can gate an agent-facing route that lives OUTSIDE the generic collector blueprint on the same per-plugin key and kill switch the ingest route uses. The endpoints command channel needs it: the agent long-polls `/api/endpoints/commands/next` and posts results back, and those are agent callbacks rather than JWT routes. Without it a plugin would reach into core collector internals to authorize them | additive surface (minor) |
| 0.25.0 | Added `caller_holds(permission)`, which answers whether the current caller holds a permission WITHOUT refusing the request - the complement to `require_permission` for a route that serves everyone but should only offer what the caller can act on. The endpoints services panel is the case: it emitted Start/Stop row actions to every reader, because the emission never consulted the caller. Clicking them was always refused server-side, so nothing was exploitable, but offering a read-only viewer controls over a shop-floor PC is the wrong signal, and it becomes a real problem once a site grants that panel to guests. Guests are included in the answer, since a signed-out visitor holds whatever the guest role was granted | additive surface (minor) |
| 0.26.0 | Dashboard cards may declare `shortname`. The dashboard merged its per-source cards into one worst-first queue, and a card's `title` is written as a heading ("Endpoints with crashes or shutdowns") which as a row badge ran wider than the hostname it qualified. Optional, defaults to the title, so a plugin that does not declare one is unaffected | additive field (minor) |
| 0.27.0 | Added `Script` so a plugin can read the ShopDB-hosted script library that backs endpoint `run_script` delivery without importing core model internals. The endpoints plugin resolves a delivered script by name and serves its body by digest to the agent | additive surface (minor) |
| 0.29.0 | Added an optional `group` to `get_asset_panels`, naming which detail-page tab a panel belongs to: `details` (the default) / `monitors` / `software` / `activity`. The detail pages are tabbed, and a page that placed panels by knowing their ids would defeat the hook. Additive: an absent or unrecognised group resolves to `details` server-side, so every existing plugin keeps working with no change | additive surface (minor) |
| 0.28.0 | Added `client_ip` and `strip_port` so a plugin does not hand-roll caller-IP extraction. Two plugins had, and both read the first `X-Forwarded-For` hop, which the caller supplies - so a rate limit keyed on it was defeated by rotating the header. The helper resolves from `remote_addr`, which ProxyFix has already filled from the header when `TRUSTED_PROXY_COUNT` says a proxy is really in front | additive surface (minor) |
| 0.31.0 | The asset-panel vocabulary a plugin may declare grew four optional things: two more tab groups, `serviceprocess` and `reports`, beside the 0.29.0 set; a `format: 'datetime'` on a column or field, so a panel stops shipping pre-formatted date strings the browser cannot re-localise; a `map.body` key, so a keyvalue field can name where its value lives in a nested payload; and a `tabs` section that carries `columns`/`rows`/`empty` and renders as a table, so one card can hold two tables that share no key (volume capacity and physical drive health are the case). All optional and all resolved server-side when absent, so a plugin declaring none of them is unaffected | additive surface (minor) |
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0,
0.11.0, 0.16.0, 0.18.0 and 0.19.0; 0.12.0 through 0.15.0 and 0.17.0 are recorded 0.11.0, 0.16.0, 0.18.0 and 0.19.0; 0.12.0 through 0.15.0 and 0.17.0 are recorded

100
docs/DEPENDENCY-SECURITY.md Normal file
View File

@@ -0,0 +1,100 @@
# Dependency security
Two scripts, one question each.
| question | script | output |
|---|---|---|
| what are we running | `scripts/generate_sbom.py` | `sbom.cdx.json`, CycloneDX 1.6, shipped with every release |
| are we exposed | `scripts/scan_sbom.py` | a report, and an exit code |
The SBOM was already generated and installed onto every server. Nothing read
it, so "are we exposed to this advisory" was still a person going and looking.
The scanner closes that.
## Running it
```bash
venv/bin/python scripts/scan_sbom.py # report, exit 0
venv/bin/python scripts/scan_sbom.py --gate # exit 1 on a shipped finding
venv/bin/python scripts/scan_sbom.py --json out.json # machine-readable too
```
With no `--sbom`, it generates one first, so there is never a stale input.
It needs outbound HTTPS to `api.osv.dev`. **Run it where the repository lives,
not on a customer's server** - a deployed instance is frequently air-gapped and
has nothing to query. That is also why the SBOM ships as a file: it is the
artefact a security team scans on their own terms.
## Why OSV
It takes a purl and returns the advisories that apply. No account, no API key,
no agent installed anywhere, no vulnerability database to keep current. The
SBOM is already the input it wants, so this is a lookup against what shipped
rather than a second scan of the tree that could disagree with it.
## Shipped and build-only are not the same finding
Two thirds of the components in the SBOM are build tooling - bundlers, test
runners, their transitive dependencies. They never reach a server, and the SBOM
already marks them `scope: excluded`.
Both are reported. **Only shipped components can fail a build.**
This is deliberate. At the time of writing, the highest-severity match in the
whole tree is a CRITICAL against the test runner, which is not installed
anywhere a release reaches. A gate that fails on that teaches everybody to pass
`--no-verify` within a month, and then it catches nothing at all.
## Severity is taken, not computed
Where an advisory records a plain word - GHSA gives LOW, MODERATE, HIGH,
CRITICAL - that word is used. Where there is only a CVSS vector, the report says
UNKNOWN rather than parsing the vector into a score it might get wrong.
UNKNOWN is shown and never gates. A guessed score is worse than an honest gap,
because somebody would act on it.
## The allowlist is a record, not a mute button
`scripts/vulnerability-allowlist.json` holds advisories that were reviewed and
accepted. An entry carries the advisory id, the reason, who accepted it, the
date, and what would invalidate the reasoning.
A good reason names the precondition that is absent. The file has held exactly
one entry so far, and how it ended is the point.
A PyJWT advisory rated HIGH - algorithm confusion, forged HS256 tokens - needs a
verifier configured with both symmetric and asymmetric algorithms AND a raw JSON
JWK passed as the key. This application configures neither: there is no
`JWT_ALGORITHM` or `JWT_DECODE_ALGORITHMS` anywhere in the tree, so
flask-jwt-extended runs HS256-only against a string secret. That was written
down, with `invalidatedby` recording what would make it untrue - SSO issuing
tokens verified against a JWK.
Then PyJWT was upgraded, the advisory stopped matching, and the entry was
deleted. The allowlist is empty again, which is the state to aim for.
`invalidatedby` is the field that earns its keep. A reason stops being true when
the code that made it true changes, and nobody re-reads an allowlist looking for
that. Write down what would break it.
Upgrading is almost always cheaper than defending a decision to skip it. Do not
add an entry to make a build pass.
## What the report tells you to do
The report ends with a per-component upgrade rollup: the smallest upgrade that
clears every advisory against that component, and how many it clears.
"Smallest" is doing real work there. An advisory patched on several release
branches lists a fix per branch - nanoid's is fixed in both 3.3.18 and 5.1.16 -
and taking the highest recommends a major upgrade when a patch on the current
branch would do. Where the package is transitive that major may not even be
reachable: nanoid arrives under postcss, pinned `^3.3.11`, a range 5.x cannot
satisfy. So the target is the lowest published fix above the version in use.
Fixes on older branches are ignored rather than offered as a downgrade.
A target on a different major version is still labelled `MAJOR`, but now that
label means something: no fix exists on the current branch, so the upgrade is
genuinely a different job - it can be ESM-only, or drop an API.

View File

@@ -27,7 +27,7 @@ pwsh scripts/build-offline-bundle.ps1 -Version 0.7.0
``` ```
This builds `shopdb-flask:0.7.0` (frontend + all Python deps baked in), pulls This builds `shopdb-flask:0.7.0` (frontend + all Python deps baked in), pulls
`mysql:8.0`, and writes: `mysql:8.4`, and writes:
- `shopdb-stack-0.7.0.tar.gz` - both images in one archive - `shopdb-stack-0.7.0.tar.gz` - both images in one archive
- `shopdb-stack-0.7.0.tar.gz.sha256` - checksum to verify after transfer - `shopdb-stack-0.7.0.tar.gz.sha256` - checksum to verify after transfer

View File

@@ -187,7 +187,13 @@ docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_fl
Verify a restore quarterly. Back up the `instance/` directory alongside the DB; Verify a restore quarterly. Back up the `instance/` directory alongside the DB;
it holds uploaded floor plans, branding, `plugins.json`, and tokens that are not it holds uploaded floor plans, branding, `plugins.json`, and tokens that are not
in MySQL. See [docs/BACKUP-RESTORE.md](BACKUP-RESTORE.md) for the full backup and in MySQL. Under compose it is the `instance_data` named volume:
```bash
docker compose run --rm -v "$PWD:/backup" api tar czf /backup/instance-$(date +%F).tar.gz -C /app/instance .
```
See [docs/BACKUP-RESTORE.md](BACKUP-RESTORE.md) for the full backup and
restore procedure. restore procedure.
## Step 9: Updates ## Step 9: Updates
@@ -197,8 +203,53 @@ git pull origin main
docker compose build api docker compose build api
docker compose up -d api docker compose up -d api
docker compose exec api flask db upgrade docker compose exec api flask db upgrade
docker compose exec api flask plugin upgrade-all
docker compose exec api flask db current # must match `flask db heads`
``` ```
`plugin upgrade-all` runs the per-plugin Alembic chains (ADR-008), which
`db upgrade` does NOT touch. The air-gap stack runs both in its one-shot
`migrate` service; a connected stack has to ask.
`up -d api` REPLACES the container. Everything the site has written lives in the
`instance_data` volume for exactly this reason: the enabled-plugin list
(`plugins.json`), uploaded floor plans and branding, model and application
images, employee photos, warranty proofs, slides, printed-part files, and the
Dell OAuth token. If your stack predates that volume, those files are in the old
container's writable layer and an update discards them. Move them across ONCE,
before the next rebuild:
```bash
docker compose cp api:/app/instance ./instance-rescued # BEFORE pulling new code
docker compose up -d api # creates the volume
docker compose cp ./instance-rescued/. api:/app/instance
docker compose exec -u root api chown -R shopdb:shopdb /app/instance
docker compose restart api
```
The `chown` is not optional. `docker compose cp` writes the files with the
copying user's numeric uid, which is only `shopdb` by coincidence if your host
account happens to be uid 1000. Get it wrong and the site reads its restored
files fine and cannot write new ones.
Then run the migrations and confirm the plugins came back:
```bash
docker compose exec api flask db upgrade
docker compose exec api flask plugin upgrade-all
docker compose exec api flask db current # must match `flask db heads`
docker compose exec api flask plugin list # the site's plugins, enabled
```
Restore `instance/` BEFORE `plugin upgrade-all`: that command works from
`plugins.json`, so running it against an empty instance directory upgrades
nothing and reports success.
The symptom of having missed this is a site that comes back with its plugins
disabled and image URLs that 404: the MySQL rows survived, the files did not.
Re-enabling by hand works, but `flask plugin apply-profile <profile.json>` puts
the same list back in one command and is the thing to keep in version control.
The framework's `__contract_version__` may have moved. Check `docs/adr/` for any new ADRs since the last update. If an ADR introduces a breaking change, the upgrade may require coordinated work; the ADR's "Consequences" section documents it. See [docs/UPGRADE.md](UPGRADE.md) for the full upgrade procedure, including re-seeding and the v0.5+ floor-plan note. The framework's `__contract_version__` may have moved. Check `docs/adr/` for any new ADRs since the last update. If an ADR introduces a breaking change, the upgrade may require coordinated work; the ADR's "Consequences" section documents it. See [docs/UPGRADE.md](UPGRADE.md) for the full upgrade procedure, including re-seeding and the v0.5+ floor-plan note.
## Common issues ## Common issues

45
docs/DEV-BUILD-TEST.md Normal file
View File

@@ -0,0 +1,45 @@
# ShopDB dev build - quick test
Two dev builds you can stand up on a Windows box with Docker Desktop. You run
one PowerShell script: it checks your dependencies (offers to winget-install
anything missing), then PULLS a prebuilt image from GHCR and starts the stack in
Docker. Nothing is compiled on your machine - no Node, no npm, no source build -
so it works on a box that has only Docker installed.
## What you need
- Windows with Docker Desktop running.
- Git for Windows (`winget install Git.Git`).
- A GitHub login for this private repo/registry already stored on the box. If
you have cloned this repo before, or signed in to GitHub in Visual Studio or
VS Code, you already have it - the script uses that stored credential
automatically. If Docker cannot pull the image, sign in once with
`docker login ghcr.io` (use a GitHub username and a personal access token that
has `read:packages`), then re-run the launcher.
## Get the launcher
You will be sent `Start-ShopDBDev.ps1` directly. Save it anywhere (for example
your Downloads folder) and open PowerShell in that folder.
## Bleeding edge (main, the trunk)
powershell -ExecutionPolicy Bypass -File .\Start-ShopDBDev.ps1 -Channel edge
Comes up at https://localhost:5011. Red DEV badge in the sidebar. Pulls the
`edge` image.
## Stable (newest release tag)
powershell -ExecutionPolicy Bypass -File .\Start-ShopDBDev.ps1 -Channel stable
Comes up at https://localhost:5001. No DEV badge. Pulls the `stable` image.
## Notes
- Both stacks can run at the same time - they use different ports and separate
Docker volumes, so they do not collide and each keeps its own database.
- Each launch pulls the latest image for the channel, so re-running the launcher
is how you pick up a new build. No local rebuild step.
- First page load shows a self-signed cert warning (expected for a dev box).
- Stop a stack: `docker compose -p shopdb-stable down` (or `shopdb-edge`).

View File

@@ -25,7 +25,7 @@ sane, then use manual for day-to-day work.
| --- | --- | --- | | --- | --- | --- |
| Python | 3.14 (64-bit) - matches CI, the container image and the Windows installer wheelhouse | `python --version` | | Python | 3.14 (64-bit) - matches CI, the container image and the Windows installer wheelhouse | `python --version` |
| Node.js | 18+ | `node --version` | | Node.js | 18+ | `node --version` |
| MySQL | 8.0 (or Docker, below) | `mysql --version` | | MySQL | 8.4 LTS (or Docker, below) | `mysql --version` |
| Git | any recent | `git --version` | | Git | any recent | `git --version` |
On Windows, install all of them with winget (accept each license, then On Windows, install all of them with winget (accept each license, then
@@ -293,7 +293,7 @@ section of the plugin lab for the full review checklist.
| --- | --- | | --- | --- |
| Frontend loads but every API call fails / CORS error | backend not on 5001 (`flask run --port 5001`), or `CORS_ORIGINS` missing `http://localhost:5173`. | | Frontend loads but every API call fails / CORS error | backend not on 5001 (`flask run --port 5001`), or `CORS_ORIGINS` missing `http://localhost:5173`. |
| App refuses to boot in production config | a required `.env` var (`SECRET_KEY`, `JWT_SECRET_KEY`, `DATABASE_URL`, `CORS_ORIGINS`) missing or a dev default. | | App refuses to boot in production config | a required `.env` var (`SECRET_KEY`, `JWT_SECRET_KEY`, `DATABASE_URL`, `CORS_ORIGINS`) missing or a dev default. |
| `flask db upgrade` error 1071 (key too long) | MySQL 5.6 without the `innodb_large_prefix`/Barracuda flags; use MySQL 8 for dev. | | `flask db upgrade` / `flask plugin upgrade-all` error 1071 (key too long) | MySQL 5.6 on the default Antelope file format, where an index prefix caps at 767 bytes - one utf8mb4 `VARCHAR(255)` is 1020. Start mysqld with `--innodb-file-format=Barracuda --innodb-large-prefix=ON` (both are also settable live with `SET GLOBAL`), keep `?charset=utf8mb4` on `DATABASE_URL`, and note that tables CREATED before those flags were on are silently `ROW_FORMAT=Compact` rather than `DYNAMIC` - 5.6 downgrades the row format with a warning instead of an error, so they keep the 767-byte cap until rebuilt with `ALTER TABLE <t> ROW_FORMAT=DYNAMIC`. MySQL 5.7+ and 8.0 need none of this. |
| Nav missing Machines/PCs/... | plugins not installed/enabled (step 2b), or the backend not restarted after enabling. | | Nav missing Machines/PCs/... | plugins not installed/enabled (step 2b), or the backend not restarted after enabling. |
| "No time zone found with key America/New_York" | `tzdata` not installed - `pip install -r requirements.txt` includes it. | | "No time zone found with key America/New_York" | `tzdata` not installed - `pip install -r requirements.txt` includes it. |
| npm/git/pip SSL error (`UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `unable to get local issuer certificate`) | corp proxy (Zscaler) intercepts HTTPS - point each tool at the corp root CA. See section 0b. | | npm/git/pip SSL error (`UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `unable to get local issuer certificate`) | corp proxy (Zscaler) intercepts HTTPS - point each tool at the corp root CA. See section 0b. |

View File

@@ -38,10 +38,12 @@ because the failure would otherwise be visible to the whole floor.
**Folder mode** predates the server and stays as a fallback for a site with no **Folder mode** predates the server and stays as a fallback for a site with no
ShopDB instance yet, or for content nobody wants in the database. ShopDB instance yet, or for content nobody wants in the database.
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, the > If `EventSaver.ini` is missing, or both `url` and `folder` are blank, there is
> binary falls back to a path compiled into `EventSaver.cs` - and that path > no source to read and the screensaver shows nothing. That is deliberate: the
> belongs to the site it was first built for. Ship the ini. A missing ini is not > compiled-in fallback used to be the path of the site it was first built for, so
> a neutral default. > a missing ini silently pointed a new site at someone else's file server. It is
> now empty, and failing visibly beats displaying the wrong site's slides. Ship
> the ini.
## What decides the running order ## What decides the running order
@@ -98,8 +100,9 @@ covering something someone needed to see.
## Building it ## Building it
No SDK required - it compiles with the in-box .NET Framework compiler on any The source is `plugins/slides/client/EventSaver.cs`. No SDK required - it
Windows 10 or 11 machine: compiles with the in-box .NET Framework compiler on any Windows 10 or 11
machine:
``` ```
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^ C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^

135
docs/FLOOR-MAP.md Normal file
View File

@@ -0,0 +1,135 @@
# The floor map: buildings, levels and marker positions
The map shows where things physically are. This page is for whoever maintains it:
loading a floor plan, adding a second floor or a second building, and moving the
markers when the plan changes.
The reasoning behind the model is in
[ADR-017](adr/ADR-017-buildings-and-levels.md). This page is the operator's view.
## The model in three sentences
A **building** holds one or more **levels**. A level owns a floor-plan image (one
per theme) and the pixel size of that image. A marker's position is an X and Y in
the pixels of ONE level, so every position names the level it belongs to.
That last point is the whole design. Coordinates alone are meaningless: `1200,900`
is a spot on the ground floor and a different spot on the second floor. A marker
whose level is unknown shows as **level unknown** and is not drawn, because
drawing it on the default plan would look completely correct while pointing at
the wrong part of the building.
## Loading a floor plan
**Settings > Buildings and levels.**
1. Add a building if you do not have one. A single-building site needs exactly
one, and it can be called anything.
2. Add a level. Give it a name people use out loud - "Ground floor", "Mezzanine",
"Second floor" - and a sort order that puts the list in the order someone
would walk it. Name and order are separate on purpose: levels are not reliably
numbered, and leaving gaps in the order lets you insert one later without
renumbering everything.
3. Upload the plan, per theme. A light-theme image is enough to start; if there
is no dark version, the light one is used in both.
**The level takes its pixel size from the image you upload, while the level is
still empty.** Once markers are on it, an upload whose size does not match reports
the mismatch and changes nothing. That is deliberate - see below.
Supported: PNG, JPEG, GIF, WEBP and SVG. An SVG is served with headers that stop
it executing as script, so a plan exported from a CAD tool is safe to use.
## Placing markers
Two ways, both fine:
- **One at a time**, from the asset's own form: the map-position picker opens on
the level the marker is already on, and you can switch level inside the picker.
The saved position states which drawing it is on.
- **Many at once**, in the map editor. Filter to what you are working through -
unplaced, needs-review, or one level - search for a specific thing, place it,
and confirm. Placing a marker by hand counts as confirming it.
A location can carry a position too, which acts as the default for assets in it.
## When the floor plan changes
This is the case that needs care, and the reason the tooling exists.
**A new plan of the SAME dimensions**: upload it and you are done. Markers keep
their coordinates and still line up.
**A new plan of DIFFERENT dimensions, or a real move**: the coordinates are pixels
of the old image, so every marker is now in the wrong place relative to the
drawing. Do NOT fix this by editing the level's width and height - that just
reinterprets every existing position against a new scale.
Use **Recalibrate** in the map editor:
1. Pick two or more **landmarks** - features you can find on both the old and the
new plan. Corners of the building, a doorway, a column. Two is the minimum;
three or four spread across the plan is better.
2. For each, give the old coordinates and the new ones.
3. Review the **dry run**. It lists every marker's old and new position and flags
anything that would land off the drawing. Nothing has changed yet.
4. Apply it. The previous positions are snapshotted first.
The transform is worked out separately for the X and Y axes from your landmarks,
and never guessed from the image dimensions. That matters in the exact case this
was built for: a plan that gained a floor below it got taller without anything
being rescaled, and a size-derived scale would have stretched everything
vertically.
After a recalibration every affected marker is marked **needs review**, because a
transform is a good starting guess and nothing in the coordinates can tell you
which machines actually moved. Work the needs-review filter, correct what is off,
and confirm as you go.
## Undo
Every bulk change snapshots the positions first, and a restore snapshots before
it restores, so an undo is itself undoable. The history is in the map editor:
what caused each snapshot, when, and whether it has been restored. A restore puts
back the level and the review state, not only the coordinates.
Assets deleted since a snapshot are reported and skipped rather than failing the
whole restore.
## Adding a second building or a second floor
Add it, upload its plan, then move the markers that belong on it. In the map
editor, markers on other levels are badged, so you can see what still needs
moving.
Do not stack two floors onto one tall image. It looks simpler and it makes the
level implicit in the Y coordinate - "above 2550 means upstairs" - which nothing
can validate and which breaks the moment either plan is redrawn.
## Things that are not levels
- **Locations** are where something is in words ("Cell 4", "Gage lab"). A level is
which drawing. A location can name a level, but they are different things.
- **Business units** are organisational, not physical.
## Known limits
- The printer-installer map (the standalone program that installs printers by
clicking a floor plan) has one plan compiled into it and does not know about
levels. It is accurate for the default level only. See
[PRINTER-INSTALLER](PRINTER-INSTALLER.md) section 6.
- The first-run setup wizard still asks for one site-wide blueprint; those
settings no longer drive the map. Use Settings > Buildings and levels.
- Map PDF export covers the level you are looking at.
## For integrators
`GET /api/maplevels` is unauthenticated - the printer installer draws a plan
before anyone logs in - and returns every building with its levels, each level's
blueprint paths, native size and marker count, plus which level is the default.
Writes require an admin. Repositioning is `/api/mappositions/*` and needs
`assets.edit`. Full parameters in [API-REFERENCE](API-REFERENCE.md) and the
generated spec at `/api/docs`.
If you import positions from another system, send `levelid` with every
coordinate - see [IMPORT-API](IMPORT-API.md).

237
docs/GUIDES.md Normal file
View File

@@ -0,0 +1,237 @@
# Guides: writing procedures in ShopDB, and generating them from elsewhere
A guide is a step-by-step procedure stored in ShopDB: images, working
checklists, version history, and printable output. This is the reference for the
data model and the API, aimed at somebody generating guides programmatically -
from a script, an importer, or a model.
Not to be confused with the **knowledgebase** plugin, which is a link registry:
a description, a URL and keywords pointing at a document held somewhere else.
That one references documents; this one holds them. They stay separate, and
share only a tag vocabulary and the global search box.
---
## 1. The shape of a guide
A guide is an ordered list of typed **blocks**, stored as JSON. Not HTML, and
that is load-bearing:
- a tickable step needs a **stable id** to be remembered between visits
- **dark mode** needs semantic markup, not pasted colours
- **print** needs to know where a step begins so it does not split one across a
page
Free-form HTML gives none of those, and pasted styling fights the theme.
### Block types
| type | Fields | Notes |
|---|---|---|
| `heading` | `text` | Section heading |
| `paragraph` | `text` | Body text |
| `step` | `title`, `text` | Numbers itself by position among steps |
| `checklist` | `items[]` | The tickable one. Items need their own ids |
| `bullets` | `items[]` | Unordered list |
| `numbers` | `items[]` | Ordered list |
| `callout` | `text`, `title`, `variant` | `variant`: `info`, `warning`, `success` |
| `code` | `text`, `language`, `caption` | Never autocorrected; wraps rather than clips |
| `image` | `url`, `alt`, `caption` | `url` comes from the image upload endpoint |
| `table` | `header[]`, `rows[][]` | Simple grid |
Every block needs:
```json
{ "id": "b1", "type": "paragraph", "text": "..." }
```
**Ids must be unique and stable.** They are what a reader's tick is stored
against, so a save without them is refused, and changing one loses that item's
progress. Checklist items carry their own ids for the same reason:
```json
{ "id": "b4", "type": "checklist",
"items": [ { "id": "i1", "text": "Confirm the Verified date" } ] }
```
An unknown block type renders as nothing rather than an error, so a guide
written against a newer version stays readable on an older one.
### Inline text
Block text supports a deliberately small subset: `**bold**`, `*italic*`,
`` `code` ``, `[links](https://example.com)`, and line breaks. Nothing else -
no fonts, sizes or colours. Emphasis is semantic and the theme decides how it
looks, which is what makes dark mode work and keeps a guide written next year
matching one written today.
Text is escaped **before** formatting is applied, so no markup in the source can
reach the DOM. `javascript:` and `data:` links are dropped.
---
## 2. Creating a guide from a script
Every endpoint is listed in `docs/api-inventory.json`; this is the workflow.
```bash
TOK=$(curl -s -X POST http://localhost:5001/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"...","password":"..."}' | jq -r .data.access_token)
# 1. Create - always starts as a draft
curl -X POST http://localhost:5001/api/guides \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{
"title": "Replace a part marker controller",
"summary": "Restoring patterns and settings after a swap",
"tags": "partmarker, backups",
"blocks": [
{"id":"b1","type":"paragraph","text":"The new unit arrives **empty**."},
{"id":"b2","type":"callout","variant":"warning","title":"Before you start",
"text":"Check the marking head."},
{"id":"b3","type":"checklist","items":[
{"id":"i1","text":"Copy the snapshot path"},
{"id":"i2","text":"Confirm the pattern count"}]},
{"id":"b4","type":"code","language":"powershell",
"text":"Backup-PartMarker.ps1 -Force"}
]
}'
# 2. Publish - readers see nothing until this happens
curl -X POST http://localhost:5001/api/guides/12/publish \
-H "Authorization: Bearer $TOK"
```
A scoped API token works the same way as a user JWT.
### What the server rejects
Validation happens at the door rather than at render time, because a malformed
guide stored is a page that fails every time somebody opens it, long after the
client bug that caused it:
- a block with no `id`, or a duplicate `id`
- an unknown block `type` (the error lists the known ones)
- a checklist item with no `id`
- more than 500 blocks, or more than 2 MB of blocks
- text longer than 20,000 characters in one block
Errors name the offending block by position: `block 3: needs a stable id`.
---
## 3. Drafts, versions and publishing
Three separate things, and the distinction is what stops a live procedure
changing under somebody following it:
| | What it is |
|---|---|
| **Working copy** | The `guides` row. What the editor loads and saves |
| **Revisions** | Immutable snapshots in `guiderevisions`, written when content changes |
| **Published pointer** | Which revision READERS get |
- `PUT /api/guides/<id>` saves the working copy and writes a revision **only if
the content hash changed**. An autosave that changed nothing records nothing,
so the history stays readable. The response carries `saved: false`.
- The hash covers title, summary, tags **and** blocks - renaming or retagging is
a change a reader notices.
- `POST /api/guides/<id>/publish` **promotes an existing revision**. It does not
snapshot, so a publish cannot ship unsaved edits nobody reviewed.
- `POST /api/guides/<id>/restore/<revisionid>` brings old content back as a
**new** revision. History is never rewritten.
- Drafts are invisible to readers, to `GET /api/guides/<slug>` (404, not 403),
and to global search.
---
## 4. Images
```bash
curl -X POST http://localhost:5001/api/guides/images \
-H "Authorization: Bearer $TOK" -F file=@screenshot.png -F guideid=12
```
Returns `{"url": "/api/guides/images/5", ...}` for use as an image block's
`url`. PNG, JPEG, GIF and WebP, up to 8 MB. `guideid` is optional because an
image is pasted before a new guide has an id.
Bytes live in the database, not on disk: a row travels with a database backup,
whereas a file under `instance/` has been lost to a container rebuild on this
deployment before.
**Known gap:** images uploaded to a guide that is then abandoned are never
pruned.
---
## 5. Proofing
The editor runs checks continuously and warns rather than blocking. They exist
because none of these are spelling mistakes:
- a **smart quote or long dash in a code block** - autocorrect does this, the
command then fails, and it still looks right to the author (one-click fix)
- an empty checklist item, which reads as a step somebody forgot to write
- a heading with nothing under it, left behind by a reorganise
- an image with no alt text
- a leftover `TODO`, `TBD` or `FIXME`
- no summary or tags, which makes a guide hard to find
Publishing with unresolved errors asks for confirmation; it does not refuse. A
procedure that exists imperfectly beats one nobody wrote.
---
## 6. Printing
Print always renders **light**, whatever the screen theme, because a dark page
lays a full sheet of toner down and knocks the text out in white.
Also on paper: no buttons or progress bar, backgrounds dropped, steps and code
blocks kept whole across page breaks, code wrapped rather than clipped, and
checkboxes printed **empty** even where ticked on screen - a printout is a
worksheet somebody ticks by hand. The footer carries the version and the print
date, so a stale copy on a noticeboard is spottable.
---
## 7. Permissions
| Permission | Allows |
|---|---|
| `guides.view` | Read guides |
| `guides.create` | Create, and edit your OWN guide while it is unpublished |
| `guides.edit` | Edit any guide, including published ones |
| `guides.publish` | Publish or withdraw |
| `guides.delete` | Delete |
The split between `edit` and `publish` is deliberate: a technician can write a
procedure up while somebody else decides it is fit for the floor.
---
## 8. Converting existing documents
There is no importer yet. When one is written, the practical notes are:
- **Structured HTML converts cleanly.** The GE Aerospace KB template's
`alert alert-warning` divs map to warning callouts, `code-block` to code
blocks, `step` divs to steps.
- **Markdown converts well**: headings, fenced code, lists and tables all have
matching block types.
- **Plain-text runbooks convert poorly.** ASCII underlines and hand-indented
lists carry no structure, so everything lands as paragraphs and a person has
to mark up the steps afterwards. Worth converting anyway - the text is then
searchable and versioned - but budget the cleanup.
- Generate ids yourself and keep them stable across re-imports, or every
re-import resets readers' progress.
---
## See also
- `docs/api-inventory.json` - every endpoint with auth, params and an example
- `plugins/guides/services/blocks.py` - the validation rules, and why each exists
- `plugins/guides/services/authoring.py` - the single write path

View File

@@ -190,12 +190,28 @@ Common `machines` columns -> core Asset fields (same for every target plugin):
| `serialnumber` | `serialnumber` | | | `serialnumber` | `serialnumber` | |
| `machinestatusid` | `statusid` | remap via `machinestatus` -> asset statuses | | `machinestatusid` | `statusid` | remap via `machinestatus` -> asset statuses |
| `businessunitid` | `businessunitid` | remap via imported business units | | `businessunitid` | `businessunitid` | remap via imported business units |
| `mapleft` | `mapx` | | | `mapleft` | `mapx` | pixels of a LEVEL; send `levelid` with it |
| `maptop` | `mapy` | | | `maptop` | `mapy` | as above |
| n/a | `levelid` | which floor plan the coordinates belong to (ADR-017) |
| `machinenotes` | `notes` | | | `machinenotes` | `notes` | |
| `dateadded` | `createddate` | import mode only | | `dateadded` | `createddate` | import mode only |
| `lastupdated` | `modifieddate` | import mode only | | `lastupdated` | `modifieddate` | import mode only |
**A position needs its level.** `mapx`/`mapy` are absolute pixels in the native
coordinate space of ONE level (ADR-017), so an import that sends coordinates
without `levelid` produces markers the map renders as "level unknown" rather than
drawing them on the default level. A legacy source that predates levels has one
floor plan, so the right value is that site's default level:
```bash
curl -s http://server/api/maplevels | python -c "import json,sys; print(json.load(sys.stdin)['data']['defaultlevelid'])"
```
Send that `levelid` on every row carrying coordinates. If an import already ran
without it, the levels migration's backfill is not retroactive - assign the rows
with `POST /api/mappositions/positions`, which requires `levelid` per row for
exactly this reason.
Per-plugin extension fields: Per-plugin extension fields:
- **computers** (`POST /api/computers`): `hostname` <- `machines.hostname`, - **computers** (`POST /api/computers`): `hostname` <- `machines.hostname`,

View File

@@ -88,12 +88,28 @@ SECRET_KEY=<64+ random chars>
JWT_SECRET_KEY=<another 64+ random chars> JWT_SECRET_KEY=<another 64+ random chars>
DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@<mysql-host>:3306/shopdb_flask?charset=utf8mb4 DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@<mysql-host>:3306/shopdb_flask?charset=utf8mb4
CORS_ORIGINS=http://<the site's own hostname-or-ip:port> CORS_ORIGINS=http://<the site's own hostname-or-ip:port>
TRUSTED_PROXY_COUNT=1
``` ```
Generate a key: `venv\Scripts\python -c "import secrets;print(secrets.token_urlsafe(64))"`. Generate a key: `venv\Scripts\python -c "import secrets;print(secrets.token_urlsafe(64))"`.
Production **refuses to boot** if any of `SECRET_KEY`, `JWT_SECRET_KEY`, Production **refuses to boot** if any of `SECRET_KEY`, `JWT_SECRET_KEY`,
`DATABASE_URL`, `CORS_ORIGINS` is missing or a dev default. `DATABASE_URL`, `CORS_ORIGINS` is missing or a dev default.
**`TRUSTED_PROXY_COUNT` matters on this install and nowhere else.** IIS is a
reverse proxy, so the app sees IIS's connection rather than the browser's. Set
it to the number of proxies actually in front (1 for a single IIS), and the app
enables ProxyFix, which resolves the real client address, scheme and host from
the `X-Forwarded-*` headers IIS sets.
Leave it at 0 behind IIS and the app is not wrong about who the client is so
much as blind to it: every request looks like it came from IIS itself. The login
rate limit is keyed on the client address, so the whole site shares one bucket,
and the audit log records the proxy for every action.
Do NOT set it when nothing is in front of the app. ProxyFix trusts those headers,
and a caller can send them - which is the reason it is off by default rather
than always on.
--- ---
## 5. Preflight (catch problems before installing) ## 5. Preflight (catch problems before installing)

214
docs/MIGRATE-TOPOLOGY.md Normal file
View File

@@ -0,0 +1,214 @@
# Moving a site between deployment topologies
Windows/IIS to Docker, Docker to a new Docker host, or Docker back to Windows.
All three are the same job, because the application keeps state in exactly two
places:
1. **The MySQL database** - every asset, user, audit and settings row.
2. **The instance directory** - `plugins.json` plus every uploaded file. Not in
the database. See [BACKUP-RESTORE](BACKUP-RESTORE.md) for the full inventory.
There is no third store. Nothing is encrypted at rest with `SECRET_KEY` or
`JWT_SECRET_KEY` (the only cryptography in the product is Ed25519 plugin
signing, which carries its own keys), so a move is a database dump plus a
directory copy. The schema is identical across topologies; only the environment
around it changes.
This page is the connective tissue between [DEPLOY](DEPLOY.md),
[INSTALL-WINDOWS-IIS](INSTALL-WINDOWS-IIS.md) and
[BACKUP-RESTORE](BACKUP-RESTORE.md). Read those for the details of each end.
---
## Before you start: what actually makes this hard
The data moves cleanly. These four things are the work.
### 1. The fleet points at the old URL
This is the item that turns a one-hour job into a project. The server address is
baked into things that are not the server:
| Where | What carries the URL |
|---|---|
| GE-Enforce | manifest entries, and the client's configured server |
| Computers collector | the generated reporter script (Settings > Computers) |
| Printers | the client scripts under `plugins/printers/client/` |
| Printed labels | QR codes, which point at asset pages |
| Browsers | bookmarks, and any kiosk or display configured with a URL |
**Keep the hostname and repoint DNS at the new host** wherever you can. The
migration then costs nothing on the fleet side. If the hostname must change,
budget for a sweep of every row in that table, and note that printed QR codes
cannot be swept at all: they are reprinted or redirected.
### 2. The subpath may differ
An IIS install can serve under an alias (`/ops`, say), which requires
`MOUNT_PATH` on the backend and a `frontend/dist` built with a matching
`VITE_BASE_PATH` - see step 7b of [INSTALL-WINDOWS-IIS](INSTALL-WINDOWS-IIS.md).
The Docker image builds `dist` for the root path.
So moving an aliased IIS site to Docker changes the URL even if the hostname
stays. Either serve Docker at the root and accept the path change (then item 1
applies), or put a reverse proxy in front that preserves the alias and build the
image with the matching `VITE_BASE_PATH`.
### 3. MySQL version and character set
The Windows runbook supports 5.6, 5.7 and 8.4. `docker-compose.yml` runs
`mysql:8.4` and forces `utf8mb4` / `utf8mb4_unicode_ci` server-wide so every
site shares one collation.
A dump from an older server can carry `latin1` or 3-byte `utf8` table
definitions. Those load without complaint and leave you on a mixed-charset
schema that only misbehaves later, on a name with an accent in it. Dump with
`--default-character-set=utf8mb4` and grep the SQL for `CHARSET=` before loading
anything.
Going 5.6 or 5.7 forward to 8.x is a supported upgrade path. Going backward is
not: an 8.x dump can use syntax an older server rejects.
### 4. File ownership
On Windows the IIS app pool holds Modify on `APP_ROOT\instance`. In the
container the application runs as `shopdb` (uid 1000). `docker compose cp`
writes files under the *copying* user's numeric uid, so the restored tree needs
an explicit `chown` or the site will read its files and fail to write new ones.
---
## Docker to a new Docker host
The simple case. Same image, same layout, same paths.
On the OLD host:
```bash
docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" \
--single-transaction --routines --triggers --default-character-set=utf8mb4 \
shopdb_flask | gzip > shopdb-$(date +%F).sql.gz
docker compose cp api:/app/instance ./instance-export
cp .env ./env-export
```
On the NEW host:
```bash
git clone <your-remote>/shopdb-flask.git && cd shopdb-flask
cp /path/to/env-export .env
# Edit .env: CORS_ORIGINS for the new hostname. Keep SECRET_KEY and
# JWT_SECRET_KEY as they were - regenerating only forces everyone to log in
# again, and buys nothing.
docker compose up -d db
# Wait for the healthcheck to pass, then load the dump:
zcat shopdb-*.sql.gz | docker compose exec -T db \
mysql -u root -p"${MYSQL_ROOT_PASSWORD}" --default-character-set=utf8mb4 shopdb_flask
docker compose build api
docker compose up -d api
docker compose cp ./instance-export/. api:/app/instance
docker compose exec -u root api chown -R shopdb:shopdb /app/instance
docker compose restart api
docker compose exec api flask db upgrade
docker compose exec api flask plugin upgrade-all
```
Verify before you decommission the old host:
```bash
docker compose exec api flask db current # must match `flask db heads`
docker compose exec api flask db heads
docker compose exec api flask plugin list # the site's plugins, enabled
```
Then log in and open a page with a floor map and one with images. The database
looks correct whether or not the files came across, so this is the only check
that proves the instance directory landed.
---
## Windows/IIS to Docker
Same shape. The dump comes from a normal MySQL server rather than a container,
and the instance directory is a normal folder.
On the WINDOWS host:
```powershell
# 1. Stop serving, so the dump and the file copy agree with each other.
Stop-WebAppPool -Name shopdbflask
# 2. Database.
mysqldump -u root -p --single-transaction --routines --triggers `
--default-character-set=utf8mb4 shopdb_flask | `
Out-File -Encoding utf8 shopdb-export.sql
# 3. Instance directory and environment.
Compress-Archive -Path APP_ROOT\instance\* -DestinationPath instance-export.zip
Copy-Item APP_ROOT\.env .\env-export
```
Move all three to the Docker host, unzip the instance archive into
`./instance-export/`, then follow the "NEW host" block above with two changes:
- `.env` needs `DATABASE_URL` rewritten to point at the `db` service rather than
the Windows MySQL server:
`mysql+pymysql://shopdb:<password>@db:3306/shopdb_flask?charset=utf8mb4`
- `MOUNT_PATH` comes out unless you are preserving a subpath (item 2 above).
Leave the Windows site installed but stopped until the Docker site is verified.
Rolling back is then a matter of starting the app pool again.
### What does not need migrating
`plugins.json` comes across in the instance directory, so the site's enabled
plugin set follows it. The image bakes the whole catalog, so whatever was
enabled on Windows is available in the container. The schema is identical, so
`db upgrade` and `plugin upgrade-all` are no-ops unless the target is also a
newer release.
---
## Docker to Windows/IIS
The reverse works the same way and is worth knowing for a rollback. Dump from
the container, restore into the Windows MySQL server, unpack the instance
directory into `APP_ROOT\instance`, and grant the app pool Modify on it (step
7.3 of [INSTALL-WINDOWS-IIS](INSTALL-WINDOWS-IIS.md)) - the container's uid
means nothing to Windows, and a directory the pool cannot write produces
"internal error" on any upload or plugin toggle.
The one constraint is MySQL version: do not restore an 8.x dump into a 5.6 or
5.7 server.
---
## Cutover checklist
1. Announce the outage. The database is stopped for the dump.
2. Take the dump and the instance copy from the SAME quiet moment.
3. Stand the new stack up and restore both.
4. Run `db upgrade` and `plugin upgrade-all`, then `db current` against
`db heads`.
5. Log in. Load a floor map. Load a page with images. Toggle nothing.
6. Repoint DNS, or update the fleet's server address if the hostname changed.
7. Confirm one bay checks in and one collector report arrives.
8. Leave the old stack in place, stopped, until step 7 has happened at least
once on a working day.
## See also
- [BACKUP-RESTORE](BACKUP-RESTORE.md) - what a complete backup contains, and the
restore procedure each of these steps is built on
- [DEPLOY](DEPLOY.md) - the Docker stack, and the update procedure
- [DEPLOY-AIRGAP](DEPLOY-AIRGAP.md) - the same stack where nothing can be pulled
- [INSTALL-WINDOWS-IIS](INSTALL-WINDOWS-IIS.md) - the Windows end in detail
- [UPGRADE](UPGRADE.md) - moving between product versions, which is a different
question from moving between topologies
- [ADR-004](adr/ADR-004-deployment-topology.md) - why each site runs its own
stack

View File

@@ -298,6 +298,40 @@ The `Permission` ROWS already in the database are NOT deleted, so roles that
already reference them keep working until an admin edits the role. A broken already reference them keep working until an admin edits the role. A broken
plugin is isolated in prod and re-raised in dev/test. plugin is isolated in prod and re-raised in dev/test.
### `get_guest_grantable_permissions() -> List[str]`
Names the WRITE permissions this plugin allows a site to grant to the `guest`
role. Added in contract 0.21.0.
Anyone with no account holds the `guest` role. It is seeded with nothing, and a
site opens a door by granting it a permission in the role grid - which replaced
the per-feature booleans (`securesend_allow_guests`, one per kiosk) that used to
answer the same question one feature at a time.
Any `.view` or `.read` permission is grantable without a plugin saying anything.
This hook is only for the exception: a write that is genuinely meant to work for
somebody who never signs in. Naming one here does not open it - it makes the
checkbox available, and a site still has to tick it.
```python
class ToolsPlugin(BasePlugin):
def get_guest_grantable_permissions(self):
# Sending a password to a vendor: the recipient has no account here,
# and often neither does the sender. The unguessable token is the
# authorisation and the link is one-view.
return ['tools.secrets']
```
The bar for naming one: could this action be attributed to nobody, forever, and
still be defensible in the audit log? A guest write records no creator - the
trail says when and from where, never who. Return `[]` (the default) and none of
this plugin's writes can ever be made anonymous; anything not named is refused
by the API when an admin tries to grant it, not merely warned about.
Consumed by `guest_grantable_permissions()`, which backs the role API's guard on
the guest role and the greyed-out checkboxes in the role grid. A plugin may only
bless permissions that already exist in the catalog, so a typo widens nothing.
### `get_settings_cards() -> List[Dict]` ### `get_settings_cards() -> List[Dict]`
Returns settings-catalog card definitions. Added in contract 0.7.0 (ADR-010). Returns settings-catalog card definitions. Added in contract 0.7.0 (ADR-010).
@@ -369,6 +403,12 @@ class MeasuringToolsPlugin(BasePlugin):
Consumed by `GET /api/pluginui/map-overlays` (disabled plugins skipped; broken Consumed by `GET /api/pluginui/map-overlays` (disabled plugins skipped; broken
plugin isolated in prod, re-raised in dev/test). plugin isolated in prod, re-raised in dev/test).
An overlay keys on `assetid` and never on coordinates, so it needs to know
nothing about buildings or levels (ADR-017): it decorates whichever marker that
asset already has, on whatever level that marker is on. Do not return `mapx` /
`mapy` / `levelid` from an overlay endpoint - a position belongs to the asset,
and a second copy of it in an overlay is one that can disagree.
### `get_asset_presentation() -> List[Dict]` ### `get_asset_presentation() -> List[Dict]`
Returns asset-type presentation/routing definitions. Added in contract 0.7.0 Returns asset-type presentation/routing definitions. Added in contract 0.7.0
@@ -695,3 +735,41 @@ When you change anything documented here, you must:
3. Add or update the test in `tests/test_plugin_contract.py` that asserts the new behavior. 3. Add or update the test in `tests/test_plugin_contract.py` that asserts the new behavior.
The skill `defining-asset-contract` walks through the full checklist. The skill `defining-asset-contract` walks through the full checklist.
## Reversible bulk-import journal
Contract version 0.30.0.
A plugin that drives a bulk import - a site asset adoption, an integration sync -
records the primitive changes it makes so the whole batch can be reversed later.
The journal is import-agnostic: it stores primitive mutations (set a cell, insert
a row) tied to a batch id, and a generic undo replays them in reverse. Nothing in
core learns what the import means.
Exposed on `shopdb.api`:
- `ImportBatch` - one row per applied import. `batchid` is the identity that the
approval step, the write, and the undo all reference. `status` is `applied` or
`undone`.
- `ImportJournalEntry` - one reversible primitive mutation. `operation` is
`update` (a cell change, carrying `oldvalue`/`newvalue` so it can be put back)
or `insert` (a row this batch created, so undo can delete strictly what the
import created).
- `BATCH_APPLIED`, `BATCH_UNDONE` - the two `ImportBatch.status` values.
- `OP_UPDATE`, `OP_INSERT` - the two `ImportJournalEntry.operation` values.
What a plugin gets from this, and what it does NOT:
- A plugin's import UI reads `ImportBatch` to list applied batches (kind, row
count, who applied it, when) and offers an "Undo import" per batch.
- A plugin does NOT write the journal or run undo through these model imports.
The write/undo logic lives with the import writer (see
`scripts/site_imports/journal.py`), which records entries in the SAME
transaction as the mutation they describe and reverses them conservatively:
an `update` is put back only if the cell still holds the value the import
wrote (a value a human changed since is left as-is and reported), and an
`insert` is hard-deleted only when nothing outside the batch still references
the created row.
The tables are core-owned (migration `7d37_import_batches_and_journal`), so they
exist regardless of which plugins are installed.

View File

@@ -2,11 +2,17 @@
This is the literal, type-along guide to building a complete shopdb plugin, This is the literal, type-along guide to building a complete shopdb plugin,
using the 3D-printed-parts storefront as the example. Every core stage shows using the 3D-printed-parts storefront as the example. Every core stage shows
the actual code; the finished implementation lives on branch the actual code; the finished implementation is merged into `main`, and each
`feat/printedparts-plugin` with one commit per stage, tagged `lab-stage-01` stage is tagged `lab-stage-01` .. `lab-stage-17` - so `git show lab-stage-05` or
.. `lab-stage-17` - so `git show lab-stage-05` or
`git diff lab-stage-04 lab-stage-05` always has the complete answer, `git diff lab-stage-04 lab-stage-05` always has the complete answer,
including the long Vue files this guide abridges. including the long Vue files this guide abridges. (The `feat/printedparts-plugin`
branch these tags were cut from has since been deleted; the tags are what
survive, and they are enough.)
Read the stage tags as a historical record, not as today's conventions. They
were cut against plugin contract 0.11-0.13 and the framework has moved on - the
`core_version` floor below, and the frontend layout in the later stages, are
both current-day values rather than what `git show` will hand you.
What you are building: a catalog of 3D-printed parts (photo, description, What you are building: a catalog of 3D-printed parts (photo, description,
quantity on hand), a stock LEDGER attributing every take/restock/adjust to a quantity on hand), a stock LEDGER attributing every take/restock/adjust to a
@@ -93,7 +99,7 @@ the edit (imports/meta boilerplate unchanged from the scaffold):
"description": "3D-printed parts inventory + kiosk checkout", "description": "3D-printed parts inventory + kiosk checkout",
"display_name": "3D Printed Parts", "display_name": "3D Printed Parts",
"dependencies": ["employees"], "dependencies": ["employees"],
"core_version": ">=0.12.0,<1.0.0", "core_version": ">=0.16.0,<1.0.0",
"api_prefix": "/api/printedparts", "api_prefix": "/api/printedparts",
"default_enabled": false "default_enabled": false
} }
@@ -1193,4 +1199,4 @@ for a contributor:
| List/Detail master templates | `PrintersList.vue`, `PrinterDetail.vue` | | List/Detail master templates | `PrintersList.vue`, `PrinterDetail.vue` |
| Reports hook + CSV | `plugins/warranty/` + `shopdb/core/api/reports.py` | | Reports hook + CSV | `plugins/warranty/` + `shopdb/core/api/reports.py` |
| Permissions declaration | `plugins/usb/plugin.py::get_permissions` | | Permissions declaration | `plugins/usb/plugin.py::get_permissions` |
| The finished plugin itself | branch `feat/printedparts-plugin`, tags `lab-stage-01..17` | | The finished plugin itself | `plugins/printedparts/` on `main`, tags `lab-stage-01..17` |

View File

@@ -17,7 +17,9 @@ For the architectural decisions behind the contract, see [docs/adr/](../docs/adr
flask plugin new cameras --description "Tracks shop-floor surveillance cameras" flask plugin new cameras --description "Tracks shop-floor surveillance cameras"
``` ```
Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, a README, and a paste-in `frontend-api-snippet.js`. When a `frontend/src/` tree is present, it also writes the frontend starting points: `frontend/src/views/cameras/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue`, and the auto-discovered route file `frontend/src/router/routes/cameras.js`. (A plugin developed in its own repo, with no frontend tree, gets the backend skeleton plus the snippet only.) Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, a README, and a paste-in `frontend-api-snippet.js`. It also writes the frontend starting points, and they live **inside the plugin**: `plugins/cameras/frontend/routes.js` and `plugins/cameras/frontend/views/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue`.
Nothing is written into `frontend/src/`. The plugin owns its frontend because that is what makes it prunable: `scripts/stage-frontend.mjs` copies each plugin's `frontend/` into `frontend/src/.plugins-staged/<name>/` and aggregates the route modules into `routes.gen.js`, so a plugin left out of `SITE_PLUGINS` simply is not copied and its frontend leaves the build with it (ADR-013). A plugin developed in its own repo gets exactly this same layout, and its frontend starts working the moment the directory is dropped into a host repo's `plugins/`.
The generated plugin already passes the framework's contract tests. Verify before editing: The generated plugin already passes the framework's contract tests. Verify before editing:
@@ -142,28 +144,35 @@ Each hook has a default that does nothing. Override only what your plugin needs.
The scaffold generates the frontend starting points too: three views, a route file, and a paste-in api-client snippet (see Step 1). They build and run out of the box against the example model, so `npm run build` is green immediately after scaffolding. The list below is what you finish by hand once the views exist. Copy patterns from the closest bundled plugin (`network` is the cleanest) as you flesh them out, and work through these in order: The scaffold generates the frontend starting points too: three views, a route file, and a paste-in api-client snippet (see Step 1). They build and run out of the box against the example model, so `npm run build` is green immediately after scaffolding. The list below is what you finish by hand once the views exist. Copy patterns from the closest bundled plugin (`network` is the cleanest) as you flesh them out, and work through these in order:
1. **View files** - the scaffold created `frontend/src/views/cameras/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue` from the example model. Replace the `examplefield` columns and inputs with your domain fields. Keep the global `.filters` / `.form-control` / `.card` styles; do not invent per-page input styling. 1. **View files** - the scaffold created `plugins/cameras/frontend/views/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue` from the example model. Replace the `examplefield` columns and inputs with your domain fields. Keep the global `.filters` / `.form-control` / `.card` styles; do not invent per-page input styling.
2. **Route file** - the scaffold created `frontend/src/router/routes/cameras.js` exporting a route array. The router auto-discovers every file in `routes/` via `import.meta.glob`, so no registration edit is needed. Every route is already tagged with `meta: { plugin: 'cameras' }` - the ADR-009 guard redirects to the dashboard when the backend plugin is disabled - and the form routes already carry `requiresAuth: true`: **Imports have one rule, and staging is the reason.** Anything from the CORE tree uses the `@` alias (`@/api`, `@/components/PaginationBar.vue`, `@/stores/auth`); anything inside your own plugin is relative (`./views/...`). A relative path into the core tree such as `../../api` is correct where the file sits in your plugin and wrong once staged into `.plugins-staged/cameras/`, and the failure is a build error rather than anything subtle.
2. **Route file** - the scaffold created `plugins/cameras/frontend/routes.js` exporting a route array plus a `toplevel` array. `stage-frontend.mjs` aggregates both into `routes.gen.js`, which the router spreads in, so no registration edit is needed. Every route is already tagged with `meta: { plugin: 'cameras' }` - the ADR-009 guard redirects to the dashboard when the backend plugin is disabled - and the form routes already carry `requiresAuth: true`:
```js ```js
export default [ export default [
{ {
path: 'cameras', path: 'cameras',
name: 'cameras', name: 'cameras',
component: () => import('../../views/cameras/CamerasList.vue'), component: () => import('./views/CamerasList.vue'),
meta: { plugin: 'cameras' } meta: { plugin: 'cameras' }
}, },
{ {
path: 'cameras/:id/edit', path: 'cameras/:id/edit',
name: 'cameras-edit', name: 'cameras-edit',
component: () => import('../../views/cameras/CamerasForm.vue'), component: () => import('./views/CamerasForm.vue'),
meta: { requiresAuth: true, plugin: 'cameras' } meta: { requiresAuth: true, plugin: 'cameras' }
} }
] ]
// Full-screen routes that render OUTSIDE AppLayout (kiosk, print pages).
// Export it even when empty: routes.gen.js reads `.toplevel` off every staged
// module, and a missing export is a Rollup warning under strict ESM.
export const toplevel = []
``` ```
3. **API client** - the scaffolded views ship with an inline `camerasApi` client so they run standalone. To graduate to the shared module, paste the generated `plugins/cameras/frontend-api-snippet.js` block into `frontend/src/api/index.js`, then delete the inline const in each view and `import { camerasApi } from '../../api'` instead. The snippet already matches the existing blocks' shape (`list(params)`, `get(id)`, `create(data)`, `update(id, data)`, `remove(id)`). 3. **API client** - the scaffolded views ship with an inline `camerasApi` client so they run standalone. To graduate to the shared module, paste the generated `plugins/cameras/frontend-api-snippet.js` block into `frontend/src/api/index.js`, then delete the inline const in each view and `import { camerasApi } from '@/api'` instead. The snippet already matches the existing blocks' shape (`list(params)`, `get(id)`, `create(data)`, `update(id, data)`, `remove(id)`).
4. **Sidebar entry** - implement `get_navigation_items` on the plugin class. No frontend edit: the sidebar builds itself from `/api/dashboard/navigation`. 4. **Sidebar entry** - implement `get_navigation_items` on the plugin class. No frontend edit: the sidebar builds itself from `/api/dashboard/navigation`.

View File

@@ -12,7 +12,7 @@ These plugins are in `plugins/` in this repo. Enable per site with `flask plugin
| `computers` | Shop-floor PCs and engineering workstations | Fed by the PXE pipeline collector per [ADR-006](adr/ADR-006-collector-contract.md). | | `computers` | Shop-floor PCs and engineering workstations | Fed by the PXE pipeline collector per [ADR-006](adr/ADR-006-collector-contract.md). |
| `printers` | Network and shop-floor printers | Public installer map page + fleet install contract (`/api/printers/install-list`, `/pc-default`, `/install-batch`; see [PRINTER-INSTALLER.md](PRINTER-INSTALLER.md)). Optional Zabbix integration for supply tracking. Legacy `PrinterData` retiring per ADR-001. | | `printers` | Network and shop-floor printers | Public installer map page + fleet install contract (`/api/printers/install-list`, `/pc-default`, `/install-batch`; see [PRINTER-INSTALLER.md](PRINTER-INSTALLER.md)). Optional Zabbix integration for supply tracking. Legacy `PrinterData` retiring per ADR-001. |
| `network` | Switches, routers, access points, IDFs as locations | Asset-only; cleanest of the bundled set. | | `network` | Switches, routers, access points, IDFs as locations | Asset-only; cleanest of the bundled set. |
| `usb` | USB devices issued to shop-floor users | Lightweight checkout / check-in. | | `usb` | USB devices issued to shop-floor users | Lightweight checkout / check-in, self-hosted tables or an existing `cmmc_usb` database. Optional logged-out kiosk (`/usb-kiosk`, off by default) so somebody at the drive cabinet can take and return a drive without a ShopDB login, attributed to a scanned badge. |
| `notifications` | Shop-floor notifications, recognitions, kiosk feed | Used by `ShopfloorDashboard.vue`. | | `notifications` | Shop-floor notifications, recognitions, kiosk feed | Used by `ShopfloorDashboard.vue`. |
| `measuringtools` | Metrology and inspection instruments: calipers, micrometers, thread/bore/height gages, indicators | Per [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Calibration lifecycle with derived status. First plugin built on the matured scaffold; its walkthrough is [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md). Ships `default_enabled: false`. | | `measuringtools` | Metrology and inspection instruments: calipers, micrometers, thread/bore/height gages, indicators | Per [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Calibration lifecycle with derived status. First plugin built on the matured scaffold; its walkthrough is [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md). Ships `default_enabled: false`. |
| `employees` | Read-only employee directory lookup | Backed by a separate HR database. Ships `default_enabled: false`. | | `employees` | Read-only employee directory lookup | Backed by a separate HR database. Ships `default_enabled: false`. |
@@ -20,7 +20,11 @@ These plugins are in `plugins/` in this repo. Enable per site with `flask plugin
| `knowledgebase` | Knowledge Base articles linking to external resources | Lightweight article store. | | `knowledgebase` | Knowledge Base articles linking to external resources | Lightweight article store. |
| `printedparts` | 3D-printed parts inventory | Kiosk checkout / check-in. Ships `default_enabled: false`. | | `printedparts` | 3D-printed parts inventory | Kiosk checkout / check-in. Ships `default_enabled: false`. |
| `slides` | Slides for the lobby display and shop-floor screensaver | Upload / reorder / delete per surface. Management gated on the `slides.manage` permission, grantable to non-admin curators; display routes are public. | | `slides` | Slides for the lobby display and shop-floor screensaver | Upload / reorder / delete per surface. Management gated on the `slides.manage` permission, grantable to non-admin curators; display routes are public. |
| `tools` | Technician utilities: barcode and QR label generation, unit conversions, Secure Send | Mostly browser-side by design, so an air-gapped bench gets them with no network. Secure Send is the exception and is why this plugin has a blueprint, a table and a migration chain: see [SECURE-SEND.md](SECURE-SEND.md). |
| `warranty` | Asset warranty tracking | Manual entry now, Dell / Lenovo / HP provider lookups later. Derived coverage status with report buckets. | | `warranty` | Asset warranty tracking | Manual entry now, Dell / Lenovo / HP provider lookups later. Derived coverage status with report buckets. |
| `guides` | Step-by-step procedures written in ShopDB: typed blocks, pasted screenshots, live checkboxes, tags, drafts and revision history | Content is stored as typed blocks rather than markup, so the same guide renders to screen, dark mode and paper. Readers see the PUBLISHED revision only - a technician following a procedure must not have it change under them mid-task. Checkbox ticks are per-viewer and never edit the guide. See [GUIDES.md](GUIDES.md). |
| `endpoints` | Live endpoint telemetry for shop-floor PCs: liveness, services, processes, inventory, and curated Windows event records, reported by the ShopDB agent | Depends on `computers` - an endpoint is telemetry ABOUT a PC asset, never a second record of it. Adds the Fleet console plus the live panels on a PC's detail page. Four separate permissions: `endpoints.view` to read, `endpoints.fleet` for the fleet-wide console, `endpoints.command` to act on one endpoint, and `endpoints.restart` for a reboot. They are split because read access is grantable to guests and the command channel is not. Ingest is not a route in this plugin: agent reports arrive on the generic collector route and authorize on a service token scoped `collector.ingest.endpoints`, not a user JWT, per [ADR-006](adr/ADR-006-collector-contract.md). That scope doubles as the kill switch for the agent key. See [FLEET-ARCHITECTURE.md](FLEET-ARCHITECTURE.md). |
| `backups` | Per-asset configuration backups with revision history: NTLARS/DNC registry and part marker | Kind-pluggable per [BACKUP-KINDS.md](BACKUP-KINDS.md); a kind is one class in `services/registry.py`. Parseable kinds store a JSON projection and re-render to the native format on download; opaque vendor formats stay on the SFLD share with ShopDB holding metadata and the UNC pointer. Fed by the collector, so ingest authorizes on a `collector.ingest` service token, not a user JWT. `backups.view` and `backups.download` are separate permissions because a `.reg` is a restorable artifact. Not to be confused with [BACKUP-RESTORE.md](BACKUP-RESTORE.md), which is the ShopDB database itself. |
## Plugin permissions ## Plugin permissions

View File

@@ -11,8 +11,8 @@ never by editing this file.
| series | value | governed by | | series | value | governed by |
|---|---|---| |---|---|---|
| product `__version__` | `0.11.0` | ADR-007 | | product `__version__` | `0.12.0` | ADR-007 |
| plugin contract `__contract_version__` | `0.20.0` | ADR-002 | | plugin contract `__contract_version__` | `0.31.0` | ADR-002 |
They move independently. A contract bump is not a release. They move independently. A contract bump is not a release.
@@ -23,30 +23,35 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
| chain | head | | chain | head |
|---|---| |---|---|
| core | `7d33_buildings_and_levels` | | core | `7d41_drop_guest_tools_secrets` |
| backups | `backups0003clearlastseen` | | backups | `backups0003clearlastseen` |
| computers | `computers0001anchor` | | computers | `computers0004policies` |
| employees | `employees0002photo` | | employees | `employees0002photo` |
| endpoints | `endpoints0016dellupdates` |
| geenforce | `geenforce0004minlib` | | geenforce | `geenforce0004minlib` |
| guides | `guides0001baseline` |
| knowledgebase | `knowledgebase0001anchor` | | knowledgebase | `knowledgebase0001anchor` |
| machines | `machines0002rename` | | machines | `machines0002rename` |
| measuringtools | `measuringtools0001baseline` | | measuringtools | `measuringtools0001baseline` |
| network | `network0003prefix` | | network | `network0003prefix` |
| notifications | `notifications0005boardorder` | | notifications | `notifications0005boardorder` |
| printedparts | `printedparts0004txnrev` | | printedparts | `printedparts0004txnrev` |
| printers | `printers0002supplyalerts` | | printers | `printers0005observedqueues` |
| slides | `slides0001anchor` | | slides | `slides0001anchor` |
| usb | `usb0002dropmachineid` | | tools | `tools0001securesend` |
| usb | `usb0004checkinidentity` |
| warranty | `warranty0002proof` | | warranty | `warranty0002proof` |
## Bundled plugins (15) ## Bundled plugins (17)
| plugin | version | core_version | owns migrations | | plugin | version | core_version | owns migrations |
|---|---|---|---| |---|---|---|---|
| backups | 1.0.0 | >=0.16.0,<1.0.0 | yes | | backups | 1.0.0 | >=0.16.0,<1.0.0 | yes |
| computers | 1.0.0 | >=0.20.0,<1.0.0 | yes | | computers | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| employees | 1.0.0 | >=0.1.0,<1.0.0 | yes | | employees | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| endpoints | 0.1.0 | >=0.20.0,<1.0.0 | yes |
| geenforce | 0.1.0 | >=0.18.0,<1.0.0 | yes | | geenforce | 0.1.0 | >=0.18.0,<1.0.0 | yes |
| guides | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| knowledgebase | 1.0.0 | >=0.1.0,<1.0.0 | yes | | knowledgebase | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| machines | 1.0.0 | >=0.20.0,<1.0.0 | yes | | machines | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| measuringtools | 1.0.0 | >=0.20.0,<1.0.0 | yes | | measuringtools | 1.0.0 | >=0.20.0,<1.0.0 | yes |
@@ -55,7 +60,7 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
| printedparts | 0.1.0 | >=0.16.0,<1.0.0 | yes | | printedparts | 0.1.0 | >=0.16.0,<1.0.0 | yes |
| printers | 1.0.0 | >=0.20.0,<1.0.0 | yes | | printers | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| slides | 2.0.0 | >=0.2.0,<1.0.0 | yes | | slides | 2.0.0 | >=0.2.0,<1.0.0 | yes |
| tools | 1.0.0 | >=0.16.0,<1.0.0 | no | | tools | 1.0.0 | >=0.16.0,<1.0.0 | yes |
| usb | 1.0.0 | >=0.1.0,<1.0.0 | yes | | usb | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| warranty | 1.0.0 | >=0.2.0,<1.0.0 | yes | | warranty | 1.0.0 | >=0.2.0,<1.0.0 | yes |
@@ -82,9 +87,10 @@ Manifest-less directories under `plugins/` are core frontend surface and always
| ADR-015-site-specific-configuration.md | ADR-015: Where a site's own data is allowed to live | ACCEPTED | | ADR-015-site-specific-configuration.md | ADR-015: Where a site's own data is allowed to live | ACCEPTED |
| ADR-016-credential-delivery.md | ADR-016: Credential delivery to the fleet | ACCEPTED (decided; NOT yet implemented - | | ADR-016-credential-delivery.md | ADR-016: Credential delivery to the fleet | ACCEPTED (decided; NOT yet implemented - |
| ADR-017-buildings-and-levels.md | ADR-017: Buildings and levels as the map model | ACCEPTED | | ADR-017-buildings-and-levels.md | ADR-017: Buildings and levels as the map model | ACCEPTED |
| ADR-018-metrology-instruments-are-machines.md | ADR-018: Metrology instruments are machines | ACCEPTED |
## Size ## Size
- test functions defined: **1087** (parametrised cases collect higher) - test functions defined: **1854** (parametrised cases collect higher)
- documented API paths: **276** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) - documented API paths: **376** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)

View File

@@ -39,6 +39,19 @@ The last big milestone before 1.0 is the legacy-ASP data import plus a productio
- Pip-installable plugins (deferred per ADR-003 v2). Filesystem distribution stays the v1 model. - Pip-installable plugins (deferred per ADR-003 v2). Filesystem distribution stays the v1 model.
- Event bus on `BasePlugin` (removed per ADR-001; add via new ADR if a real use case appears). - Event bus on `BasePlugin` (removed per ADR-001; add via new ADR if a real use case appears).
- Frontend rebuild beyond Vue 3 + Pinia + Vite (the existing stack is fine). - Frontend rebuild beyond Vue 3 + Pinia + Vite (the existing stack is fine).
- **File** sharing by expiring link. The secret half of this shipped as Secure
Send in the `tools` plugin ([SECURE-SEND.md](SECURE-SEND.md)); the file half
was dropped deliberately and stays out of scope. Dropping it removed the
export-control question entirely - a password is not technical data - along
with blob storage and the need to authenticate the recipient. What would have
to be settled before it comes back, so nobody rediscovers it: a FILE recipient
should be logged in too, which costs nothing on an intranet-only server where
everyone with the link already has an account; sharing a file wants its own
permission separate from sharing a secret, because the risk classes differ;
and zero-knowledge encryption is right for a credential but questionable for a
controlled document, where "the server cannot say what was in it" is a
liability rather than a feature. Export control should confirm internal link
sharing in writing before anything here carries drawings or NC programs.
## What 1.0.0 means ## What 1.0.0 means

155
docs/SECURE-SEND.md Normal file
View File

@@ -0,0 +1,155 @@
# Secure Send
Hand somebody a password without leaving it in a chat window.
Secure Send makes a link that carries one credential, dies when it is opened,
and expires on its own if nobody opens it. It lives in **Tech Tools** at
`/tools/secure-send`, and the person receiving it opens `/s/<token>`.
It is the one tool in that section that is not purely browser-side, because a
link that expires and dies on first read needs somewhere to keep the ciphertext
until it is claimed.
## What the server can and cannot see
The secret is encrypted **in the sender's browser** with AES-GCM, and the key
travels in the **URL fragment** - the part after the `#`. Browsers never send a
fragment to a server, so the key reaches the recipient's browser and nowhere
else: not this server, not its logs, not a proxy in between.
What the `securesendsecrets` table holds is a random token, base64 ciphertext,
an IV, and the rules for the row's life. Reading the database yields nothing.
That cuts both ways, and it is worth being explicit about it:
- **Nobody can recover a secret that goes missing.** Not an administrator, not
the database owner. If the link is lost, the secret is lost. Create another.
- **The server cannot tell a password from a page of a drawing.** It cannot
look at what it holds, so it cannot enforce a rule about content. The only
lever it has is SIZE, which is the next section.
- **The audit trail records the fact, never the content.** Who created a
secret, when, and when it was read are all recorded. What was in it is not
recoverable by anyone.
## This is for credentials, not documents
The ciphertext limit is **4096 characters** (about 2800 characters of plaintext,
which the create page enforces first with a friendlier message). That limit is
not a tuning knob. In a design where the server cannot read what it stores,
size is the entire server-side control over what this feature is for, and
raising it quietly turns a password tool into an unaudited document transfer.
Everything else the create page does - counting lines, spotting file
signatures, refusing a pasted file - runs in the browser. It guides an honest
person and is bypassable by anyone willing to POST directly. The size limit is
the part that holds.
If somebody needs to move a file, the answer is a different tool, not a bigger
number here.
## Who may create, who may read
**Two different doors, on purpose.**
**Creating** normally needs a login plus the `tools.secrets` permission (admins
hold it by default; grant it to other roles from Settings > Users & Roles). The
login is not a formality: anonymous creation would make this an unattributable
write endpoint that stores arbitrary ciphertext, and the creator is the only
person these rows can ever name.
**Reading needs neither an account nor a permission.** The recipient usually has
no account here, and the link is normally handed over on a call or in a chat.
The unguessable token IS the authorisation, which is why it is 128 bits of
randomness and why the row is spent on the first successful read.
Making the create page reachable is not the same as allowing the action. The
page is served without a login so a guest at a site that allows guest creation
is not stopped by a sign-in wall; the server still decides.
### Guest creation
A site may open creation to people with no account with the
`securesend_allow_guests` setting (Settings > Tools > Secure Send). It is
**off by default**, because a guest secret has no creator and the audit trail
can then say when and from where but never who.
When it is on, guest creation is rate limited to 10 links per address per 10
minutes. That is not a security boundary - a determined person has more
addresses - but it stops an open endpoint being an easy place to park data or
fill a disk.
The setting is read straight from the settings row rather than the cache, and
**fails closed**: if it cannot be read at all, the answer is no. An error must
never be the thing that opens an unauthenticated write endpoint.
Receiving a secret never needs an account either way.
## Lifetime
| Control | Default | Range |
|---------|---------|-------|
| Expiry | 24 hours | 1 to 168 hours |
| Views | 1 | 1 to 100 |
Both apply, whichever comes first. A spent row is deleted outright, including
its ciphertext.
Expired secrets are swept opportunistically - whenever anybody creates a secret,
and whenever a dead link is clicked - rather than on a schedule. A secret nobody
ever opens would otherwise be removed by nothing, and an air-gapped site is not
going to have a cron job anybody remembers to add.
Missing, expired and spent all answer identically. Distinguishing them would
confirm to anyone holding a guessed token whether it ever named anything.
## Redeeming is a POST
`POST /api/tools/secrets/<token>` hands over the ciphertext and spends the view.
A GET would be wrong here: chat and mail clients prefetch links to build
previews, and a one-view secret consumed by a preview window is a secret the
recipient never gets to see.
There is a `GET /api/tools/secrets/<token>` as well, but it reports availability
only and never spends a view. The read page asks it first so it can say "this
secret is waiting, and opening it will destroy it" instead of consuming the only
view to find out whether there was anything there.
## Before anybody relies on it
**Send a link to yourself through whatever your site uses to pass it on -
Teams, Outlook, a chat client - and check the key is still there after the `#`.**
Some mail and chat security products rewrite links. If one strips or mangles the
fragment, the secret is unrecoverable and the server cannot help, which is the
whole point of the design. Find that out on a test secret rather than on a
production credential.
## Getting the link right
The link is built from the site's configured `site_base_url`, not from the
address the sender's browser happens to be using. Somebody creating a secret may
have reached the app by an IP, a short hostname, or a name that only resolves
inside one VLAN, while the recipient has to open the link from wherever they
are. Blank falls back to the browsing origin.
A site served under a subpath (`/shopdb`, `/ops`) needs that subpath reachable
in the link. Setting it as part of `site_base_url` works, and so does leaving it
out; the mount is added only when it is not there already.
## Where it lives
| Piece | Path |
|-------|------|
| Plugin | `plugins/tools/` |
| Table | `securesendsecrets` |
| API | `POST /api/tools/secrets`, `POST` and `GET /api/tools/secrets/<token>` |
| Create page | `/tools/secure-send` |
| Read page | `/s/<token>` |
| Setting | `securesend_allow_guests` (public, boolean, category `tools`) |
| Permission | `tools.secrets` |
## See also
- [CONFIG.md](CONFIG.md) - the `tools` settings section
- [PLUGINS.md](PLUGINS.md) - the bundled plugin set
- [API-REFERENCE.md](API-REFERENCE.md) - the response envelope these routes use

View File

@@ -13,6 +13,9 @@ exists.
2. [OPERATE-WINDOWS](OPERATE-WINDOWS.md) - restart, logs, backups, upgrades. 2. [OPERATE-WINDOWS](OPERATE-WINDOWS.md) - restart, logs, backups, upgrades.
3. [CSV-IMPORT](CSV-IMPORT.md) if the site's data is in spreadsheets, or 3. [CSV-IMPORT](CSV-IMPORT.md) if the site's data is in spreadsheets, or
[IMPORT-API](IMPORT-API.md) if there is a source database to script against. [IMPORT-API](IMPORT-API.md) if there is a source database to script against.
4. [FLOOR-MAP](FLOOR-MAP.md) - load the site's floor plans and place markers.
Also the page to read BEFORE a plan changes: it covers moving every marker
onto a redrawn plan without doing it one at a time.
**Do not** follow [INSTALL-WINDOWS-IIS](INSTALL-WINDOWS-IIS.md) for a new site. **Do not** follow [INSTALL-WINDOWS-IIS](INSTALL-WINDOWS-IIS.md) for a new site.
That is the manual procedure, kept for hand-built servers that predate the That is the manual procedure, kept for hand-built servers that predate the
@@ -28,6 +31,13 @@ Then [ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md), which has worked
examples for Intune, GE-Enforce and manual installation, and says where each examples for Intune, GE-Enforce and manual installation, and says where each
artifact comes from. artifact comes from.
## I am writing a procedure for other people to follow
Guides live in ShopDB itself: [GUIDES.md](GUIDES.md). Typed blocks rather than
markup, pasted screenshots, checkboxes a technician can tick as they work, and a
draft/publish split so a procedure cannot change under somebody halfway through
it. Use it instead of leaving a Word document on a share.
## I am writing a plugin ## I am writing a plugin
1. [PLUGIN-QUICKSTART](PLUGIN-QUICKSTART.md) - scaffold and first run. 1. [PLUGIN-QUICKSTART](PLUGIN-QUICKSTART.md) - scaffold and first run.
@@ -40,16 +50,33 @@ artifact comes from.
## I am integrating with the API ## I am integrating with the API
1. [LLM-GUIDE](LLM-GUIDE.md) (`docs/llms.txt` in the repo, also served at 1. [LLM-GUIDE](llms.txt) (`docs/llms.txt` in the repo, also served at
`/api/docs/llms.txt`) - auth, the response envelope, common recipes. `/api/docs/llms.txt`) - auth, the response envelope, common recipes.
Short, and the envelope section is the part people get wrong. Short, and the envelope section is the part people get wrong.
2. `GET /api/docs` on any running instance - the full spec, browsable. 2. `GET /api/docs` on any running instance - the full spec, browsable.
3. [API-REFERENCE](API-REFERENCE.md) for prose, [COLLECTOR-INTEGRATION](COLLECTOR-INTEGRATION.md) 3. [API-REFERENCE](API-REFERENCE.md) for prose, [COLLECTOR-INTEGRATION](COLLECTOR-INTEGRATION.md)
if you are feeding data in from a PC. if you are feeding data in from a PC.
## I have to give somebody a password
[SECURE-SEND](SECURE-SEND.md) - a one-view expiring link, instead of a chat
message or a sticky note. Read the "Before anybody relies on it" section first:
there is one thing to test at your site before the first real credential goes
through it.
## Somebody is asking whether we are exposed to a published vulnerability
[DEPENDENCY-SECURITY](DEPENDENCY-SECURITY.md). Every release installs
`sbom.cdx.json`, a full inventory of third-party components and versions, so a
security team can answer this on their own without access to anything here.
In the repository, `scripts/scan_sbom.py` checks that inventory against
published advisories and separates what ships from what only builds - the two
are not the same finding, and only the first can fail a release.
## I am trying to understand why something is built this way ## I am trying to understand why something is built this way
[The ADRs](ADR-001-asset-as-platform-contract.md). They are the decision record, [The ADRs](adr/ADR-001-asset-as-platform-contract.md). They are the decision record,
they say what was rejected and why, and they are the fastest way to avoid they say what was rejected and why, and they are the fastest way to avoid
relitigating a settled question. [PROJECT-MAP](PROJECT-MAP.md) lists them all relitigating a settled question. [PROJECT-MAP](PROJECT-MAP.md) lists them all
with their status, along with the current versions and every migration head - with their status, along with the current versions and every migration head -
@@ -62,3 +89,8 @@ it is generated, so it is never stale.
[FLEET-ARCHITECTURE](FLEET-ARCHITECTURE.md) says which piece to open first. [FLEET-ARCHITECTURE](FLEET-ARCHITECTURE.md) says which piece to open first.
- A deploy that half-worked: [UPGRADE](UPGRADE.md) and - A deploy that half-worked: [UPGRADE](UPGRADE.md) and
[BACKUP-RESTORE](BACKUP-RESTORE.md). [BACKUP-RESTORE](BACKUP-RESTORE.md).
## I am moving a site to a different server or stack
[MIGRATE-TOPOLOGY](MIGRATE-TOPOLOGY.md) - Windows/IIS to Docker, Docker to a new
host, and back. The data moves cleanly; the URL the fleet points at is the work.

View File

@@ -183,6 +183,11 @@ and its version, installed on the server as `sbom.cdx.json`. If your security
team asks whether you are exposed to a published vulnerability, that file team asks whether you are exposed to a published vulnerability, that file
answers it without anyone guessing. answers it without anyone guessing.
That inventory is also checked against published advisories before a release is
issued, so the fixes described above are found rather than waited for. Your copy
of the file lets your own team repeat that check independently, on their
schedule and with their own tooling.
An update that is only a dependency bump is still worth taking: the version An update that is only a dependency bump is still worth taking: the version
number moves and the application behaviour does not. number moves and the application behaviour does not.

View File

@@ -89,6 +89,36 @@ Confirm the app is healthy (login page renders, `/api/auth/login` returns a
## Version-specific notes ## Version-specific notes
### Upgrading to v0.11.0: the floor map became buildings and levels
Nothing to do, and nothing moves on screen. Run both migration steps as always:
```bash
flask db upgrade
flask plugin upgrade-all
```
`7d33_buildings_and_levels` creates one building ("Main") and one default level
("Ground floor") carrying the blueprint paths and dimensions from your `map_*`
settings, then assigns every already-placed asset and location to that level.
Unplaced things stay unplaced. The old settings rows are deliberately left in
place, so a downgrade still finds the blueprint.
Two things to know afterwards:
- **A second floor or building is a Settings task**, not a migration: add it under
Settings > Buildings and levels, upload its blueprint, then move the markers
that belong on it. `POST /api/mappositions/transform` moves a whole level's
markers onto a redrawn plan from landmark pairs, dry run by default.
- **Replacing a blueprint with one of DIFFERENT dimensions moves every marker on
that level**, because the coordinates are pixels of the old size. Upload it, then
recalibrate with landmarks rather than editing the level's width and height by
hand. The API refuses to adopt a new size on a level that already has markers,
for this reason.
If you wrote a plugin that stores map positions, see CONTRACT-STABILITY.md: the
contract is 0.20.0 and a position must now carry its `levelid`.
### Upgrading to v0.5.0 or later: bundled the reference site floor plan removed ### Upgrading to v0.5.0 or later: bundled the reference site floor plan removed
Versions before 0.5 shipped the the reference site facility floor-plan PNGs as the Versions before 0.5 shipped the the reference site facility floor-plan PNGs as the

View File

@@ -0,0 +1,124 @@
# ADR-018: Metrology instruments are machines
- Status: ACCEPTED
- Date: 2026-09-04
- Deciders: ShopDB maintainers
- Relates to: ADR-001 (asset as the platform contract), ADR-006 (collector
contract), ADR-008 (a plugin owns its tables and its migration chain)
- Supersedes: ADR-005 (equipment plugin scope vs measuringtools plugin)
## Context
ADR-005 split manufacturing machinery from metrology instruments into two
plugins on the grounds that they are two asset classes: machines make parts,
measuring tools measure them. It named the metrology population as "CMMs,
Keyence vision systems, wax-and-trace surface profilometers, GenSpec
instruments", and the `measuringtools` plugin was built against a docstring
naming a different population again: "calipers, micrometers, thread gages,
bore gages, height gages".
Those two lists are not the same thing, and the difference is the whole
decision. One is bay-mounted capital equipment with a controlling PC. The other
is hand tools in a drawer. The plugin was built for the second and populated
with neither.
Sixteen months on, the evidence is in:
- `measuringtools` holds **zero rows**, against 204 machines, 305 computers,
73 network devices and 53 printers.
- The metrology inventory actually waiting to be entered is 134 instruments:
35 CMM, 32 form tracer, 23 vision, 18 wax trace, 14 measuring, 8 Genspect,
2 eddy current, 1 spline. Every one is bay-mounted with a controlling PC.
- Not one caliper, micrometer, thread gage, bore gage or height gage. The
population the plugin's own docstring justifies it with does not exist at
the reference site and is not planned.
- `CMM` already exists as a `machinetype`, and `Part Marker` is already
classed as a machine while `Form Tracer` is classed as a measuring tool.
Two bay instruments with a controlling PC, split across two plugins, with
the line drawn somewhere no operator can predict.
The split has therefore been paying an ongoing cost - a duplicate type page, a
settings group, a nav section, a migration chain, a searcher, dashboard cards,
map colours, label printing and four collector `pctypemap` entries - to model a
distinction that the inventory does not make.
## Decision
Metrology and inspection instruments are machines. The `measuringtools` plugin
is retired and the `measuring_tool` asset type is removed.
### Calibration moves to machines, and is not lost
`machines` gains the calibration lifecycle as nullable columns:
```
machines
- calibrationintervaldays integer, null
- lastcalibrationdate date, null
- nextcalibrationdate date, null
- calibrationprovider varchar(150), null
```
Calibration status stays **derived at read time** from `nextcalibrationdate`
(`overdue` / `duesoon` / `current` / `unknown`, 30-day window), exactly as
`measuringtools` derived it and for the same reason: a stored status is stale
the day after it is written. The helper moves across unchanged.
This is a net gain rather than a port. A machine could not record calibration
at all before, and a part marker with a certified fixture, or a machine with a
calibrated probe, had nowhere to put it.
### `machinetypes.ismetrology` answers ADR-005's objection
ADR-005 made one concrete technical argument, and it deserves a concrete
answer rather than silence:
> "show me all measuring tools" requires an enumeration of measuring-instrument
> equipmenttype values, which scales badly.
That is correct about enumeration and it is why `machinetypes` gains a boolean
`ismetrology`. The metrology view stays a first-class single-column filter -
`WHERE mt.ismetrology = 1` - not a hardcoded list of type names that drifts the
first time someone adds an instrument type. The calibration report, the gage
lab view and any compliance export all hang off that flag.
### What does not change
- `assets.gaugelabreference` stays on the core asset. The gage tag was never a
`measuringtools` column, so it survives untouched and stays searchable for
every asset type.
- The collector's device identity file and the `measuringtoolid` payload alias
stay accepted. Bays staged with `measuringtool-id.txt` keep reporting; only
the asset type they resolve to changes.
- Instrument types keep their names. A Form Tracer is still a Form Tracer, now
as a `machinetype` with `ismetrology = 1`.
## Consequences
**The migration is free today and expensive later.** `measuringtools` has zero
rows, so retirement is a `DROP TABLE`, not a data migration. Once the 134
staged instruments are entered they carry relationships, audit history, map
positions and collector links, and every one of those would have to be
rewritten. This ADR is being taken now specifically because the window is open.
**A site that did populate it needs a real migration.** The reference site did
not and the sister site shows no sign of having done so - its branch contains
no deliberate work on the plugin in its entire history - but that is inference,
not a query against their database. Confirm before the removal ships to another
site. If rows exist, the retirement migration must copy them into `machines`
with `ismetrology` types rather than drop them.
**Four collector `pctypemap` entries change asset type.** `gea-shopfloor-cmm`,
`-keyence`, `-genspect` and `-waxtrace` resolve to `machine` instead of
`measuring_tool`. The `collector:measuringtool` relationship label is kept as a
recognised origin marker so links a collector already created are still
archived correctly rather than orphaned.
**One fewer of everything.** A duplicate type page, a settings group, a plugin
migration chain, a gated searcher, a set of dashboard cards, a map colour entry
and a label-printing branch all go. That is the point.
**ADR-005 is superseded, not reversed in principle.** Its reasoning was sound
for the population it described. The population never arrived. If a site ever
does track hand gages in quantity, the answer is a `handgages` plugin against
that real inventory - not the reinstatement of this one.

Some files were not shown because too many files have changed in this diff Show More