2feb569686aee682b004e7576343ad3ecf0a28d5
205 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2feb569686 |
Rename Metrics to Monitors and move it second
"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 |
||
|
|
2122c3b45c |
Tab the PC detail page, and let a panel say which tab it belongs to
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 |
||
|
|
df083776b3 |
Offer the search box to whoever can actually search
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 |
||
|
|
d9eb7a74db | Regenerate the project map after promotion | ||
|
|
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
|
||
|
|
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
|
||
|
|
5f27f81e88 |
Clear the review blockers on the merged wilm work
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.
|
||
|
|
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. |
||
|
|
7a8acbbcb5 | Regenerate the project map after promotion | ||
|
|
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. |
||
|
|
70e909054f |
Document the endpoints plugin in the catalogue
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. |
||
|
|
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. |
||
|
|
1cc2a6b782 |
Recommend the smallest upgrade that clears an advisory, not the highest
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. |
||
|
|
c4e2db4755 |
Route the dependency-security doc from START-HERE
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. |
||
|
|
794bb4668f |
Add vulnerability scanning on top of the SBOM
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. |
||
|
|
0baed4cc80 |
Close the gaps against the design: critical tile, filters, policy card, action placement
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.
|
||
|
|
414a7243d7 |
Merge events and commands into one Activity feed, and lead with live state
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. |
||
|
|
54f0857e13 |
Split the fleet roster from per-endpoint reads, and stop offering controls nobody can use
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. |
||
|
|
af42ba3589 |
Register the endpoints list without a trailing slash, and stop hiding its errors
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.
|
||
|
|
ae800772d1 |
Make the dev MySQL 5.6 index-prefix requirement actually reachable
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. |
||
|
|
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.
|
||
|
|
8953ce8210 |
Ticking a section off crosses out the section
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. |
||
|
|
afce24379c |
Reading anything 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 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. |
||
|
|
2331c03664 |
Tick a guide's sections off as you work through it
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. |
||
|
|
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. |
||
|
|
8531559f22 |
A guide can be carried to another site
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. |
||
|
|
c8efadefba |
An HTML article can become a guide
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. |
||
|
|
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. |
||
|
|
95ad4f1ef3 |
Docs: the USB kiosk, its endpoints and why the list costs a badge
Changelog, the plugin table, the three kiosk endpoints in the API inventory, and regenerated openapi.json / PROJECT-MAP. |
||
|
|
90c3c3102b |
Docs: guides in the indexes, the stalest-kind rule, and the changelog
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. |
||
|
|
580c0d0c80 | Record the tab title and Guides packaging fixes in the changelog | ||
|
|
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. |
||
|
|
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. |
||
|
|
7fb6bdba07 | Regenerate the project map for the new script tests | ||
|
|
5f915310c9 |
Point the lab's reference table at main too
The header stopped claiming the deleted feat/printedparts-plugin branch; its "where to look" table at the bottom still did. |
||
|
|
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
|
||
|
|
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. |
||
|
|
adc7ef43f9 |
Stop the collector being able to set a printer assignment
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. |
||
|
|
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. |
||
|
|
d4b49b91b4 |
Move secure send into Tech Tools, where somebody would look for it
|
||
|
|
98458672eb |
Hand somebody a password without leaving it in a chat window
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. |
||
|
|
5001aedcf9 |
Offer a spelling on every list, not just the global search
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. |
||
|
|
5de3fe4b40 |
Note secret sharing as a tabled tool, with the reasoning attached
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
adb30c8875 |
Release 0.12.0
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. |
||
|
|
c6c806667e |
Run the database version the rest of the product already recommends
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. |