12 Commits

Author SHA1 Message Date
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
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
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
035419fa51 ADR-015: stop shipping one site's values, and make the rule a gate
The scanner has been reporting the same count for weeks, which is what a rule
that only prints becomes. It now FAILS the build, and it looks where the leaks
actually were: PowerShell, the installer, the seeds, generated JSON, the
frontend - case-insensitively, across plugins, shopdb, scripts, deploy, tools.
A line that is deliberate declares itself with an ADR-015-OK marker and a
reason, so the claim is visible in review instead of tolerated in silence.

What it found, fixed here:

- The shadow client wrote one site's ShopDB URL into HKLM whenever the registry
  disagreed. At the site it was written for that reads as healing drift;
  anywhere else it overwrites the site's own address on every enforce cycle,
  and the site cannot win because the cycle repeats. The bay's value now wins,
  an explicit -BaseUrl seeds it, and with neither there is nothing honest to
  write, so it says so and skips.
- The kiosk dispatcher fell back to one plant's host when HKLM was unset, so a
  kiosk elsewhere quietly opened a server it has no business reaching. The
  fallback is now this site's site_base_url, baked in at seed time, and the
  dispatcher refuses rather than guessing when neither is set. Its legacy
  shortcut matcher derives the host from that URL instead of naming one.
- The OpenAPI generator hardcoded a production hostname into every spec it
  generated, which then published to a public wiki. The relative mount is the
  only server it can honestly name; a site passes its own by environment.
- Placeholders and examples in the UI and the client help offered real internal
  subnets and a real production URL. They now use documentation ranges.

Both publication gates - the export scrub and the docs publishability test -
carry the site patterns, which neither did. One plant's hostname, FQDN and
internal networks are out of the documentation and the generated specs.

Comments naming the reference site are reworded rather than deleted: the
reasoning is worth keeping, the plant name is not what makes it true.
2026-08-14 13:47:39 -04:00
cproudlock
536a8f0825 Centre the parts kiosk on the screen instead of hugging the top
The kiosk runs full-screen on a wall-mounted display, where the interface sat
against the top edge with the rest of the screen empty below it. It now
centres as one block, vertically and horizontally.

The title was the other half of it: the header was space-between, which reads
as centred only on the steps that show the Start over button. On the first
step, with no button to balance it, the title sat alone at the left edge of
the column. The title is centred and Start over is taken out of the flow so it
keeps its corner without shifting the title on the steps that have it.

Centring uses `safe center`, with plain `center` as the fallback line. On a
screen too short for the content, plain `center` overflows in both directions
and the header ends up above the scroll origin, unreachable. `safe` falls back
to top-aligned there and the page scrolls normally.
2026-08-07 08:38:55 -04:00
cproudlock
53c1f6476c Pick a network device's map position, and stop hardcoding one site's label prefix
Two things a second site ran into.

The network device form asked for the map position as two raw numbers, so
placing a device meant reading coordinates off another screen and typing them
in. Machines, PCs and printers have had a "Set Location on Map" picker all
along, and the network API already accepted mapx and mapy - only the form was
missing. Same picker, same modal.

The 3D parts kiosk hardcoded 'WJ' as the prefix shown before the number box,
with a comment inviting whoever needed something else to edit the source. That
is West Jefferson's gage-lab tag format and nobody else's, so another site's
operators were told to expect letters that are not on their labels.

It is now printedparts_label_prefix, set in Settings, defaulting to EMPTY - a
site that has not set one sees no prefix rather than inheriting another site's
convention. West Jefferson sets it to WJ once. The kiosk hides the prefix
entirely when unset and falls back to no prefix if the setting cannot be read,
because a cosmetic hint must never stop a kiosk working.

