Global search is gated on assets.view, and that was the whole check.
Behind it the route queried twelve domains: applications, the knowledge
base, guides, the staff directory, assets, measuring tools, USB devices,
printed parts, custom fields, notifications, hostnames, and vendor and
model reference data, plus addresses and subnets for an IP-shaped query.
None of them consulted their own permission.
So anyone who could look up an asset number could also read the staff
directory out of the HR database, the USB checkout history and who holds
which drive, and the network inventory. The SEARCH_DOMAINS settings look
like they bound this but they are site feature toggles, not
authorization.
Each domain now answers only to a caller who holds its permission.
Assets, custom fields and vendor/model stay on assets.view because all
three emit asset rows routed to asset pages.
caller_holds rather than require_permission: refusing the whole request
because one domain is off-limits would make search useless to anyone
holding less than everything. A domain the caller cannot see contributes
nothing and the rest still answers.
This is what makes assets.view safe to grant the guest role again. It
left the grantable set in 3474028 because of the leak above, and that
took the shop floor's asset lookup with it - a signed-out visitor at a
machine could no longer search for the asset number in front of them.
Now the grant reaches assets and nothing else.
The first version of the leak test passed with the gates removed. The
staff directory is an external HR database and is unreachable under
test, so the domain silently returned nothing and the assertion proved
nothing. It patches _employee_rows now, and fails when the gate goes.
Scope: product
126 KiB
Changelog
All notable changes to shopdb-flask are recorded here.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.
The product version (__version__) and the plugin-contract version
(__contract_version__) are distinct series with independent bump rules; see
ADR-007 and ADR-002.
[Unreleased]
Added
-
A link that opens the code generator with the work already in it. Copy share link hands somebody a URL that reopens the page with the content, the label, the copy count and every layout value the sender changed - so "here is the label, print it" is a link rather than a screenshot and a list of settings to retype. Everything rides in the fragment, which browsers never transmit: it does not reach Flask, the IIS log or the proxy in between. Only the values that differ from a first-time visitor's defaults travel, so the link stays short enough to paste into a chat box, and an unknown key is ignored rather than refused, so a link made by a newer version still opens. The picture never travels - a data URL runs to megabytes and no chat client would carry it. This is convenience, not secrecy: anyone holding the link can read the content, which is what Secure Send is for.
-
Reading anything now needs a permission. A sweep of the URL map found 165 GET routes that answered anybody with no token at all: the whole asset inventory, every machine, PC, printer and network device, the USB checkout history and who holds which drive, the staff directory, the core reports. Guest access had been argued about one feature-flag at a time while the front door stood open. Every one of them is now behind the read permission it belongs to -
assets.view,machines.view,printers.viewand so on - which also means the guest role can open exactly the ones a site chooses and nothing else.Thirty-three endpoints stay public, each named with its reason in
tests/test_core/test_public_read_surface.py: the app shell and its bootstrap, the settings allowlist, images fetched by<img src>(which carries no Authorization header), what a display kiosk renders before anybody signs in, the parts and USB kiosks, the client scripts that run as SYSTEM before a user session exists, and a secure-send link whose token is itself the authorisation. That file is a standing gate: a new public read fails CI until somebody adds it to the list, in front of a reviewer. Four more reads need a login but no permission because the handler decides from who you are - your own user record, your own API tokens, the role catalogue - and the same test proves each still refuses an anonymous caller.The employees plugin gained
employees.view: the directory is names, SSOs, photographs and reporting lines, and it had no permission at all. -
A guide's sections can be ticked off while you read it. Only checklist items were tickable, which answered "what have I done" but not the question a long procedure actually raises - where was I. Somebody looks away to do the thing the guide asked for and looks back at a wall of text. Every step and every heading now carries a checkbox in the reader; ticking one strikes it through and fades it rather than hiding it, because scanning back up the page has to still show what was done. Headings count as well as steps: a guide converted from an HTML article often has fifty headings and no steps at all. Progress is per browser and per version, as it already was for checklists - shopfloor PCs share a login, so one technician's ticks must not greet the next person on that bay.
-
A guest is a role now, not a switch per feature. Whether somebody with no account may see something was answered one feature at a time: a
securesend_allow_guestssetting, an enable flag per kiosk, and very nearly a third for guides - each with its own settings page and its own hand-written branch inside a route. It is a permissions question, and this app already administers permissions per role, so anyone not signed in now holds the seededguestrole. It starts with nothing, so an upgrade opens no door; a site opens one by ticking a box in the same role grid it uses for everybody else, and closes it the same way - taking effect on the next request, not the next restart.require_permissionconsults the role when there is no token, which is the whole mechanism.What a guest may be granted is bounded: any
.viewor.readpermission, plus writes a plugin has explicitly declared safe to perform without an account (get_guest_grantable_permissions, contract 0.21.0). Secure Send declares its create permission, because sending a password to a vendor is the one write the product means to offer somebody who never signs in. Everything else is refused by the API and greyed out in the grid rather than warned about. A guest write records no creator - the audit trail can say when and from where, never who - and that is the reason for the bar.GE-Enforce and Slides gained read permissions (
geenforce.view,slides.view) so they can be opened to guests without handing out the manage right; their read routes accept either, so nothing an existing manager could reach has moved. The navigation endpoint now returns only what the caller can actually reach, so a signed-out visitor sees the pages that were opened to them rather than a sidebar of links that bounce off a login page. -
USB check-in / check-out audit report. CMMC removable-media tracking is an audit obligation and there was no report for it - answering "who had this drive, and when" meant paging through the check-in/out list by hand. The window is named rather than a day count: today, last month, six months, a year, or all time. Events rather than totals, because that is the question an audit actually asks, with a CSV export for the people who want it in a spreadsheet. Works against the external
cmmc_usblog or the self-hosted tables; in self-hosted mode one stored loan expands into a take and a return, so a returned drive is distinguishable from one still out. -
flask guides export/flask guides import- a guide can reach another site. Sites are separate databases and the ones that matter are air-gapped from wherever a guide gets written, so a procedure written once was stranded where it was typed. Export writes one.guide.jsonper guide with its images carried inside it - one file, because a bundle that needs its images copied alongside it is a bundle that will eventually arrive without them - and import reads them back. The slug travels, since that is the address somebody pastes into a work order and the same guide should answer to the same name on every site; ids, revision history and publication state do not, because those belong to the database that issued them. Imports arrive as drafts;--replaceupdates a guide already carrying that slug rather than creating a second one, which is how a corrected guide is re-shipped. -
flask guides import-html- an existing HTML article becomes a guide. A site adopting this app arrives holding procedures already written: styled HTML pages that get mailed around and opened off a share. Retyping one into the editor is how a good procedure stays on a share forever. The importer reads the page and produces blocks - steps, callouts, code, checklists, tables, lists - and lifts embedded images intoguideimagesso a 1.6 MB document stops carrying its screenshots as base64. Imports land as DRAFTS, and--dry-run(the default) reports what it would produce without writing. Conversion is deliberately lossy: colour, fonts and layout go, because that is the whole reason a guide is stored as blocks rather than markup. The blocks go through the same validator the editor uses, so an import cannot store something the editor could not have produced.
Fixed
-
Global search answered twelve domains behind one permission. The route is gated on
assets.view, and that was the entire check: looking up an asset number also read out of the staff directory, the USB checkout history, the notification log and the network inventory, none of which consult their own permission. TheSEARCH_DOMAINSsettings are site feature toggles, not authorization, and were never doing that job. Each domain now answers only to a caller holding its permission -employees.viewfor the directory,usb.viewfor checkouts,network.viewfor address and subnet matches,computers.viewfor hostnames. Asset, custom-field and vendor/model results stay onassets.view, since all three emit asset rows. The check iscaller_holdsrather thanrequire_permission: a search that refused the whole request because one domain was off-limits would be useless, so a domain the caller cannot see simply contributes nothing. This is also what makesassets.viewsafe to hand to the guest role again, which is what lets a signed-out visitor look up an asset number without also getting the staff directory. -
The printer supplies card was ordered by printer name, so the emptiest cartridge was not first and sometimes was not shown at all. Rows sorted on "is critical" and then alphabetically, and critical is everything at or below 5% - so a cartridge at 5% and one at 0% tied on the first key and the alphabet broke the tie. That is the wrong order on its own, and worse than it looks: the dashboard queue keeps only the first few rows per source, so a printer that was genuinely empty got truncated off the card while a fuller one held its place. The card exists to answer "what do I walk out and change today" and was answering it with the alphabet. Rows now sort by the emptiest cartridge on the printer, ascending. Waste cartridges were already normalised to percent remaining, so one ascending sort is worst-first for every supply type and the critical flag falls out of it rather than needing its own key; the name only breaks ties.
-
A permission became grantable to anonymous visitors because of how it was spelled.
guest_grantable_permissions()opened any permission whose name ended.viewor.readto the guest role, on the reasoning that reading is the safe default. That is true of a permission gating a list of asset numbers and false of one gating bytes, and a suffix cannot tell them apart. Twenty-four of eighty-five qualified, and nobody had decided any of them:applications.viewreached the SYSTEM-context PowerShell bodies and the installer binaries a site pushes to its bays;backups.viewreturned the full payload that the separatebackups.downloadpermission exists to protect;assets.viewgated a global search that queries the staff directory, the USB history and the network inventory with no per-domain check of its own;audit.viewandsettings.vieware admin-category and were opened by naming alone. Every entry is now DECLARED, by core or by the plugin that owns it, having read what its routes actually return. Adding one is a code change, which is the point - it puts the decision in a diff rather than in a suffix. The two routes serving script bodies and installer packages moved to a newapplications.downloadpermission, which is not grantable, so the catalogue metadata can still be opened to a site's floor without the bytes going with it. -
Deactivating an account did not end its sessions. There is no token denylist in this product, so the JWT user loader is the only place a deactivated account can be stopped, and it did a bare lookup by id. Somebody deactivated at 09:00 kept a working access token until it expired and a refresh token that minted new ones for thirty days.
/api/auth/refreshand the API-token path already checkedisactive; the loader now does too. -
The login rate limit could be reset by a header the caller writes.
client_ip()read the firstX-Forwarded-Forhop unconditionally, so rotating it per request bought an unbounded budget, and every audit row recorded the forged address. This walked aroundTRUSTED_PROXY_COUNT, which exists to decide whether a proxy is really in front: the app deliberately leaves ProxyFix off by default for exactly this reason, and then read the header anyway. Resolution is now fromremote_addr, which ProxyFix has already filled from the header where a proxy is configured. Two plugins had hand-rolled the same mistake for their own limiters and now share the helper, which is onshopdb.apiso the third one does not. A site behind IIS must setTRUSTED_PROXY_COUNTor its whole floor shares one rate-limit bucket; INSTALL-WINDOWS-IIS.md now says so. -
What an operator approved and what an endpoint ran were two different lookups. Delivery descriptors for
run_scriptandupdate_appare resolved when the agent claims a command, not when the command is queued, and the command row recorded no digest - the enqueue path resolved the descriptor purely to validate it and threw the result away. Anyone able to replace a script body or an installer package could do so in the gap, and the re-resolved descriptor would then faithfully describe the new bytes: the agent verifies correctly and runs something nobody approved, with the audit trail naming the operator who queued the original. The approved digest is now pinned on the command at enqueue and the claim refuses a descriptor that no longer matches, failing the push instead of shipping it. Separately, an agent may only finalize a command that is actually running, so a result cannot be posted for a command that was never claimed nor overwrite a recorded failure with a success. -
The published site's API documentation page had no spec to show.
shopdb/core/api/docs.pyreadsdocs/openapi.jsonat runtime to serve/api/docs, and publication excluded the whole ofdocs/because the prose becomes the wiki. So every site installed from the published repo shipped the ReDoc page, loaded it, and answered{"error":"openapi.json not generated"}. The spec now publishes: it is a product file that happens to live underdocs/, not documentation. The prose still goes to the wiki and nowhere else. -
Three gates were reporting green by not running.
test_api_inventory_parity,test_openapi_specandtest_docs_publishableall skip themselves when the file they read is absent - correct in a published checkout, and silently wrong on GitHub, which runs against exactly that checkout. An outside branch added nine routes documented nowhere and passed CI.scripts/check-gates-ran.shnow asserts each gate actually ran, splitting the ones that must run everywhere from the ones that need files publication deliberately leaves behind, and a newgatesjob runs it in CI. Verified in both directions: it passes here, and it fails when the file a gate reads is taken away. -
Secure Send now hands you a message, not a bare URL. A link on its own invites being pasted onto text already sitting in the chat box, and a chat client turns the selection into the link's caption - so the secret travels as the visible message and the link beside it protects nothing. That happened in production: six LAPS passwords sent as the caption of a link whose whole job was to keep them out of the chat. Copy message puts a sentence on the clipboard instead, which has nowhere to attach itself, and the success card now says plainly to delete the password from the chat box before pasting. Nothing about the link itself was wrong - the URL carries only the token, the key rides in the fragment, and neither ever reached the message.
-
Ticking a section off crossed out its title and nothing else. The strike now covers everything under that heading, including its sub-sections, which is what "this section is done" means to somebody who ticked the thing that contained them. It needed the heading RANK, which the HTML importer was discarding: every heading arrived as a sibling, so a parent had no children to cross out. Headings now carry their level, render at their rank, and a guide written before this reads as flat, exactly as it did. Code blocks, tables and images inside a finished section are faded but not struck - a line through a command makes it unreadable, and re-reading the command is usually why somebody looks back at a step they have already done.
-
Tech Tools was invisible to anyone without an account, even though every tool on the page is deliberately usable without one and Secure Send is the single write this product offers a guest. The nav item named no permission, because the plugin had none to name, so the sidebar filter dropped it. The plugin gained
tools.view, and the item now names either that ortools.secrets- somebody granted only Secure Send still needs the entry that leads to it. -
The sidebar offered pages the reader could not open, and hid ones they could. A nav item is hidden from anybody who does not hold its permission, but only four items ever declared one - so the filter did nothing for the rest, and a signed-out visitor granted
machines.viewstill saw an empty sidebar. Every item now names the read permission its page needs, in the plugins, in the core navigation endpoint and in the frontend's fallback list. A signed-in member with a sparse role will notice the same correction: a link that could only answer 403 no longer appears. -
Every report offers to email itself, not just two of them.
POST /api/reports/emailhas always taken any{columns, rows}, but the button was on the toner and warranty pages only - because those two happen to be their own routed pages, and the reports page had no button at all. It is now beside Export CSV for every report the page can build a table from, which is all of them: the plugin reports through their declared columns, the core reports through the flat list they return, and the asset inventory summary by flattening its three tables into one with the grouping named in a column. Presentation-only fields - a status colour, a link id - are left out, since a column of hex codes is worse than no column. The button is hidden without thereports.exportpermission the endpoint enforces, on all three pages: offering somebody a button that can only answer 403 is worse than not offering it. -
A long drive name pushed Take and Return out of the card. On the USB kiosk the name is a flex item, which defaults to
min-width: auto, so it kept its full intrinsic width and shoved the button through the side of the card - leaving a drive nobody could take. Names now wrap, including the serial-style ones that carry no spaces at all, and a description is clamped to two lines so one long name cannot make every card in its row tall. The locker line is never clipped: it is how somebody finds the thing. -
Muted text on the USB kiosk and in guides was not muted. Both named a
--textmutedvariable this app has never defined, so the browser dropped the declaration and the hints, subtitles and locker lines rendered at full strength against the text they were meant to sit behind. The variable is--text-light. -
Plugin reports rendered a raw JSON dump. The reports page ran a hardcoded switch over five core report ids and fell through to
console.error('Unknown report'), and its results panel was a matching chain of per-id templates ending in aJSON.stringify. So a plugin could register a report, watch it appear in the catalogue, and get a wall of JSON when it was opened - which is where the three printedparts reports have been since they were added. Any report that declares an endpoint is now fetched from it, and any report returning{columns, rows}renders as a table. A plugin needs no core code to have a working report. CSV export follows the same endpoint rather than assuming/api/reports/<id>, which does not exist for a plugin report, and a report that fails now says so instead of showing an empty panel. Export CSV is fetched through the api client rather thanwindow.open: a new window carries no Authorization header, so every report behind a realjwt_requiredanswered the export button with a 401. The core reports are jwt-optional, which is the only reason the old way ever appeared to work. -
Reports had no way to choose a time period. Endpoints accepted a window; the page offered no control for it, so
printedparts-consumptionwas permanently stuck on its 30-day default. -
USB kiosk: check a drive out and back in without a login. The people who actually take USB drives are standing at the cabinet, and most of them have no ShopDB account - so check-out and check-in were gated behind a login that was not doing much anyway, since both modes already attribute the action to a scanned BADGE rather than to the signed-in user.
/usb-kioskis a full-screen touch screen modelled on the parts kiosk, and works against the self-hosted tables or an existingcmmc_usbdatabase.It meets the same bar as the parts kiosk: it can move a device between checked-out and checked-in and nothing else - no create, edit or retire - and it does not exist at all until a site sets
usb_kiosk_enabled. An unauthenticated write is a decision about the door.The drives carry no barcode, so unlike the parts kiosk there is nothing to present and the screen has to offer a LIST. That widens what a logged-out screen exposes, so the list is not anonymous: an SSO comes first and has to resolve to a real person before any drive is named - the same bar taking one already had to clear. The main screen shows the caller's own drives, what is free, and a COUNT of everything else; the holders of other people's drives appear only behind a deliberate second tap, so a passer-by cannot read the whole cabinet off a wall panel.
A badge that resolves to no employee is REFUSED, unlike the signed-in route which creates the user row: a checkout nobody can trace to a person defeats the point, and at a kiosk there is no operator standing there to vouch for it.
Returning somebody else's drive asks first, then records the badge that was scanned - so the log says who actually handed it back, which is the honest record and needs no change to a schema this app does not own. Refusing a mismatch outright is available as a setting, but is not the default: a drive left behind by somebody on leave would then be unreturnable at the kiosk, which just moves the problem to a person with a login.
The list of free drives is filtered from a search box, and lays itself out in as many columns as the panel is wide - up to four on a wall display. A cabinet of thirty drives was one long column somebody had to read through. A drive is found by its id, by what is written on it, or by where it lives, in whatever word order comes naturally: "locker engineer" finds the engineering locker, because that is what people say.
-
Audit log retention. The audit log is append-only and nothing ever removed a row, so it grew for as long as a site ran - here to the point where the audit page was tens of thousands of pages deep and therefore useless, because nobody pages through that to find anything. Site & Facility gains
audit_retentiondaysplus a card showing the log's size and exactly how many entries a purge would remove, and there isflask audit stats/flask audit purge --applyfor the server. Opt-in: the default is 0, which keeps everything. An upgrade that silently started deleting a site's audit history would be indefensible. Deletion runs in committed batches, because oneDELETEover a million rows holds table locks long enough to block the collectors posting inventory; a run that stops early reports how much is left rather than claiming it finished. Purging needs its ownaudit.purgepermission - reading the log and destroying it are different acts. -
Guides: images, and a reader who cannot see drafts. Guide images are fetched through the authenticated API client instead of being pointed at by
<img src>. An image tag sends no Authorization header and this app keeps its JWT in one, so every guide image was a broken icon for every user; the same change makes stored image links survive a subpath mount. A failed image now says so in words rather than showing a broken icon. Revision and image routes are gated on their parent guide - revision ids are sequential, so reading one guide used to be enough to walk the ids and read every unpublished draft on the site. Search, the guide list and the tag chips now match the PUBLISHED text, not the working copy, so a reader can no longer get a hit on wording that is not in the page when they open it.
Fixed
-
A guide's URL no longer moves when its title is edited. The slug was regenerated on every save that carried a title, and the editor autosaves every three seconds - so fixing a typo in a heading silently moved the guide and broke exactly the pasted links the stored slug exists to protect. The slug is now set once, at create.
-
Publishing a guide after a failed save reported success.
publish()ran regardless of whether the pre-publish save reached the server, so the author saw "Published version N" while readers got the previous revision. A failed save now stops the publish and says why. -
The ADR-015 site-literal check was enforcing nothing. Its patterns are assembled from fragments so the published script does not contain the strings it greps for, but they used the apostrophe-escape form (
'a'\''b') instead of concatenation ('a''b'), embedding a literal apostrophe in every pattern. Nothing could ever match and the check passed everything from the day it was written. Fixed, and it immediately found 25 real violations - among them a QA ServiceNow tenant URL sitting in the shipped defaults in seven places. -
ServiceNow ships off and blank. The defaults pointed at one organisation's QA ServiceNow tenant with the integration enabled, so a site that had never configured ServiceNow still rendered ticket links - to a tenant its technicians cannot reach, and for an adopting site, into somebody else's instance. There is no cross-site default for "which ServiceNow is yours".
-
Credentials are no longer stored in backup revisions. The NTLARS/DNC registry carries an FTP password in cleartext, and it was stored verbatim - rendered by the revision panel and handed back by the download route as a ready-to-import
.reg. Credential values are now redacted at parse, so they never reach the database, and the rendered file NAMES each withheld value instead of carrying a placebo one: importing a fake password would produce a machine that looks restored and cannot connect.scripts/redact_backup_secrets.pyscrubs revisions recorded before this (dry run by default). Rotate any password that was stored. -
A search domain that fails is no longer indistinguishable from one that found nothing. Each domain catches broadly and returns no rows so one broken table cannot take the whole search box down - but the technician was then told they might have misspelled a query that was right, and concluded the record did not exist. Failed domains are named in the response, shown as a banner, and suppress the did-you-mean.
-
An empty
sourcehostnamedefeated backup dedup permanently. The dedup query maps''toIS NULLwhile the insert stored the raw'', so those rows could never match their own chain: every post inserted, the table grew without bound and the history filled with identical entries. Normalized once at ingest, whitespace included. -
printedpartsmigration 0003 was unguarded. Unlike its twenty siblings it ran a bareALTER, soflask plugin upgrade-allfailed on any database built bycreate_alland took 0004 down with it. The migration test that should have caught this only covered single-migration chains; it now covers every post-cutover chain. -
Guide links with several parameters opened the wrong page. Text is escaped before formatting, so a captured URL arrives carrying entities and was then escaped a second time -
&amp;in every multi-parameter ServiceNow and SharePoint link. Emphasis could also reach inside anhref, and//hostpassed as an in-app link when the browser reads it as off-site. -
Retire an asset from the interface. Machines, PCs, printers and measuring tools had no way to take a record out of ShopDB without calling the API, so a scrapped tool stayed in every list and report. The endpoints already existed and were already soft deletes: the record leaves the lists while its calibration history, backups and relationships stay put, and it can be restored by setting
isactiveback. Called Retire rather than Delete for that reason, and the confirm says what is kept. USB is not included - it retires through a status column in the external CMMC database and needs its own decision. -
flask catalog dedupe,mergeandrename: catalog hygiene. The legacy data carried space-stripped twins -DellInc.besideDell Inc.,Windows10besideWindows 10- which rode into the shipped seed catalog and out to every site that ranseed catalog.dedupemerges only whitespace-and-punctuation twins, because those are one name typed two ways;DellandDell Inc.normalise apart and are deliberately left alone, since that is a naming choice only a person can make. The surviving row is the most referenced, tie-broken toward the spaced spelling.mergemakes that judgment call explicitly and refuses to invent a target that does not exist.renamefixes a stripped name with no twin, which neither of the others can reach. All three report and change nothing unless--applyis passed, anddedupeis idempotent. References are discovered from the live schema, not listed in code: there are 14 foreign keys into these three tables, andmachines.controllervendoridis not one anybody would enumerate by hand. -
Secure Send: hand somebody a password without leaving it in a chat window. A link in Tech Tools that carries one credential, dies when it is opened, and expires on its own if nobody opens it. The secret is encrypted in the sender's browser and the key rides in the URL fragment, which browsers never transmit, so the server stores ciphertext it cannot read and a compromised database yields nothing. Nobody can recover a lost secret either, including an administrator, which is the same property seen from the other side. Redeeming is a POST because chat and mail clients prefetch links to build previews, and a one-view secret consumed by a preview window is a secret the recipient never sees; a GET reports availability only. Expiry defaults to 24 hours (maximum a week) and views to one, whichever comes first, and a spent row is deleted. Creating needs a login and
tools.secrets; reading needs neither, because the recipient usually has no account and the 128-bit token is the authorisation. The 4096-character ciphertext limit is deliberately the whole server-side enforcement: a zero-knowledge server cannot tell a password from a document, so size is the only control it has, and raising it turns this into the file transfer it was scoped not to be. Documented indocs/SECURE-SEND.md. -
A site can let people without an account create a Secure Send link. New
securesend_allow_guestssetting (Settings > Tools > Secure Send), off by default because a guest secret has no creator and the audit trail can then say when and from where but never who. Guest creation is rate limited per address and the setting fails closed if it cannot be read, so an error can never be what opens an unauthenticated write endpoint. -
The code generator can print a back side. A badge card is two-sided and the generator only ever emitted one page per card. The Back side section turns on a second page per card carrying either the same code again, a fixed block of text, its own per-row content (CSV
back/backlabel), or nothing. Page order is a choice, because the two kinds of printer want opposite things: interleaved front-back-front-back for a duplex card printer, grouped all-fronts-then-all-backs for a single-sided printer that gets its stack flipped and reloaded. Getting that backwards prints perfectly readable cards with the wrong back on every one, which is why the order is pinned by tests rather than by looking at the preview. -
The gap between a code and its caption is its own control. It was the quiet zone doing double duty: closing up a caption meant shaving the clear space a scanner needs, and the only way to get a code to scan was to push the caption away from it. Quiet zone now means only the code's own margin; gap means only the distance to the caption, and adds to the quiet zone rather than eating into it. Every stock preset carries a gap that suits it.
-
Contents can be positioned inside the label without moving the label. Align and Vertical place the code and caption block anywhere on the stock, and Content X/Y nudge it from there. The existing X/Y nudge kept its behavior and is now labeled Media X/Y: it moves the whole printed area, for a printer whose origin is off, which is a different problem from composing a label.
-
A picture can go on the label. Pick an image once and it prints on every label - above the code, beside it, or behind everything as a watermark - on the front, the back, or both. The file never leaves the browser, so an air-gapped site can use it, and it is deliberately not saved between visits: a data URL runs to megabytes and would take every sizing value with it when the storage quota gave out.
-
The back can carry its own code, in its own symbology. "Back carries" now defaults to a code rather than text, takes per-row content from the CSV
backcolumn, and has its own QR/CODE128 choice - a QR on the front that opens the record, a CODE128 on the back that the bench scanner already reads. A back set to carry a code can no longer be laid out as text-only: that combination printed a blank card that looked like the code had failed, and it is corrected on load for anyone who already saved it.
Fixed
-
Every detail page had the same browser tab title. A machine, a PC and a printer all read "West Jefferson ShopDB - Machine" or the equivalent, so two tabs on two different records were indistinguishable, a bookmark said nothing about what it pointed at, and a link shared into Teams previewed as the section rather than the thing. Detail pages now name the record: machines, PCs, printers, network devices, subnets, measuring tools, USB devices, printed parts, applications, employees, knowledge base articles and guides. A record with no usable name, or one that failed to load, keeps the section name, and a long name is cut at a word boundary. Report pages gained proper titles too, which also fixes "Pc Relationships".
-
Guides would have been dropped at provisioning. The plugin was missing from
deploy/site-profile-universal.json, the profile the released Windows installer is built from, so the wizard would not have offered it andflask plugin prune-schemadrops the tables of any plugin absent from the profile (ADR-014). Its model also imported core directly rather than throughshopdb.api, which the contract test catches. -
The shipped catalog offered two ways to spell the same vendor, and one row that should never have left a test.
HP Inc.andZebra Technologieswere empty rows duplicatingHPandZebra, which carry every model between them; neither exists in this site's database, so they would have arrived as duplicates at a new site and nowhere else.UnscopedLiveVendorwas a test artifact:export_catalog.pyreads the live development database, so a row left behind by a lab run was published as reference data. The canonical name is whichever spelling holds the entries, which is why it isDell Inc.butHPandZebra. Vendors 51 to 48. -
The shipped seed catalog contained duplicate vendors, models and operating systems. Four space-stripped twins, each with a correctly spaced original already present, so a new site inherited the same company listed twice.
OptiPlex5000had no twin and was renamed toOptiPlex 5000rather than dropped. Vendors 53 to 51, models 128 to 127, operating systems 14 to 12. Not a code fault: the collector strips only leading and trailing whitespace and treats the OS list as lookup-only, so it neither created these nor is creating more. They came in with the classic-ASP import. -
A maintenance script could not find the database the site actually uses.
python scripts/export_catalog.pyon a deployed site died with 1045 "Access denied for user 'root'@'localhost'", naming a user that site does not have. Nothing loads.envfor a plain script run:wsgi.pydoes it for the served app and theflaskCLI does it for itself, so both reach the real database, while a barepython scripts/...gets neither and falls back to a hardcodedroot@localhostURL. The read-only failure is the harmless version - six of the seven scripts write, and on a box where those fallback credentials happen to resolve they would have silently rewritten the wrong database. All seven now load.envfrom the repo root, resolved from the script's own location rather than the working directory. -
A search result opened the list it sits in, not the record it found. A NOTICE linked to
/notifications, so clicking the row search had already identified sent the reader to the list page to find it again by hand. The id was in the payload the whole time and only the URL ignored it. The subnet searcher had the same defect: an IP search resolves which subnet an address falls inside, then linked to the/networkhub rather than that subnet. Both now link to the record. Every other result type already carried its id. -
flask plugin newscaffolded a frontend that no lean build could prune. It wrote views tofrontend/src/views/<name>/and a route file tofrontend/src/router/routes/<name>.js, while all 16 bundled plugins keep their frontend inplugins/<name>/frontend/, staged bystage-frontend.mjs. Nothing looked broken, which is why it lasted: the router still globs./routes/*.jsduring the Phase 4 dual-location transition, so the pages loaded. What a new plugin author silently lost was ADR-013 lean builds, since pruning works off the staged tree, plus stranded views on deleting the plugin. The templates for the right layout already existed; only the output paths were wrong. Their core imports moved to the@alias, which resolves the same before and after staging, where the previous../../apiwas correct only in the old location. -
A zero bar height printed a barcode 176 wide and 0 high. Nothing rejected it: the value went straight into a CSS length,
height: 0inis perfectly valid, and an image atheight: 100%of a zero-height box is zero pixels tall. A cleared field was its own version of the same bug -'' + 'in'is not a length, the browser dropped the declaration, and the box fell back to auto height. Every measurement now goes throughlabelVars(), which will not emit NaN, an empty length, or a zero where a zero means the box stops existing; padding, quiet zone and gap still accept zero, because there it is a real answer. The stylesheet carries its own fallbacks too, though those only cover a property that is absent - a property set to garbage is dropped at computed value time and takes the fallback with it, which is why the sanitiser is the fix and the CSS is only a net. -
A caption could eat the barcode. The code box and the caption both grew, so any caption took half the label and kept going: "BAY 12 PRESS LINE 1" on 1.00 x 0.50in stock left a 36px stub, which is a code that is present, unscannable, and reads as one that failed to render. The caption now takes only the room it needs and a CODE128 keeps at least 40% of the label, all of it when stacked. A code that genuinely fails to render says so in the preview now, in red, rather than leaving white space that looks like a label with no code on it.
-
The generator prints from inside the app shell. It was a full-screen route with no sidebar and a narrow column of controls on the left. It is now an ordinary page - navigation, full width - and
style.csshides the shell at print time, so the stock still gets nothing but labels. Verified against a real print PDF rather than a preview. -
A test print paired each page with a code by loop position while the codes are keyed to position in the whole run, so a two-sided test card could carry the wrong code. The page index now travels with the page.
-
The back label's dotted preview outline outspecified the print reset and drew a dashed rule down the edge of every second card.
-
The warranty list could not be searched by machine number. The number was in the payload and rendered in the table as a chip, but the search box looked only at vendor, service level, service tag and asset number. That made the identifier the feature was built around - a shopfloor PC is warranted as a PC but found by the machine it drives - the one thing the box could not find. Machine number and machine name are now searched too, and the placeholder names them.
-
flask plugin upgrade-allfailed a plugin whose tables already existed ("table securesendsecrets already exists" on the tools plugin). Every post-cutover baseline emitted a plainop.create_table, which is correct only while a table's existence and its plugin's Alembic stamp cannot disagree. They can: anything that builds tables from the models stamps nothing, and a run that creates a table then fails before its stamp commits leaves the same split, where re-running is the obvious response. Creates and indexes across all nine table-creating plugin migrations now skip what is already there. Indexes are matched on their COLUMNS rather than their name, becausedb.create_all()names them by SQLAlchemy's convention and a migration by this repo's, so a name-keyed check would silently add a duplicate index instead of skipping. Note the remaining limit: this makes a baseline idempotent, not a whole chain. A longer chain can still meet anALTERwhose column is already present, which no guard oncreate_tableaddresses. -
Saving a machine that has a controlling PC answered "this relationship already exists" and refused the save. Reported against a map position change, but the map was incidental: the form re-POSTed the controlling-PC link on every save, and the row was already there. It used "do I know the existing link's id" as a stand-in for "is there an existing link", and read that id from a key the API does not emit, so the two disagreed on any machine that had a PC. The same conflation meant CLEARING a machine's controlling PC silently did nothing, which was the more dangerous half and had not been reported. The loaded PC is now what decides, and re-selecting the same PC is a no-op rather than a delete-and-recreate.
Changed
- The computers collector no longer accepts
defaultprinterorprinters, and can no longer write a printer assignment. Those keys used to becomedefaultprinter/connectedtorelationship rows straight from a report, which contradicts the rule the whole printer feature rests on: a collector report is OBSERVED state and never ASSIGNED state, or a bay that installed the wrong printer makes itself right simply by reporting it. It was not a dormant code path - the collector schema advertised both keys and the example client indocs/COLLECTOR-INTEGRATION.mdsetdefaultprinter, while another page of the same document promised nothing on that path writes an assignment row. A client still sending them keeps working: the keys are ignored, not rejected, and the response carries a warning naming them and pointing atPOST /api/collector/printersfor reporting queues andseed-from-observedfor adopting them. Silently dropping them would have left an integrator believing assignments were still being written. - Tech Tools no longer asks people to sign in for tools that need no
account.
/tools,/tools/codesand/tools/secure-sendwere all behindrequiresAuth. The label generator runs entirely in the browser - no API, no data, nothing to protect - so gating it only ever cost a guest the use of it, and gating the Secure Send page contradicted the guest setting that lets somebody with no account create a link. The server still decides who may create a secret: making a page reachable is not the same as allowing the action, and the two were conflated. - Settings on the left, labels on the right. The controls were a narrow column with the preview trailing underneath, so the labels being adjusted were off the bottom of the screen. The preview is now a column of its own that stays in view and scrolls by itself, and it folds back to one column on a narrow window. Note for anyone changing it: that scrolling column is exactly the kind of ancestor that clips a multi-page print to whatever was on screen, so print resets its overflow - verified by printing a six-page two-sided run, not by looking at the preview.
- CSV parsing and page building moved out of the generator component into
plugins/tools/frontend/labelPages.js, where the column contract and the two-sided page order are covered by tests.
[0.12.0] - 2026-08-20
Printers become a property of the bay rather than of whoever last walked up to a PC, and the container stops throwing away the site's own files on every update.
Air-gapped sites need a fresh offline bundle for this one. The stack moves to
MySQL 8.4 LTS, and an existing tarball only carries the 8.0 image, so
docker compose up would ask for an image the site cannot pull.
Added
- Printer assignment has a form.
PrinterAssignmentPickeris the ONE picker for both ends: MachineForm gained it (the machine is where the assignment belongs and there was no way to set it except the relationships card), and PCForm now uses the same component instead of its own copy, so the two sides of an override cannot drift. PCForm also stopped reconciling row at a time through the generic relationship endpoints, which left a PC half-assigned on an HTTP failure; it callsPUT /api/printers/assignments/for-asset/<id>instead. - A relationship type can declare itself singular
(
relationshiptypes.issingular, migration 7d34), anddefaultprinterdoes. Setting a second default REPLACES the first. The unique constraint is (source, target, type), so two different targets were two valid rows and the resolver took the OLDEST: a new default silently lost. - Observed printer queues.
POST /api/collector/printers(the ADR-006 hook, no new transport and no new credential) records what a bay ACTUALLY has in a plugin-owned table, kept strictly apart from what it is assigned. Adoption is explicit viaPOST /api/printers/assignments/seed-from-observed, which routes through the same reconcile path as the editor and REFUSES a queue matching no known printer. New client scriptReport-PrintersToShopDB.ps1. - Wave-gated driver rollout.
Install-ShopdbPrinterDrivers.ps1takes-WaveStart,-Waves,-WaveUnitand-IgnoreWave. GE-Enforce offsets each PC by SHA256(hostname) % 5 MINUTES, which was sized for a JSON check, not a 100 MB driver set: ungated, ~300 bays pull ~30 GB inside one five-minute window on the share the whole floor depends on. Each bay derives its wave from its own hostname, the gate runs BEFORE the manifest is read (the manifest is on that share too), and it fails closed on an unparseable date.
Changed
- MySQL 8.4 LTS in both compose files, and as the default in
build-offline-bundle.ps1. 8.0 reached end of life in April 2026, whichINSTALL-WINDOWS-IIS.mdalready said while the stack still pinned it; the two halves of the product disagreed about which server a site should run. Verified against a real 8.4.11 server rather than by changing a tag: the core chain and five plugin chains applied clean, 66 tables at a singleutf8mb4_unicode_cicollation, and the image's PyMySQL authenticates against 8.4'scaching_sha2_password(which is whycryptographyis pinned). Sites upgrading an EXISTING server should note that 8.4 removesmysql_native_password: an account created on 5.6 or 5.7 must be moved tocaching_sha2_passwordbefore the upgrade or it cannot log in afterwards. Air-gapped sites need a fresh offline bundle, since the tarball carries the MySQL image. - The database healthcheck probes over TCP rather than the unix socket. The
entrypoint's init pass answers on the socket while running the server with
--skip-networking, so a socket ping reported healthy DURING init anddepends_on: service_healthyreleasedapiandmigrateagainst a server that was about to restart. Found by having it happen: a probe passed at 8s and the next query failed because the server was mid-restart.
Fixed
- The image did not build.
npm run buildfires aprebuildhook that runsnode ../scripts/stage-frontend.mjs, which stages each plugin's frontend into the Vite tree and codegensroutes.gen.js. The Dockerfile's frontend stage copiedfrontend/alone and flattened it to the stage root, so that path resolved to/scriptsand every build since the staging script landed died onCannot find module. The stage now keeps the repo-relative layout and copiesscripts/stage-frontend.mjsandplugins/in beside it. Verified end to end: 16 plugin frontends staged,frontend/distin the final image. - The compose stack did not persist
instance/.db_datawas a volume and the instance directory was not, sodocker compose build api && up -d apirecreated the container and discardedplugins.jsonalong with every upload: floor plans, branding, model and application images, employee photos, warranty proofs, slides, printed-part files and the Dell OAuth token. The visible symptom was a site coming back with its plugins disabled. Both compose files now mount aninstance_datavolume (the air-gapmigrateservice too, sinceplugin upgrade-allwritesplugins.json), and DEPLOY.md carries the one-time rescue for a stack that predates it. - A drifted print queue is corrected, not just a missing one. Queues were
matched by NAME alone, so a bay whose printer had moved or whose queue was
built on a replaced driver looked converged and printed to the wrong device.
Set-ShopdbPrinters.ps1now repoints a wrong port and swaps a wrong driver IN PLACE withSet-Printer, so the queue keeps its name, sharing, permissions and whoever holds it as their default. The driver is only swapped when the wanted one is staged, and there is still no removal path in the script. - MachineForm's dropdowns all came up empty. It read
.data.dataoffcomputersApi.listAll(), which already resolves to the array, so the whole parallel load threw into the catch and the machine's own values never loaded. - The legacy import loader dropped
machines.printerid, the classic system's record of each machine's default printer, so the production import would have lost every one.
[0.11.3] - 2026-08-19
Fixes the last of the buildings-and-levels bugs, and brings the shop-floor clients into the product: the collector reporter, EventSaver and a printer assignment feature that lets a bay install its own printers.
Anyone on 0.11.0-0.11.2 should take this. The hover preview reported "this asset has a position but no level" for every asset, and the map PDF printed markers from every floor onto one sheet.
Fixed
- Every hover mini-map said "no level". When 0.11.0 gave LocationMapTooltip a levelid, none of its seven call sites were taught to pass one - printer, machine and PC detail, the toner report, enforcement reports, the warranty chip and the dashboard cards. Two payloads behind them also emitted coordinates with no level.
- Map PDF export printed other floors' markers. It plotted every filtered asset onto one sheet, which is worse than the on-screen version was: nobody can correct a sheet once it is printed and carried onto the floor.
- The legacy import loader created undrawable markers, sending mapleft/maptop with no level at three call sites. That loader is the one still to run against production.
- The build gate that should have caught all of this asked whether a FILE
mentions
levelid, not whether each position does - one module emittedmapxsix times andlevelidonce and passed. It now checks per occurrence, coversscripts/, and fails any Vue file that binds tooltip coordinates without a level.
Added
- Printer assignment. Printers belong to the MACHINE and reach whichever PC
controls it, so a reimaged or swapped PC comes back with the bay's printers and
nothing had to be saved off the old one. New
usesprinterrelationship type, propagating throughcontrolsat read time;GET /api/printers/for-host/<host>for the client;PUT /api/printers/assignments/for-asset/<id>to reconcile a whole set in one call. printerdrivers.vendorid, so one row serves a make. HP and Xerox universal drivers cover 41 of the reference site's 44 printers; binding a driver to a single model meant 21 near-duplicate rows. Resolution is model, then vendor, then the pre-vendorid name convention.- Client scripts in
plugins/printers/client/: stage a site's driver set (Install-ShopdbPrinterDrivers.ps1, with-TestOnlyfor DSC), converge a bay's queues (Set-ShopdbPrinters.ps1), and apply the per-user default (Apply-ShopdbDefaultPrinter.ps1). - The collector reporter is in the repository at
plugins/computers/client/, with no site in it, and the server generates a copy carrying this site's URL and ranges: Settings > Computers > Asset reporter, orGET /api/computers/client-script. The collector key is deliberately never stamped into it. - EventSaver source at
plugins/slides/client/. The site UNC that was compiled into it is gone; a missing ini now fails visibly rather than pointing a new site at the reference site's file server. - Settings:
computers_routableranges(replaces two hardcoded VLANs) and the buildings-and-levels admin gains editable width AND height.
Changed
- The map editor accepts a click. Its handler was bound only if the map was already a picker when it mounted, and the editor opens with nothing selected, so placing a marker by hand was impossible for the life of the page.
- Global search orders every query before truncating, so the same search cannot return a different subset twice.
[0.11.2] - 2026-08-17
Bug fixes for the buildings-and-levels work in 0.11.0, all found by using it. Anyone on 0.11.0 or 0.11.1 with more than one level should take this: until now the map drew markers from every level on whichever floor plan was showing.
Fixed
- Markers from other levels were drawn on the displayed level. The map component filtered out positions with no coordinates and drew everything else, never checking which drawing a position belonged to - the exact failure ADR-017 exists to prevent, in the one component that draws the map. Markers are now filtered to the level being shown, and a position with no level is omitted rather than approximated onto the default.
- The map page had no way to choose a level. It read the current level only to title a PDF export, so a second floor was unreachable from the viewer most people use. A level selector appears when a site has more than one, and switching moves the drawing, the bounds, the coordinate space and the markers together.
- The map editor never accepted a click. Its click handler was bound only if the map was already in picker mode when it mounted, and the editor opens with nothing selected, so selecting an asset and clicking did nothing at all for the life of the page. The asset forms were unaffected, because their picker is mounted inside a dialog that is already in picker mode.
- Existing markers are now drawn while placing one. Positioning an asset relative to the machines already on the floor is the whole task.
- Searching the map follows results across levels: a search whose matches are all on another floor showed an empty map while the filter counted them.
- A level's native size could not be set. The settings page had a width field and no height field at all, and locked both once any marker was placed - which blocked the case the feature was built for, a new blueprint of new dimensions on a floor that already has markers. Both fields are editable, with a confirmation and a pointer to landmark recalibration.
- The same search could return different results. Fourteen of the sixteen
searchers behind global search truncated with
LIMITand noORDER BY, so the database was free to return a different subset of matching rows each time. Every searcher now ends in a total order. - Re-searching a term already in the address bar did nothing: the router treats it as a duplicate navigation and aborts, so the results page never re-queried. This is why the sidebar box and the results-page box appeared to disagree.
- The empty legend no longer draws its bar and border, which read as a stray input box under the map toolbar.
- The map editor's panel header no longer crams a heading and three controls onto one row, which had squeezed the search box until its placeholder read "Search na".
- Both map pages had a scrollbar. They subtracted the wrong amount from the viewport height for the page chrome, so each overflowed by the difference; the padding is now a value the pages and the layout share.
[0.11.1] - 2026-08-17
A patch release: the pagination-cap fixes, plus the documentation that missed the 0.11.0 installer. No application behaviour changed on the server and the plugin contract stays at 0.20.0.
docs/UPGRADE.md is one of the documents the Windows installer puts on the box,
and its buildings-and-levels section landed after 0.11.0 was tagged - so 0.11.0
shipped the page an operator reads WHILE upgrading without the guidance about
that upgrade. An air-gapped server has no other way to reach it.
Fixed
- Pickers and batch label sheets no longer stop at 100 rows.
perpageis clamped toMAX_PAGE_SIZE(100) server-side and the response says nothing about it, so a control asking for 500 or 1000 received the first 100 rows and a success. On a site with 126 applications the 26 sorting last could not be chosen as a knowledge-base topic; batch label printing rendered a sheet for the first 100 of 262 machines that looked complete. NewfetchAllPages()helper pluslistAll()on the applications, models, machines, computers, printers, network, measuring-tools, USB and printed-parts APIs, used by the KB article form and topic filter, the notification form, the report filter builder,AssetLabelBatch,PrinterQRBatch,USBLabelBatch,PrintedPartsLabels,MachineFormandNetworkDeviceForm. Lists that render a page at a time are unchanged - they page deliberately.
Changed
- The installer now also ships
FLOOR-MAP.md, the operator's page for loading floor plans, placing markers and moving them when a plan changes. Same reason the Windows runbooks ship: the box cannot reach the wiki. - The bundled
UPGRADE.mdnow carries the 0.11.0 section: an existing map is preserved and nothing moves on screen, and replacing a blueprint with one of different dimensions moves every marker on that level, so recalibrate from landmarks rather than editing the level's width and height.
[0.11.0] - 2026-08-17
The floor map became a set of drawings instead of one picture. A site can hold more than one building, a building more than one level, and every map position now records which drawing its coordinates belong to. Driven by a real move: a second floor was added, a new blueprint changed size, and machines relocated.
Alongside it, a sweep of what search can actually reach. Two identifiers the UI collects were findable in almost no way, and two plugins' records were findable in no way at all.
Added
- Buildings and levels (ADR-017). Each level owns its own blueprint per theme
and its own native pixel size;
assets.mapx/mapyare pixels of the level named by the newassets.levelid, not of the site. - A bulk repositioning tool on the map editor: filter by unplaced, by
needs-review, or by level, search, then place markers and confirm them. New
POST /api/mappositions/positionsand/verify. - Landmark recalibration (
POST /api/mappositions/transform). Name two or more points that appear on both the old and new drawing and every marker on the level moves onto the new one. Defaults to a dry run that reports each old and new position and anything that would land off the drawing. The transform is solved PER AXIS from the landmarks, never derived from image dimensions: a taller drawing that gained a level below did not rescale, and a dimension-derived scale would stretch Y by 1.57 and be wrong everywhere. - Position snapshots with restore, taken before any bulk change including a restore, so an undo is itself undoable.
- A buildings and levels admin under Settings: name and order levels, see each level's id and marker count, upload a blueprint per theme, choose the default.
assets.mapverifiedat, the record of when a position was last confirmed against the current drawing. A bulk transform clears it, because a transform is a starting guess and nothing in the coordinates says which markers moved.
Changed
- Plugin contract to 0.20.0 (additive; see CONTRACT-STABILITY.md). A plugin that writes a map position must now write its level.
- A position whose level is unknown renders as "level unknown" and is NOT drawn on the default level. Drawing it there would look entirely correct while pointing at the wrong part of the building.
GET /api/printers/install-list?format=textgainedlevelidas a NINTH field, appended. Fields 0-7 are unchanged because the shipped Pascal installer reads them by index.- Knowledge base: an article whose topic is a retired (
isactive = 0) application no longer appears in listings, searches or the counts, and the topic picker offers every active application rather than only installable ones.
Migration
7d33_buildings_and_levelsconverts an existing single-map site: it creates one building and one default level carrying the blueprint paths and dimensions from the oldmap_*settings, then assigns every already-placed asset and location to it. Nothing moves on screen. The old settings rows are left in place so a rollback still finds them. Verified end to end on MySQL 5.6 from a production-shaped database.
Fixed
gaugelabreferencewas matched only on measuring tools andmaintenancereferencewas matched nowhere at all, for any asset type, while Settings offers both identifiers on machines, PCs, printers and network devices. Both are now matched by global search AND by every asset list's?search=. An identifier that can be entered has to be findable, or it is a write-only field, and the tag is read off the physical machine by someone looking at the list page.- USB devices and printed items were unreachable from global search entirely.
Neither is an asset, so the generic asset search could not see them and no
searcher existed. They now match on serial, asset tag and label, and on bin
code (
itemcode), gage-lab tag, name and description respectively - both honouringisactive, both with Settings toggles, result labels and filter chips. - Global search returned knowledge-base articles whose topic application is
retired, while
GET /api/knowledgebasehid them. A filter that only some paths apply is not a filter: the article stayed two keystrokes away and the result printed the retired application as its subject.
Known gaps
- The Pascal printer-installer map still compiles in one drawing's dimensions and bundles one blueprint, so it is accurate for the default level only. See section 6 of PRINTER-INSTALLER.md.
- The setup wizard still asks for a single site-wide blueprint and writes settings that no longer drive the map.
- Map PDF export covers the current level only.
[0.10.0] - 2026-08-17
A security release. The Windows installer left the directories it creates writable by every logged-on user, which made two of the files it puts there a local privilege escalation; that is fixed and verified on Windows. Alongside it, the work that makes this product genuinely adoptable by a second site: one site's hostnames, share paths and networks are out of the product code and out of the published documentation, and the rule that keeps them out now fails the build rather than printing a warning nobody read.
The plugin contract moved to 0.19.0, which is a BREAKING change - see the Changed section and CONTRACT-STABILITY.md before upgrading a plugin you wrote.
Added
-
Applications accept an uploaded image and an uploaded installer, rather than only a path typed into a text box. The path field kept working only while someone maintained a share by hand, and a link to a file nobody could still find was indistinguishable from a link to one that worked. The installer download is gated on
applications.viewbecause it is licensed vendor software; the image is public, since tiles render before login. -
Warranty records accept an uploaded proof document - the invoice or certificate that actually settles a claim. The documentation path stays as it was: it points at a vendor portal or a share, which an upload cannot replace. Downloads require
warranty.view, since a proof carries pricing and a service tag, and it comes back under the vendor's own filename so it is recognisable in a support ticket. -
Toner forecast report: days until each cartridge runs out, soonest first, from Zabbix level history. Ordering by days left rather than by level is the point - a cartridge at 60% draining fast needs ordering before one sitting at 8% that has not moved in a month. It also counts how many cartridges each printer has been through in the window, which is the number a purchasing conversation asks for. Printers with no honest estimate are listed separately with the reason (too little history, gauge has not moved, replaced recently) instead of being sorted in as zero or as safe, and an unreachable Zabbix says so rather than rendering an empty table that reads as "nothing is due".
-
Fiscal week under the site name in the sidebar and on the shop-floor board, as the classic ASP site showed it. People quote a week number across both sites, so the arithmetic is a port of the old one (ISO 8601: week 1 contains 4 January, the week's Thursday decides the year) rather than a fresh interpretation.
-
Backup history distinguishes CAPTURED from LAST VERIFIED. Captured is when a configuration was written; verified is when a PC last posted it and the hash still matched. They differ by design, because an unchanged config writes no new revision - so a machine checked daily for a year shows one revision from last spring, and previously read as abandoned. Shown on the history page and on the asset panel; only the current revision carries it, since an older one is not what the PC holds now.
-
A backup now records that it was CHECKED, not only that it changed. Dedup means an unchanged configuration writes no revision, so the stored timestamps moved only on a change: a machine whose settings had been stable for six months was indistinguishable from a machine whose backup died six months ago. The only evidence a backup still ran was a line in a log file on the PC.
lastseenatis touched on every matching post, including the no-op, and is backfilled from the timestamps that already exist. -
The low-supplies report lists only the cartridges that need replacing. A printer with one empty black and three full colour cartridges was listing all four, so the reader had to hunt for the problem inside the row. The report exists to answer "what needs replacing".
-
Dashboard cards for enforcement failures, machine numbers claimed by two PCs, PCs not reporting, backups that have stopped, printer supplies, expiring warranties, machines out of service, and the notifications currently showing on the floor. The dashboard now renders cards plugins declare, which it never did before - five plugins had been declaring widgets into a void, pointing at components nobody wrote. Cards are ordered by severity, hide themselves when there is nothing to report, gate on a permission, and each fetches independently so one broken endpoint cannot blank the board.
-
Tech Tools: a section for the small conversions and code generation that otherwise happen in a browser tab on somebody's phone. Starts with barcode and QR codes laid out for the label stock actually in the printers.
-
Kiosks report what they are. The dispatcher already read
C:\Enrollment\display-type.txtto pick its target; now it tells ShopDB, so the Asset column on an enforcement report shows a reported value instead of one inferred from a mapping table nobody filled in. -
Enforcement reports carry what the PC is, where it is, and whether it is backed up: the host links to its own page, the Asset column names the machine or instrument the PC controls, a map pin shows the location on hover, and the backup column judges the chain rather than printing its last date.
-
flask relationships audit, read-only, listing links that cannot both be true, plus an inverse guard that refuses to create one. -
An employees endpoint that says WHY a name did not resolve, rather than returning nothing and leaving the caller to guess.
-
The display client module updates itself through the manifest, so a client change no longer means hands on every kiosk.
-
docs/START-HERE.mdroutes a reader by what they came to do, and is now the wiki's landing page - which previously recommended the one install document the docs themselves forbid for a new site.docs/FLEET-ARCHITECTURE.mdis the page nothing else could assume: how the server, GE-Enforce, the asset reporter, the backup collectors and EventSaver relate, where credentials live at both ends, and which of the five to open for a given symptom. -
docs/ADOPTING-AT-ANOTHER-SITE.md: how another site repoints the asset reporter and the EventSaver screensaver at its own ShopDB, with worked examples for Intune (including Machine Configuration/DSC), a GE-Enforce manifest entry, and manual installation. Both tools were already built to be repointed - the URL, the key and the targeting are inputs - but nothing said so, and the two things that catch people out are written down: EventSaver's compiled-in fallback path is not neutral, and a config enforced by hash reverts a hand edit by design. -
docs/PROJECT-MAP.md, generated byscripts/gen_project_map.py: versions, the plugin inventory, every Alembic chain head, the ADR index and the size of the codebase, derived from the code. The hand-written equivalents had drifted by two contract releases and 400 tests.
Changed
- Plugin contract 0.19.0:
get_dashboard_widgetsreturns data and shape, not a component name. The old shape named a Vue component per widget, which cannot survive a lean build (ADR-013) and had five plugins pointing at components nobody wrote. The change itself shipped earlier without a bump and withBasePluginand PLUGIN-HOOKS.md still teaching the dead shape; this records it and corrects both. - Plugin contract 0.17.0:
DashboardDefaultis part of theshopdb.apisurface, so a plugin can resolve a display without reaching into core. - Plugin contract 0.18.0: one name per display role, and it is the kiosk's
own (
Dashboard,Lobby,3DPrintRoom). Server and dispatcher had three vocabularies for the same three kiosks, and a display could report a role core could not store.normalize_display_roleresolves case and the retired spelling; an unknown value is kept verbatim so a typo stays visible. Contract versions are their own series (ADR-002) and belong in this file; 0.17.0 and 0.18.0 shipped without an entry, which is what this note corrects. - Publishing a GE-Enforce manifest is refused when PCs reporting for that scope run an older client library than the manifest needs, naming the hosts. A minor version bump that NARROWS behaviour is not backward compatible: an old library reads a gated entry as ungated and installs every version it cannot detect.
- Shadow mode runs inside the enforce cycle instead of on its own schedule. The share is mounted only for the length of a cycle, so a task on its own clock woke to a missing drive and reported 0 installed / 0 skipped / 0 failed, which is indistinguishable from a healthy no-op.
- A GE-Enforce report is judged stale on both clocks, the server's and the client's asserted check-in, so a PC cannot mask real silence with a future timestamp. A PC that has gone quiet stops reading as healthy.
Fixed
-
Documentation a reader could not follow. 98 curl examples single-quoted
$TOKEN, so the shell never expanded it and the server answered 422; a JSON body carried a//comment, which made the server report a missing hostname about a body that has one; the IIS install omittedflask plugin upgrade-all, which is the 1054 error a deploy hits days later; a pilot loop exited 1 on every iteration; and the external-plugin guide pinned a contract version six releases stale, so a plugin built by following it is refused at startup. Nine documents now point at the generated project map rather than typing a version, and a test enforces that. The API inventory gained the 22 routes that were serving traffic undocumented, with a parity test against the live URL map so the next one cannot slip in silently. -
Every plugin migration chain re-ran its head on each MySQL deploy. Core's Alembic env got a
connection.commit(); the per-plugin template did not, so the last migration of every run lost its version stamp while its DDL survived. Invisible for as long as every head happened to be idempotent - and two were not. The backups head cleared a column for every row, and the geenforce head added a column unconditionally and failed on the second attempt. Both are now bounded and guarded, and the template commits. -
A part-marker PC reported 200 on the cycle that created its marker and 500 on every cycle after. Both of the links it makes were looked up by label, so a row made by hand or by the legacy import was invisible and the insert violated the uniqueness of (source, target, type); and the marker's asset number is derived from the PC, so it could already be taken.
-
A CMM reported the same way: the instrument IS the reported bay, so adoption was relabelling the row the machine sync owns and finding it by that label.
-
A reported identifier is matched exactly rather than with
ilike, so_and%in a file on a shopfloor PC are no longer wildcards.MT-600_had been adopting MT-6001, and a bare%adopted whatever active asset came first. -
Measuring tools are adopted before they are minted. Minting derived the asset number from the HOSTNAME, so a permanent instrument took the identity of whichever PC drove it that week, and the collector could not see a tool it had not created - which left legacy instruments shadowed by minted twins.
-
An unanchored rsync exclude in the export was matching at any depth, so a whole plugin was silently dropped from the published payload. Root excludes are anchored and the export now aborts if a plugin manifest fails to survive.
-
Backup revisions are ordered in Python, not with
ORDER BY ... NULLS LAST, which SQLite accepts and MySQL rejects.
Security
- The Windows installer breaks ACL inheritance on the roots it creates. A
directory made directly under
C:\inherits an inherit-only Modify grant for Authenticated Users, soC:\shopdb-flask,C:\Python314andC:\MySQL84were writable by any logged-on user. Two files inside them make that a local privilege escalation:shopdb-admin.ps1, which self-elevates, andinstance/config.py, which the app executes unconditionally. The MySQL data directory is locked down as well, since it holds the password hashes. A Python installation this installer did not create is reported rather than seized. - Site-specific values no longer ship in product code (ADR-015). The backups
share root defaults to blank instead of one site's file server, the shadow
client no longer overwrites a site's own ShopDB URL in the registry on every
cycle, the kiosk dispatcher takes its URL from the
site_base_urlsetting and refuses rather than guessing, and one site's hostname, FQDN and internal subnets are out of the documentation, the generated API specs and the UI placeholders. The ADR-015 scanner now covers PowerShell, the installer, the seeds and generated JSON, case-insensitively, and FAILS the build; deliberate organisation-wide defaults are declared with anADR-015-OKmarker. Both publication gates carry the site patterns.
[0.9.0] - 2026-08-11
Driven by a fleet that had been failing quietly. A bay had been returning 500 to every collector report since it was imaged, backups were accumulating duplicate revisions on any machine number more than one PC reported against, and a setting that looked configurable had never been readable by the script that consumed it. The theme of the release is making those failures visible: each fix ships with the check, report or document that would have surfaced it.
Changed
- The toner forecast is read as an order, not as a table. It opens with what to buy - grouped by part number, with a quantity - because two cartridges of the same part in different printers is a quantity of two, and that was a number the reader had to work out by hand. Below it, cartridges sit in urgency bands (empty / two weeks / thirty days / later) rather than in one long sortable list, since the question being asked is which pile a thing is in. A row is now a cartridge rather than a printer, carries its own part number and a level bar, and everything past "empty" starts collapsed. Cartridges with no part mapped are counted on one line instead of a line each: they still have to be ordered, but not from this page - the job is to map them.
Fixed
- The toner forecast was reading the wrong end of the window. Zabbix applies a
query's
limitto the whole answer rather than to each item, and combined with an ascending sort that kept the OLDEST rows: a four-cartridge printer polled every five minutes writes over 100k readings in 90 days, so a "90 day" forecast was fitted to the first few days of the window and nothing since. That is where "4 days left" beside a cartridge at 20% came from - the rate was real, it just described a cartridge thrown away in May. Long windows now read hourly trends, which is the table meant for this and is a tenth of the rows; short ones read raw history, newest first, with a budget scaled per item. - The forecast now counts down from the level it displays. It showed the live reading but computed days-left from the last stored one, so the two could disagree by a whole cartridge. A live level far above the stored run is treated as a swap that happened since the last reading rather than as a collapse in the burn rate.
- A cartridge at or below 5% reads as empty rather than as a slow drain. At 1% losing a tenth of a point a day the arithmetic said ten days; the printer is out of toner, and it is the first thing that should be ordered.
- Days-left in the forecast is per cartridge again. It was rendered in a cell spanning the printer's rows, which put the printer's soonest figure beside every supply it had - a cartridge at 20% displaying "4 days" that belonged to the black beside it, and a cartridge at 1% displaying weeks that belonged to nothing on that row at all.
- Supplies typed as floats were dropped from the forecast on any printer that also had an integer-typed one. The two live in different Zabbix history tables and the fetch stopped at whichever answered first, so half a printer's cartridges silently had no history at all.
- A shop-floor PC that reported the machine number of a machine ShopDB already
knew got a 500 from the collector, on every report, forever. The reported
machine number was being written to the PC's own
assets.assetnumber, which is uniquely indexed and already held by the machine, so the insert failed with a duplicate-key error and the whole report was lost: operating system, boot time, applications, printers and access protocols never landed. The machine number now identifies the machine only. A new PC takes its hostname as its asset number, which is what the existing data already does, and an existing PC's asset number is never overwritten. - Configuration backups piled up duplicate revisions on any machine number reported by more than one PC. A posted config was compared against the latest revision for the machine, so where several PCs share a number - the part markers do, and their configs differ by COM port - each PC's post differed from whichever PC posted last, nothing deduped, and the table grew by a row per PC per collection cycle. A backup chain is now per source PC, so an unchanged config is a no-op again and each PC keeps its own history against the machine it belongs to. Retention prunes each chain separately, so a busy PC's revisions can no longer push out a quiet PC's only backup, and the revision diff compares against the same PC's previous revision rather than another PC's.
- The backup collection interval could not actually be changed. The collecting
script reads
backups_intervalhoursfrom the public settings endpoint before it holds any credential, but the plugin did not declare the key as public, so the endpoint never returned it and the script fell back to its built-in 24 hours - silently. The setting looked configurable in the UI and was not. - A backup export containing a value with an empty right-hand side (
Name=) failed to parse, and with it the whole file, so that machine could never be backed up. The form is not strictly legal but occurs in real exports. It is now read as an empty string, preserving the value name. - The 3D parts kiosk label prefix never appeared on the kiosk. The kiosk runs logged out, and an unauthenticated read of a setting is limited to an allowlist the key was not on, so the kiosk got a 404 and fell back to no prefix. An admin previewing the same page while logged in saw it, which is why it looked like it worked. A plugin now declares which of its settings a logged-out page may read.
- The same setting also looked like it would not save. The row did not exist on a site that installed the plugin before the setting was added, so the first save created it - under a placeholder category, where the settings page, which lists by category, could no longer see it. The value was in the database the whole time. A first-time save now files the key under the category its owner declares. Any row already misfiled is repaired in place on upgrade, value untouched. The 3D parts alert email and the employee directory host were in that state.
- Settings added in a later plugin version never reached a site that installed
an earlier one. The seeding ran from
on_install/on_enable, which fire only on a state transition, so neither ran again on an upgrade - and the comment claiming enable ran every upgrade cycle was wrong.flask plugin upgrade-allnow seeds missing declared settings, without touching values a site has configured.
Added
-
A PC reporting a machine number is now linked to that machine. Until now the number was collected and then discarded, so a replaced PC never took over its bay: the retired PC kept the link and everything that walks PC to machine, the warranty machine column and the DNC info card among them, pointed at hardware that had been pulled out. The link is a
controlsrelationship carrying acollector:machineorigin label, so a link made by hand is never touched. Superseding a PC archives the old link rather than deleting it, keeping the history of which PC ran a machine and when. The retired PC's status is deliberately left alone: the collector cannot tell whether it was shelved, sent for repair or re-imaged for another bay, so a person decides.A second PC reporting a machine another PC already holds is treated as a claim, not a handover. A PC imaged for a machine carries that number before it ever reaches the floor, so the PC actually running the machine keeps the link while it is still reporting and still In Use, and the challenger is recorded as a dormant link. The handover completes on its own once the old PC has been quiet for a day, or immediately when someone moves it off In Use. Without this the two PCs traded the link back and forth on every collector cycle.
Alerts for both cases go out by email and webhook, gated on a new
computers_machinelink_alertssetting that ships OFF: a site may legitimately run several PCs on one machine number, and there the alerts fire on correct data. The collector response warns either way. -
A PC that drives a subordinate device is now declared, not coded. Two cases had arrived separately and been written twice: a metrology PC drives a CMM or Keyence unit that becomes a measuring-tool asset, and a part-marker PC drives a marker that becomes a machine asset filed under its operation. Both mint a device, link the PC with
controlsand archive that link on a re-image - one mechanism with different nouns. They are now one map, and a site can add or retarget an entry through asubordinatedevice_<pctype>setting, so the next case - two marking lasers or two wax-trace units on one machine number - is configuration rather than a code change. A malformed override falls back to the default instead of failing the collector push. -
flask relationships check-shared-machinesreports machine numbers claimed by more than one PC, and says which are modelled and which are faults. Several devices genuinely sharing a number and two PCs mis-numbered at imaging look identical from the outside; the difference is whether anything is filed under the operation, which is what the check reports. Read-only. -
A part marker is its own asset. Several Telesis markers can serve one operation number, so treating the operation as the marker collapsed separate devices into one record: their configurations, which differ by COM port, overwrote each other in the backup history, and no question about an individual marker - how many there are, which port one is on, which one failed - could be asked at all. There is one marker per PC, so the collector now mints a Part Marker machine asset for a marker PC the same way it already mints a CMM or a Keyence unit for a metrology PC: the PC
controlsthe marker, and the marker ispartofthe operation it serves. Becausecontrolspropagates throughpartof, control of the operation still follows from controlling its marker, and two markers on one operation no longer contest a link only one of them can hold. Backups from a marker PC file against the marker. Moving a marker to another operation archives the old membership rather than deleting it. -
Plugin contract 0.16.0:
get_settings_defaults()lets a plugin declare the settings it owns (key, default, type, category, description, and whether an unauthenticated caller may read it). Seedocs/PLUGIN-HOOKS.md.
[0.8.1] - 2026-08-05
Everything here shipped after v0.8.0 was tagged the same morning, driven by two sites putting real data in for the first time. Most of it is defects that only appear when somebody who did not build the software tries to use it.
Fixed
- A blank optional code could be saved once and never again. A unique nullable column accepts any number of NULLs but exactly one empty string, so the second business unit with no code collided with the first and returned 500 from a field the form correctly showed as optional. Blank now stores as NULL, which covers every unique nullable column - asset numbers, hostnames, item codes, subnet names, gage-lab tags - not just the one that was reported. A genuine duplicate answers 409 with a readable message instead of a bare 500.
- A lobby display with exactly two slides never changed between them. The slideshow was started twice, and two timer chains advancing one slide each landed back on the first every cycle. With three or more slides it advanced by two and merely skipped one, which is why it went unnoticed. The per-slide duration the feed has always sent was also ignored in favour of a hardcoded ten seconds.
- Clicking slightly outside a modal discarded a part-filled form, in 35 modals across 30 files. Confirmation dialogs still dismiss that way, since they hold nothing to lose.
- Selecting a filter while past page one returned an empty list, because the filter asked for page five of a result set that now had one page.
- Model photos could not be saved: an uploaded photo sets the field to an application path, and the input was type="url", which demands an absolute address. The upload button was also hidden when creating a model.
- An empty bordered box appeared after Notes on the network device form.
Added
- The equipment catalog travels.
flask seed catalogloads 53 vendors, 128 models with photos, 146 printer supply part numbers and every type vocabulary, so a new site can start adding printers immediately instead of retyping a catalog another site spent a year building. Idempotent and additive; catalog only, with nothing site-specific. Offered by the installer as a tick-box and by the operator console. shopdb-admin.ps1 repaircompletes an interrupted provisioning, andchecknow says when a server is not fully provisioned instead of leaving it to be inferred from 500s on unrelated pages.- Network devices can be linked to a catalog model, and their map position is picked on the floor plan rather than typed as coordinates.
- An asset's vendor and type can be derived from its catalog model, by exact name match only, with a reviewable backfill script.
Changed
- Labels say whose type they mean: Machine Type, PC Type, Printer Type, Device Type, next to the catalog's Model type.
- The 3D parts kiosk label prefix is a setting rather than one site's initials hardcoded in the source.
- The operator console menu is grouped by what each action touches.
[0.8.0] - 2026-08-05
First release to carry the Windows installer. Everything below shipped after v0.7.0 was tagged, and a complete install was exercised end to end on Windows Server 2019 before this release was cut.
Fixed - installer, from a real Server 2019 install
packagingwas imported by the plugin loader but declared nowhere. It reached development and CI only as a dependency of pytest, so the whole suite passed while a virtual environment built fromrequirements.txtalone - which is exactly what the installer builds - could not import the application at all.tests/test_runtime_dependencies.pynow fails on any runtime import that is not a declared dependency.- IIS returned 500.52 before the application was ever launched.
web.configdeclared<allowedServerVariables>for the X-Forwarded-For rule, and that section shipsoverrideModeDefault="Deny", so the whole file was rejected. The installer now permits the single variable at server level instead of unlocking the section for every site on the machine, and repairs aweb.configan earlier build had made unusable. - The config unlock ran before the application it unlocks existed, so the scoped form could never succeed on a first install and every install silently fell back to granting handler delegation server-wide.
- The stage 5 smoke test discarded the status code and error page it had already received, reporting "site did not return 200" for a fault IIS had named. It now records both, plus the tail of the application log.
- Database dumps were readable by every authenticated user: a directory created
under ProgramData inherits
Users:RX, and the owner-only ACL was applied only when the installer itself created it. - The uninstaller ran the 32-bit PowerShell, which cannot see IIS, so the site, pool and application survived a "successful" uninstall pointing at a deleted directory.
- Uninstall matched applications by alias alone and would remove an unrelated application of the same name under another site.
- Wizard input reached a command line unchecked: unvalidated ports, a drive-root path that escaped its own quote, and a password written as ANSI but read back as UTF-8, which reported a correct non-ASCII password as wrong.
- Plugin deregistration could never succeed - it omitted
--yesagainst a command that prompts - while the plugin directory was deleted regardless. - Preflight rows were drawn past the bottom of the panel and silently vanished; the failures loop had no cap at all.
Changed - installer behaviour
- "Is this a re-run of my install?" is answered from a durable install record rather than inferred from the state of the machine. Nothing on a server says who created its database tables, so a retry after a failed first install was taken for an upgrade of a working system: it demanded a mandatory backup of a database its own failed attempt had written, then refused to prune tables it had created minutes earlier. During unfinished first provisioning the backup is advisory and prune may force; on an established install both are unchanged.
- The installer offers every bundled plugin, so one build serves any site instead of one build per plugin profile.
Added - operator documentation and diagnostics
docs/UPDATES-WINDOWS.md- what operators should expect from future updates, bug fixes and security releases, including downtime, what is preserved, and the effect on other sites sharing the same IIS server.docs/RELEASING-WINDOWS.md- how to build and release, and the two known gaps.deploy/windows/shopdb-diagnose.py- collects what IIS answers, the config lock state, the application logs and the ACLs in one pass, scrubbing secrets before writing anything.
Added - the installer itself
- Air-gapped Windows installer (
deploy/windows/installer/). One.exeper site, built from that site's plugin profile, containing Python, the wheels, the SPA, the IIS modules and optionally MySQL. Operator docs:docs/INSTALL-WINDOWS.mdanddocs/OPERATE-WINDOWS.md, both shipped onto the server.docs/INSTALL-WINDOWS-IIS.mdanddocs/DEPLOY-WINDOWS-IIS.mdare now reference-only, for hand-built servers. bundle-lock.json: an exact sha256 + size record of the installer's third-party payload (wheels, Python installer, IIS MSIs). Verified as set equality by both builders and again on the server before anything runs; there is no install-time override. Regenerate withrefresh-bundle-lock.ps1.- CycloneDX SBOM (
sbom.cdx.json) generated on every build fromrequirements.txtandpackage-lock.json, covering both ecosystems, staged into the application tree so an air-gapped server can answer "do we carry this component" locally:shopdb-admin.ps1 verify -Path <name>. shopdb-admin.ps1, the operator console: status, start/stop/restart, logs, health check, backup, plugins, verify.check -Jsonemits secret-free structured state for pasting into a support ticket or an AI assistant.build-installer.ps1, the whole build natively on Windows, so a work PC needs no Bash. Sharesscripts/resolve_plugin_closure.pywithbuild-site.sh.- URL Rewrite is bundled and the wizard asks where client IPs come from
(
-ClientIpSource direct|proxy). Without the rule IIS sends noX-Forwarded-Forat all and every client reads as 127.0.0.1.
Changed
requirements.txtandrequirements-dev.txtare compiled--universal --generate-hashes. Installs run underpip --require-hashes, so a wheel whose sha256 is not listed is refused. The dev lockfile is constrained to the production pins; the two had drifted.- Python 3.14 across the Dockerfile, CI,
web.configand the docs, which previously declared four different versions. - The naming/style gate covers Markdown, JSON and YAML, not just code.
Fixed
plugins/employeesimportedshopdb.core.modelsdirectly, failing the contract-surface test onmainsince the dashboard employee-name resolver landed.scripts/build-site.shcopied all ofdeploy/into its own output directory, which recursed when the output was staged inside it - the documented Windows build could not complete.- Plugin baseline migrations inherited the MySQL server's default charset: the
utf8mb4 compiler hook lived in
migrations/env.pyand so covered the core chain only. It is nowshopdb/utils/mysql_charset.py, imported by both.flask db-utils preflightreports the database's default charset.
Added
- Configurable site timezone: a
site_timezonesite setting (defaultAmerica/New_York, public-readable), editable in Settings > Site > Localization. Notification start/end times are entered and displayed in this zone, and daily-reset notification expiry is computed in it. A sharedfrontend/src/utils/datetime.js(Intl-based, DST-safe) does the conversion.
Changed
- Asset detail pages (machines, PCs, printers, network devices, measuring tools) now share one canonical card skeleton: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer. The location card reads "Location & Organization" on every page. The network device page was rebuilt into the family (its "Asset Information" folded into Identity, "Record Info" converted to the standard audit footer). Printer Notes moved out of mid-page to second-to-last and the printer gained an audit footer. Template reordering only; no data or API changes.
Fixed
- Notification start/end times were off by the timezone offset (a 2:34 PM entry displayed as 6:34 PM). Times are now stored UTC and shown/entered in the site timezone; the calendar keys all-day events off the site-local day.
- Kiosk displays showed a white screen on login: a legacy 32-bit kiosk
installer's autostart kept relaunching Edge at a now-dead URL. The install's
HKLM Run value was WOW64-redirected into
SOFTWARE\Wow6432Node\...\Runand survived earlier cleanup. Thegea-shopfloor-displaydispatcher now purges the legacy autostarts every enforce cycle across both registry views, all user hives, Run/RunOnce/policy-Run, and every Startup folder. - List pages keep the current page (and search term) in the URL query, so paging to page 9, opening an item, and hitting browser Back returns to page 9 instead of resetting to page 1. Applies to all 18 list views via a shared useListQuery composable; page 1 with no search stays a bare path.
- PC detail Installed Applications no longer 500s and silently disappears on real PCs (ComputerInstalledApp had no to_dict); the section renders app name, version, and description again.
- Employee detail skips its USB panels when the usb plugin is disabled (no more 404 console noise).
- Shopfloor kiosk header text is readable (light on the dark navy header).
Added (continued)
- Collector-driven PC -> printer relationships. The computers collector schema
gained optional
defaultprinter(string) andprinters(array of strings) fields carrying Win32_Printer identifiers. On ingest each identifier resolves to a printer asset (by windows name / share / hostname / asset number-name or a communications IP) and the PC is linked to it: the default via adefaultprinterrelationship, the rest viaconnectedto. The links render in the shared Relationships card on both the PC and printer detail pages. The sync is idempotent and archives collector-created links to printers no longer reported (taggedassetrelationships.label = 'collector:printers', so manually-created links are never touched); unresolved identifiers become response warnings, never failures. The collector response carriesprinterlinkcountand aprinterlinkslist. See docs/COLLECTOR-INTEGRATION.md. - Searchable custom fields. Each custom-field definition gained a
searchableflag (Settings > Custom Fields). When on, that field's stored values are matched by global search and a hit routes to the owning asset's detail page. The asset'ssearch_<type>_enableddomain toggle still applies, and matches dedupe against built-in-field asset hits so an asset appears once. Inactive or non-searchable fields are never matched. - Single-label sheet-position printing. The single asset-label page
(
/print/asset-label/:assettype/:id) gained an Output control that toggles between the standalone label (unchanged default) and placing that one label at a chosen cell (1-6, via a 2x3 grid picker) of a ULINE 6-up sheet, leaving the other five cells blank. This prints a single label onto the correct physical spot of a partially-used sheet instead of wasting a fresh sheet, mirroring the legacy shopdb behavior and complementing the batch page's start-cell offset. The ULINE 6-up cell layout and dimensions are replicated fromAssetLabelBatch.vue(left untouched); encode resolution stays shared viaassetLabel.js.
[0.7.0] - 2026-07-12
Added
-
Email sending. A stdlib-only mail service (
shopdb/utils/mailer.py;smtplib/ssl/email) reads the existingemailSMTP settings settings-first with anSMTP_*env fallback, sends multipart HTML+text, and is a graceful no-op (logs a warning, returns False) when email is disabled or the host is unset. The SMTP password is never logged. Three flows use it: (1) New-user welcome + forced first-login password change. Admin-created users (POST/api/users) are flaggedmustchangepassword(newuserscolumn, migration7d23_user_mustchangepassword, default false) and sent a best-effort welcome email with the facility name, username, temporary password, and sign-in link; the account is created even if mail fails (response carries awarning). Login returnsmustchangepassword; the frontend forces the user through a new/change-passwordview (POST/api/auth/change-password, jwt-guarded) before the app, and changing the password clears the flag and resets lockout counters. A self-service "Change password" entry is also available from the user menu. (2) Test email. POST/api/settings/test-email(settings.edit) sends a probe and surfaces any SMTP error with the password scrubbed; wires up the Email / SMTP settings page "Send Test Email" button. (3) On-demand alert/report delivery. POST/api/reports/email(reports.export) mails{subject, columns, rows}as an HTML table to a supplied recipient or the sitealert_recipients; an "Email report" button on the Warranty and Toner report pages posts the loaded rows. There is no scheduler: automation is an external cron hitting the endpoint with a scoped API token (PAT). Documented indocs/CONFIG.md. -
Shared asset label/code generator: a single
/print/asset-label/<assettype>/<id>page (public, like the other/print/*routes) that any asset detail page opens via a "Print Label" button (machines, computers, printers, network devices, measuring tools). A no-print controls panel toggles the layout (cardbadge vsplaincode-only), the code type (QR vs CODE128 barcode), and what the code encodes: the asset page link, asset number, serial number, a per-type custom target template, or - for measuring tools by default - the tool's inspection location code so every tool at one operation shares one code (e.g.0615). QR codes reuse the shared logo-overlay renderer. Newprintingsettings seed and surface on the Printing & Labels settings page:qr_target_machine,qr_target_computer,qr_target_network_device,qr_target_measuring_tool;label_default_style(defaultcard) andlabel_default_codetype(defaultqr); and a per-asset-type default for what the code encodes,label_default_encodes_<type>(machines default to their machine number, measuring tools to their inspection location code, the rest to a page link), all overridable on the label page itself. When the chosen field has no value (e.g. serial number on an asset with none), the label states so instead of rendering an empty code. Asset payloads now carry a derivedlocationcode(leading token of the resolved own/inherited location name). -
Batch label sheets: a "Print Labels" button on each asset list page opens
/print/asset-label-batch/<assettype>, a multi-select sheet that lays the chosen assets onto ULINE label pages (6-up 3 in x 3 in, or a dense 72-up mini-label format), with a start-cell offset to reuse partial sheets. Shares the same code-type and encode settings/defaults as the single label, so a batch of measuring tools encodes each tool's inspection location code just like the single label does. Restores the ULINE batch printing the legacy shopdb had, generalized across all asset types. -
Support-team contact UX: the settings Support Teams page now manages each team's contacts in a per-team "Contacts (N)" modal (name, SSO, active, plus Add/Edit/Delete) instead of an inline row expander, and the application detail Support card renders Email (
mailto:) and Microsoft Teams chat (teams.microsoft.com/l/chat) action buttons for every contact that has an SSO. Both link targets derive assso@<domain>from a newsitesettingcontact_email_domain(defaultgeaerospace.com; blank hides the buttons), surfaced in Site & Facility settings under Naming & Patterns and read on the frontend viagetContactEmailDomain(). -
Plugin
get_permissionshook (contract 0.10.0) so a plugin declares the RBAC permissions its own routes enforce, instead of core accumulating every plugin's permissions inPermission.PERMISSIONS(plugin-is-the-product). The core catalog (Permission.CORE_PERMISSIONS) now holds only genuinely core sets (assets, applications, reports, settings, users, audit, apitokens, collector); the 36 permissions for machines, computers, printers, network, knowledgebase, notifications, usb, warranty, and measuringtools moved into each owning plugin's hook. New core helperfull_permission_catalog()merges core plus every ENABLED plugin's permissions and backs all three consumers:flask seed permissions, the role grid (GET /api/users/permissions), and API-token scope validation (ApiToken.unknown_scope_names). Plugin install and enable seed the plugin's own permissions idempotently. A disabled plugin drops out of the catalog (no new scope grants or role assignments), but its existingPermissionrows and role links persist so current roles keep working. Docs:docs/PLUGIN-HOOKS.mdnew section,docs/PLUGIN-GUIDE.mdpermissions walkthrough rewritten to the hook,docs/PLUGIN-QUICKSTART.mdhooks table row. -
Personal API tokens (PATs) so scripts and integrations authenticate without the hourly-expiring login JWT (immediate consumer: long legacy-import runs that die when the JWT expires mid-run). New core
apitokenstable + migration7d21_apitokens(stores only the sha256 hash of each secret; the full secretshopdb_pat_<40 hex>is shown ONCE at creation). New core blueprint/api/apitokens(list own / admin?all=true; create; rename or deactivate; revoke). ABearer shopdb_pat_...header is recognized before any JWT decode by a before_request shim that mints a request-scoped JWT for the token's owner, so the entire existing auth+authz stack (jwt_required, require_permission, require_role, import mode, current_user) authenticates the PAT as its owner with zero decorator changes; an invalid, revoked, or expired PAT gets a clean 401.lastusedatis stamped on use (throttled to at most one write per 60s). Any authenticated user manages their own tokens; admins may list or revoke anyone's. New Settings > API Tokens page (ApiTokensList.vue) with a create modal that reveals the secret once (copy button) and an admin All Tokens section. Docs:docs/IMPORT-API.mdanddocs/CONFIG.mdupdated to recommend a PAT for imports. Core feature; no plugin-contract change. -
Optional permission scopes on personal API tokens. A token MAY carry a scopes list (permission names, migration
7d22_apitokens_scopesadds the nullableapitokens.scopesJSON column); NULL keeps the original behavior (acts as its owner). A scoped token grants ONLY the listed permissions, intersected with what the owner actually holds at use time, and SUSPENDS the admin-role bypass, so a scoped token minted by an admin is genuinely limited: it is denied on role-gated (require_role) endpoints and gets no import mode. The shim mints the request JWT with apatscopesclaim thatrequire_permission,require_role, andimport_mode_activeread; normal login JWTs carry no such claim and are unaffected (zero regression). Scopes are validated at write time against the token OWNER's permissions (the scope ceiling - a token can never grant more than its owner holds; when an admin edits another user's token the ceiling is that owner's permissions), rejecting unknown or unheld names 400. Minting/managing tokens now requires the newapitokens.createpermission (categoryapitokens; admins hold it by default, grantable via the roles UI) rather than being open to any authenticated user. The Settings > API Tokens create/edit modals gain a "Restrict permissions" section (a category-grouped checkbox grid limited to the permissions the creator holds) and the token lists show a full-access / N-permissions access chip. -
Managed collector service tokens: the collector ingest API (
/api/collector/<plugin>+ the legacy/pc/apps/heartbeat/bulk/statusendpoints) now ALSO accepts a managed API token scoped to the newcollector.ingestpermission (categorycollector), on top of the existingCOLLECTOR_API_KEY[_<PLUGIN>]env keys (which stay supported as a bootstrap/legacy fallback - nothing breaks). The token may be presented inX-API-Key(as GE-Enforce sends today) OR as anAuthorization: Bearertoken; both transports validate the PAT the same way the login shim does (hash lookup, active, unexpired, active owner) via a sharedresolve_api_tokenhelper refactored out ofapitoken_auth.py, requirecollector.ingestin the token's scope list AND that the owner holds it, and stamplastusedat(same 60s throttle). A token scoped to ONLYcollector.ingestis a collector service token: it authorizes the collector API and NOTHING else - the existing scoped-token machinery denies it on every permission- and role-gated route and on import mode, so a leaked collector token cannot touch the regular API. Recommended flow (documented): an admin mints the scoped token (the scope suspends the admin bypass, containing it); rotate by minting a new one, deploying viasite-config.json, watchinglastusedat, then revoking the old. The Settings > API Tokens create modal gains a "Collector service token" quick-preset (pre-selects onlycollector.ingest). Docs:docs/COLLECTOR-INTEGRATION.md(new "Managed collector tokens" section) anddocs/CONFIG.md. Core feature; no plugin-contract change. -
Vendor-model photos on asset detail heroes: computers and printers now surface the linked model's
imageurlin their extension payloads (the field machines already exposed), and the machine, PC, printer, network device, and measuring tool detail pages render the photo in the hero card when present (hidden cleanly when absent). Network devices and measuring tools have no model link yet, so their heroes stay photo-less until one is added. -
Dualpath "single machine" site toggle (
dualpath_single_machine, default on). A Dualpath relationship pair is one physical dual-bay machine (single controller, bay-selector switch); when on, the machines list, dashboard and machines-by-type counts, and the floor map collapse each pair to one entry (the lower natural-sort assetnumber is PRIMARY; the SECONDARY bay is hidden) and show a combined2007 / 2008label. The data model is unchanged (both bay records always exist); detail pages stay per-bay and always show a sibling-bay banner regardless of the toggle. Contract surface (plugin contract bumped 0.8.0 -> 0.9.0, additive): newshopdb.apihelpersresolve_dualpath_pairsanddualpath_single_machine_enabled, consumed by the machines plugin to collapse pairs contract-purely. -
Relationship propagation, wired and data-driven: relationship types declare propagation-through pairs (relationshiptypepropagations M:N, replacing the never-consumed single column); creating a controls link on one Dualpath bay auto-creates it on the partner bay, and
flask relationships propagatebackfills existing data. -
Employee photos, mode-aware: self-hosted directory employees support upload/replace/delete (admin), served publicly for kiosk cards; external directory mode passes the HR-supplied picture URL through read-only. One resolver feeds the shopfloor recognition/recert cards and the employee detail hero in either mode.
-
Vendor-model photo management. New admin-gated core endpoints
POST /api/models/<modelid>/image(multipartfile, png/jpg/jpeg/gif/webp/svg, one image per model, replace semantics) andDELETE /api/models/<modelid>/image, plus the publicGET /api/models/image/<filename>serve route. Uploads land ininstance/modelimages/(survives upgrades, backed up with the rest ofinstance/) and setmodels.imageurlto the served URL; the manual Image URL field still accepts external URLs and the shipped/images/models/*assets (upload is additive). Delete only removes files we own under the instance dir. The Models settings page grows a thumbnail, Upload/Replace, and Remove controls in the edit modal. Asset hero images (e.g. the machine badge) readimageurlunchanged, so uploaded photos render with no consumer changes. -
Application support teams with contacts, replacing the legacy supportteams/appowners pair. New core
supportteamcontactstable (multiple named contacts per team, ordered bysortorder);supportteamskeepsteamname(now unique) andteamurl(a ServiceNow group deep link) and sheds the single-ownerappowneridFK. New core blueprint at/api/supportteams(team + nested contact CRUD, admin-gated;?teamnameexact-match lookup for import; delete a team 409s while any application still references it). Migration7d18_supportteamcontactsmigrates each legacy team's app owner into one contact. Application payloads now flattensupportteamname,teamurl, and the team's activecontacts; a Support card on the application detail page and a newsettings/supportteamsmanagement page render them. -
Import mode: a complete, idempotent HTTP migration surface so a migration script can import the classic ASP shopdb through the API alone (no direct DB writes).
- Contract surface (plugin contract bumped 0.7.0 -> 0.8.0, additive): new
shopdb.apihelpersapply_import_timestamps,import_mode_active,parse_import_datetimeinshopdb/utils/import_mode.py. When the caller is an admin AND sends headerX-Import-Mode: true, create/update endpoints accept optionalcreateddate/modifieddate(ISO or legacyYYYY-MM-DD HH:MM:SS, naive-UTC) and preserve them instead of stamping now. Non-admin or missing header: the fields are ignored exactly as before. Wired into every timestamped import target: assets (all five type plugins), vendors, models, modeltypes, businessunits, locations, operating systems, applications, knowledge base, USB devices, and asset relationships. - Natural-key exact-match lookup filters for the documented
lookup-then-upsert idempotency recipe:
assetnumberon all five asset plugin list endpoints;vendor,modelnumber,modeltype,businessunit,locationname,osname/osversion,appname, knowledge baselinkurl/shortdescription, warrantyservicetag/vendor, and notificationticketnumber. - Backdated event history: in import mode the selfhosted USB checkout/checkin
endpoints accept optional
checkouttime/checkintimeoverrides so migratedusbcheckoutsrows keep their real event times. - New operator manual
docs/IMPORT-API.mdgrounded in the realprodscratchlegacy schema: order of operations, a full table-by-table mapping, honest no-target list with dispositions, a worked idempotent Python importer, and row-count parity checks.
- Contract surface (plugin contract bumped 0.7.0 -> 0.8.0, additive): new
Changed
- System Settings is no longer one tabbed page. The inner tab bar is gone and
each section is its own routed settings page reached through the settings
rail: ServiceNow, Zabbix Supplies, Dell Warranty, and Collector PC Types
(the former Integrations dumping ground, now split three-plus ways), plus
Branding, Floor Map, Printing & Labels, Email / SMTP, Authentication,
Audit & Logging, Asset Identifiers, and Global Search. The rail regroups
these under Site & Facility, Integrations, Communication, Search & Identity,
and Access & Security. Shared load/save/upload plumbing moved into a
useSystemSettingscomposable so the pages stay thin. Old bookmarks keep working:/settings/systemand every/settings/system?tab=<key>redirect to the matching new page.
Fixed
- Audit log: hovering a user's SSO now shows their full name (best-effort, resolved from the employee directory in either mode).
- Refreshed the internal status docs to match the code (project active state, CONTRACT-STABILITY.md and README plugin list at contract 0.10.0), corrected the get_asset_panels endpoint path in the hook docstring, and removed leftover debug console.log lines.
- Measuring tools wired into the remaining cross-cutting surfaces an audit found them missing from: generic asset serialization (typedata + pluginid, which also fixes relationship-card links to tools), map subtype filtering/coloring and the MapEditor filter, dashboard totals, warranty asset links (via a new by-asset detail route), and the two ADR-010 hook declarations (presentation route token corrected; the calibration map-overlay endpoint now actually exists). The login avatar also resolves through the employee-photo helper, so self-hosted uploads show.
- Measuring tools are now wired into the asset-identifier matrix and global
search. The Settings identifier matrix gains a Measuring Tool column and the
gauge-lab and maintenance reference inputs/rows on the measuring-tool form
and detail pages honor those per-type toggles (a maintenance-reference field
was added, matching the other asset types). Global search gains a Measuring
Tools domain toggle and filter chip, routes measuring-tool hits to
/measuringtools/<id>(previously the generic/assets/<id>fallback), and matches ongaugelabreferenceso a gage-tag lookup resolves the tool. - Site & Facility settings page renders booleans as toggles and the directory-mode settings as dropdowns, with labels and help text for every field (no more raw keys or type-true/false boxes).
- System Settings tabs follow the URL: clicking a settings-rail link that only changes the ?tab= query (Branding, Floor Map) now switches the right panel, tab clicks update the URL, and browser back/forward restore tabs.
- Following a relationship link between two assets of the same type now loads the destination page instead of stale content (router-view keyed on path; query-only URL changes still avoid a remount).
- Asset relationships card no longer lists a symmetric peer twice. Relationship
types gain
relationshiptypes.isdirectional(migration7d19_relationshiptype_directional; seeded false for the connection-like types Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet, USB, WiFi, true for controls/Controlled By/Backup For/Master-Slave/partof/ defaultprinter). The card now collapses every stored direction row of a symmetric type into one direction-blind "Connected" entry per peer (deleting it removes all collapsed rows), while directional types drop the Outgoing/Incoming headers for inlineType -> peer/<- Type from peerphrasing. The type CRUD and the per-asset relationships endpoint carryisdirectional; the Relationship Types settings page gains a Directional toggle.
[0.6.0] - 2026-07-11
Added
- measuringtools plugin (ADR-005): gage-lab instruments as Asset extensions with type lookup (color-coded), calibration tracking (derived overdue/due-soon/current status), calibration report, and full frontend. Built as the framework exemplar; docs/PLUGIN-GUIDE.md walks through its construction step by step as the plugin-system tutorial.
- CSV export on the Warranty and Toner report pages; per-report filter controls (business unit, asset type, location, application, limit) on the inline core reports; report open-state is URL-backed and deep-linkable.
- Per-plugin Alembic migration chains (ADR-008): every bundled plugin now
carries its own chain with a stamp-only anchor; new plugin schema changes
land in
plugins/<name>/migrations/, never the core chain. Deploys runflask plugin upgrade-allafterflask db upgrade. - Frontend plugin route gating (ADR-009): a disabled backend plugin's pages
redirect to the dashboard; new public
GET /api/plugins/enabled. get_reports()plugin hook (plugin contract 0.5.0 -> 0.6.0): plugins contribute their own report cards; warranty and toner cards moved off the hardcoded frontend list.- Reports hub grouped by category with a search filter.
- Configurable QR label targets:
qr_target_printer/qr_target_usbsettings (blank = the asset's own page, else a URL template with placeholders) and ausb_label_stylebarcode/QR toggle for USB mini-labels. New Settings > Printing & Labels section. - Site palette theming: optional
brand_primary_dark_color,brand_accent_color,brand_sidebar_colorsettings applied at bootstrap. - Collector integration guide rewrite: header-only auth reference and a paste-ready GE-Enforce PowerShell reporting function.
Changed
-
Equipment -> machines rename (backend). The equipment plugin is now the machines plugin:
/api/equipment->/api/machines, tablesequipment/equipmenttypes->machines/machinetypes(columnsequipmentid->machineid,equipmenttypeid->machinetypeid,equipmenttype->machinetype), permissionsequipment.*->machines.*, assettype valueequipment->machine. The legacy coremachinetypeslookup (it types the vendor MODELS catalog, not machine instances) is renamed tomodeltypes(/api/machinetypes->/api/modeltypes,models.machinetypeid->models.modeltypeid) to free the name. Data flips migrate assettypes, auditlog entitytype, settings keys (identifier_*_equipment_enabled->identifier_*_machine_enabled,search_equipment_enabled->search_machine_enabled), and permission rows in place; plugins.json registry entries carry over automatically. Upgrade: runflask db upgradethenflask plugin upgrade-all. -
Inter (variable) replaces Roboto, bundled locally - no Google Fonts fetch, so air-gapped installs render correctly. Tables use tabular numerals.
-
ServiceNow defaults point at the current geaerospaceqa.service-now.com global search (search, incident, and change links).
Fixed
- USB frontend remapped to the actual API shape (
device_id/device_desc): device list, detail, form, label batch, and the employee profile's checked-out/history panels were all reading dead legacy fields. - External-mode
GET /api/usb/checkouts/activenow honors thebadgefilter. - Warranties list page no longer demands login (matches every other list page; reads were already public).
- Removed the dead legacy Warranty Status report (always-zero buckets from a retired column); the warranty plugin's report is the real one.
- Pruned dead usbApi client methods that had no backend routes.
[0.5.0] - 2026-07-10
First release cut with a version, tag, changelog, and CI. Focused on letting other GE Aerospace sites stand up their own self-hosted instance (single-tenant per ADR-004).
Added
- First-run setup wizard (
/setup): creates the initial superadmin in-app, configures each plugin (create tables here vs connect your own database), uploads light/dark floor-map blueprints, and seeds starter reference data. - Self-hosted employee directory and USB plugins: in-app management plus CSV import, no external database required. Both ship default-disabled with an enable-time provisioning note.
- Dell warranty plugin: real Dell provider, bulk warranty sync, add-warranty from asset pages, PC hero warranty badge, disk-cached Dell API token.
- Custom fields, and a two-pane settings shell with tabbed, searchable System Settings and Settings index pages.
- Dashboard defaults (visitor-IP to business-unit mapping) for kiosk displays; printer installer endpoint (data plus floor-map positions).
- Global toast notifications replacing
alert()calls. - Multi-stage Docker build that compiles the Vue frontend and ships
frontend/dist, which Flask serves. - Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides; reconciled README, DEPLOY, status docs, and ROADMAP.
- ADR-007 (product versioning and releases), CHANGELOG, and best-effort CI (backend tests, naming/style gate, frontend build).
Changed
- Plugin contract (
__contract_version__) settled at 0.5.0: full plugin import surface exposed viashopdb.api, dead search hook removed, and the dashboard-widgets hook wired to a real consumer. - Role-based access control now enforced on write routes, including admin-only guards on dashboard-defaults writes.
- Branding, ServiceNow integration, employee-ID pattern, printer hostname template, and floor-plan blueprints are settings-driven and per-site configurable, with GE defaults preserved as shipped fallbacks (branding and floor-plan configurability landed in this release; some consumer wiring continues under Unreleased).
Security
- Dashboard-defaults writes now require admin authorization instead of any authenticated user.
- Collector error responses no longer leak exception detail; failures are logged server-side with generic client-facing messages.
- Login rate limiting added (IP-based fixed window) on top of the existing account lockout.
BREAKING
- Collector API key must now be sent in the
X-API-Keyheader. The api-key-in-querystring fallback has been removed. Update any collector integration that passed the key as a query parameter. Seedocs/COLLECTOR-INTEGRATION.md.