Not to be confused with printedparts_code_prefix, which mints item codes like
3DP0042 and was already configurable. That is the code we generate; this is the
tag already printed on the label.
2026-08-05 13:16:41 -04:00
cproudlock
d141fef203 printedparts: show revision at kiosk; low-stock email uses gage tag + item link
Kiosk now displays the scanned revision (badge, quantity, and done screens) so
the operator sees which revision they checked out - it was already recorded on
the take, just not shown. Low-stock email now uses the gage lab tag (was the
internal item code) and links to the item page when site_base_url is set (new
core setting, category email; emails have no request context to derive the URL).
2026-07-22 10:12:51 -04:00
cproudlock
baf6862151 printedparts: QR label with gage tag + revision; record taken rev
Label switches from CODE128 to a QR encoding 'TAG|rev' (gage lab tag + latest
print-file revision), so a physical part carries which revision it was printed
from - short payload stays low-version + reliable at 0.5in (margin quiet zone,
EC M, no logo). Item exposes latestrevision; kiosk strips the |rev to resolve
and records the scanned revision on the take (migration 0004 adds
printeditemtransactions.revision) for traceability of which rev was consumed.
Manual entry records a null revision.
2026-07-22 07:52:17 -04:00
cproudlock
18f027c951 printedparts kiosk: show gage lab tag (not item code) on the SSO prompt
The badge step card still showed the internal item code; show the gage lab tag
(fallback to item code) to match the label and list. The take still posts the
item code as the stable identifier.
2026-07-20 11:34:12 -04:00
cproudlock
108d4d396c printedparts: kiosk tag prefix + labels use the gage lab tag
Kiosk item lookup shows a fixed "WJ" prefix addon so operators type only the
number off the label. The label page - and the detail "Part Label" button,
renamed from "Bin Label" - now barcodes and prints the gage lab tag, falling
back to the internal item code when a part has no tag assigned.
2026-07-20 11:18:16 -04:00
cproudlock
91fa3b9115 printedparts kiosk: one visible entry input for scanner and keyboard
The bin step hid entry behind a "Type the number" link, and a plugged-in
keyboard could not drive the visible fields. Replace the hidden wedge input
with one visible, always-focused input per step that a wedge scanner, a
physical keyboard/numpad, and the on-screen keypad all feed; Enter submits.
inputmode="none" keeps the OS soft keyboard from popping on a touchscreen.
2026-07-20 11:14:00 -04:00
cproudlock
af9a3b190b ADR-013 Phase 4: frontend staging machinery + relocate printedparts; fix router crash
The staging step that makes lean per-site frontend builds possible, plus the
first plugin relocated as the pilot.

- scripts/stage-frontend.mjs: copies each chosen plugin's plugins/<name>/frontend/
  into frontend/src/.plugins-staged/<name>/ and codegens routes.gen.js. Plugin
  selection via SITE_PLUGINS (comma-separated); empty = all plugins that have a
  frontend/ (the full build). Wired as npm predev/prebuild; outputs gitignored.
- Router imports routes.gen.js and merges staged routes with the in-tree
  ./routes/*.js glob - dual-location during the transition.
- printedparts relocated: its 6 views (list/detail/form/kiosk + the settings and
  labels views from the shared dirs) moved into plugins/printedparts/frontend/
  views/, core imports rewritten to the @/ alias; routes.js is the self-contained
  route module. Its old in-tree route file is removed.

Also fixes a crash the previous commit (37c764b) shipped: slides.js exports only
`toplevel` (its child routes live in core.js), so the router's
flatMap(m => m.default) produced an undefined child and threw
"Cannot read properties of undefined (reading 'path')" at load - the whole SPA
went blank. Guarded with `m.default || []`. (The earlier "print pages are blank"
reading was this crash, not page nature.)

Verified live: /machines renders again; the relocated /printedparts list renders
identically from the staged plugin frontend; SITE_PLUGINS=machines excludes
printedparts from routes.gen. Build (via npm, runs stage) + vitest + naming green.
2026-07-18 23:42:43 -04:00