c4e2db4755ecc833665cb58c330ceb26ce32d27c
54 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
784964c907 |
Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.
What arrives:
endpoints plugin - a ShopDB agent reporting endpoint state, telemetry,
services and interfaces, with a command channel the agent
long-polls. Ingest stays on the generic collector route
(ADR-006); the agent key is a UI-managed single-scope
token whose revocation is an instant kill switch.
computers policies - Configuration and Business Policies (RMM-style), scope
trees validated at write time, actions checked against
the endpoints command allowlist. Feature-gated: every
route 404s while the setting is off, so enabling it can
never half-expose the surface.
asset CSV import - templates, a collector script carrying the site path, and
a validate-then-commit import. A plugin can own a virtual
column plus a post-write row hook, which is how a PC's
machine number becomes a controls link without a
hand-built relationships sheet.
agent/ - the Windows agent and its installer.
Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.
MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).
Review fixes carried on top:
- six policy GETs were jwt_required(optional=True), which authenticates a
caller without checking anything; they require computers.view now, and six
redundant jwt_required decorators are gone
- the agent installer took a default BaseUrl and CollectorKey, so a mistyped
run silently pointed an endpoint at the wrong instance; both are mandatory
and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
- a committing CSV import wrote no audit row
Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.
Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
|
||
|
|
151d7c4f44 |
Anyone not signed in holds the guest role
Whether somebody with no account may see something was being answered one feature at a time: a securesend_allow_guests setting, an enable flag per kiosk, and very nearly a third for guides - each with its own settings page, its own vocabulary and its own hand-written branch inside a route. Every one of them answered a permissions question, and this app already administers permissions per role. So a caller with no token is not nobody: they hold the seeded `guest` role. require_permission consults it when there is no identity, which is the entire mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a site opens one by ticking a box in the same grid it uses for every other role, and closes it the same way. Revocation is read on every request rather than cached, because taking guest access away is what somebody does the moment they realise what they opened, and it has to bite immediately. The redundant @jwt_required() above require_permission is gone from 184 routes. It was belt-and-braces before; now it is actively wrong, because it refuses a request with no token before the permission check can have an opinion, so a grant would appear to do nothing. What a guest may hold is bounded. Any .view or .read permission is grantable. A WRITE has to be blessed by whoever owns it, through the new get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the only case that exists: sending a password to a vendor is the one write this product means to offer somebody who never signs in. Everything else is refused by the API and greyed out in the grid rather than warned about - a checkbox that quietly makes deleting an asset anonymous is not a warning anybody reads twice. A guest write records no creator: the trail says when and from where, never who, and that is the reason for the bar. GE-Enforce and Slides gained read permissions so they can be opened to guests without handing out the manage right. Their read routes accept either through require_any_permission, so nothing an existing manager could reach has moved. The navigation endpoint now returns only what the caller can actually reach. Without that a signed-out visitor gets a sidebar of the whole app where every link bounces off the login page, which reads as a broken site rather than as a closed door. A nav item may name several permissions; holding any one is enough, which is what GE-Enforce and Slides need now that a viewer and a manager hold different strings. Two things this found on the way: current_user is a LocalProxy, so `current_user is None` is False for a request with nobody behind it - harmless under a required JWT, an AttributeError the moment verification became optional. And an image pasted into a guide before its first save was never attached to it, so it was visible to editors and invisible to every reader; saving now attaches it, and `flask guides fix-images` repairs what is stored. The guides guest setting written earlier today is gone. Granting guides.view to the guest role replaces it, which is the point. |
||
|
|
38d1e367fc |
Guides: write a procedure in ShopDB, not beside it
The knowledgebase plugin is a link registry - a description, a URL and some keywords pointing at a document held somewhere else. That leaves the documents themselves as HTML files on a share: no search, no history, no way to know whether the copy pinned to a noticeboard is current, and no way for a technician to tick off where they are. A guide is stored as an ordered list of typed blocks rather than a blob of HTML, because three of the four things this feature exists for depend on it. A tickable step needs a stable id to be remembered; dark mode needs semantic markup rather than pasted colours; and print needs to know where a step begins so it does not split one across a page. Free HTML gives none of that and lets pasted styling fight the theme. Inline text is a restricted Markdown subset - bold, italic, inline code, links, line breaks - escaped BEFORE formatting, so nothing an author pasted can reach the DOM as markup. No fonts, sizes or colours: emphasis is semantic and the theme decides how it looks. That is what makes a guide written next year match one written today, and dark mode work without an author thinking about it. DRAFTS AND VERSIONS. The guide row is the working copy; every save that changes something writes an immutable revision; and a separate pointer says which revision READERS get. Without that pointer, editing a live procedure changes it under a technician who is halfway through following it. Publishing promotes an existing revision rather than snapshotting, so a publish with unsaved edits cannot ship a version nobody reviewed. Restoring writes a new revision with old content, so the record of what people were told in between survives. Saves dedup on a content hash covering title, summary, tags and blocks, so an autosave that changed nothing writes no version - otherwise the history fills with identical entries and hides the three edits that mattered. Progress lives in the browser, not the database: shopfloor PCs share a login, so per-user ticks would show one technician's progress to the next person on that bay, which reads as "already done". Keyed by guide and version, because step 4 of version 3 is not step 4 of version 2. Five permissions, and the split between edit and publish is the point: a technician can write a procedure up while somebody else decides it is fit for the floor. create-without-edit works too - an author may edit their own guide until it is published, after which other people are following it. |
||
|
|
7b79a444b8 |
Scaffold a plugin's frontend into the plugin, so it can be pruned
`flask plugin new` wrote views to frontend/src/views/<name>/ and a route file to frontend/src/router/routes/<name>.js. Every one of the 16 bundled plugins keeps its frontend in plugins/<name>/frontend/ instead, staged into .plugins-staged by stage-frontend.mjs and aggregated into routes.gen.js. It was not visibly broken, which is why it lasted: the router still globs ./routes/*.js during the Phase 4 dual-location transition, so a scaffolded plugin's pages loaded. What a new plugin author silently lost was ADR-013 lean builds. Pruning works off the staged tree, so a frontend in the core tree cannot be excluded from a site build, and deleting the plugin strands its views behind. The templates for the correct layout already existed; only the output paths were wrong. Also fixed the imports they carry: core imports now use the @ alias (@/api, @/components/...), which resolves both before and after staging, where the previous ../../api was correct only in the old location. Sibling views stay relative and travel with the route file. frontend_dir is gone from scaffold_plugin. There is no second output tree to point at, and a plugin developed in its own repo now gets the same frontend as a bundled one rather than being skipped. Proven end to end: scaffolded a throwaway plugin, confirmed it stages, builds real chunks, and that SITE_PLUGINS=network prunes it, which is what the old layout could not do. |
||
|
|
b96637e1a1 |
Let a baseline meet a table that is already there
Reported: `flask plugin upgrade-all` failed the tools plugin with "table securesendsecrets already exists". Every post-cutover baseline emitted a plain op.create_table. That is correct only while a table's existence and its plugin's Alembic stamp can never disagree, and they can. Anything that builds tables from the models - db.create_all(), which `flask db-utils create-all` calls - creates them and stamps nothing. A run that creates a table and then fails before its stamp commits leaves the same split, and re-running is the obvious thing to try. In both cases upgrade-all meets existing tables with an unstamped chain and the create is fatal. Nothing caught it because the migration test DROPS the post-cutover tables db.create_all() built before running upgrade-all. That arranges the one state where an unguarded create works, and never the state the report came from. The new test is the same sequence with the drop removed for the plugin under test. create_table_if_missing and create_index_if_missing are in alembic_template.py next to create_plugin_tables, which had the idempotency rule right and turns out to be called by nobody - its docstring claimed every 0001 used it. Fixed the docstring rather than rewiring the baselines onto it, because the reason they hand-write their DDL still stands: the helper's per-plugin MetaData cannot resolve a foreign key to a core table at CreateTable-compile time. INDEXES ARE MATCHED ON COLUMNS, NOT NAME, and that distinction matters. The two creators disagree about names - db.create_all() takes SQLAlchemy's default convention (ix_securesendsecrets_token) and a baseline spells it the way this repo's naming rules require (ixsecuresendsecretstoken). A name-keyed guard would have called the index missing and added a second one over the same column, which MySQL permits with a warning, so every affected site would have quietly grown duplicates. The test asserts no table ends up with two indexes over the same columns. Applied to all nine table-creating plugin migrations, not just the tools baseline that was reported: the four later migrations that create tables have the same exposure, and geenforce, measuringtools, printedparts and backups are the same defect waiting for a different site. WHAT THIS DOES NOT FIX. It makes a baseline idempotent, not a whole chain. A longer chain can still meet an ALTER whose column is already present - printedparts adding gagelabtag to a table create_all built at head is the case that showed up while testing. Guarding every op one at a time is the wrong answer to that; a database built from the models is already AT head and its chains want stamping rather than replaying, and there is no `flask plugin stamp` today. Left alone deliberately rather than half-built, and the new test is scoped to single-baseline plugins so it does not pretend otherwise. |
||
|
|
d4b49b91b4 |
Move secure send into Tech Tools, where somebody would look for it
|
||
|
|
98458672eb |
Hand somebody a password without leaving it in a chat window
The habit this replaces is pasting a credential into Teams, where it stays until someone deletes the conversation. Tabled on 2026-08-21 pending a decision about scope; the decision was text only, so this is the half that survives it. BACKEND ONLY. There is no page yet, so nothing here is reachable by a person. TEXT, NOT DOCUMENTS, and that shapes everything. Dropping files removed the export-control question entirely - a password is not technical data - along with blob storage, size and MIME handling, and the need to authenticate the recipient so a document fetch could be pinned to a name. TWO DOORS. Creating needs a login AND securesend.create: anonymous creation would be an untraceable way to move data out of a system that otherwise records who did what, and the creator is the only person these rows can ever name. Reading needs neither, because the recipient usually has no account here and the link is handed over on a call - the unguessable token IS the authorisation, which is why it is 128 bits of randomness and why the row is spent on first read. REDEEMING IS A POST, and that is the detail that would have broken this in practice. Teams, Outlook and Safe Links prefetch GET urls to build previews and scan for malware, so a GET that redeemed would let a preview window consume a secret before its recipient ever saw it. The GET route that remains reports only whether a link is still live, so the read page can say "opening this will destroy it" without spending the only view to find out. THE SIZE LIMIT IS THE WHOLE ENFORCEMENT. The key lives in the URL fragment, which browsers never transmit, so the server holds ciphertext it cannot read and cannot tell a password from a page of a drawing. MAX_CIPHERTEXT is therefore not a tuning knob: raising it quietly turns this into the document transfer it was scoped not to be. The refusal says what the limit is FOR, because somebody hitting it is usually trying to send a file and the answer is a different tool. Line counts, file signatures and refusing a pasted file belong in the browser, guide an honest person, and are bypassable by anyone willing to POST directly. NOT ENUMERABLE. Missing, expired and spent all answer identically - telling them apart would confirm to anyone holding a guessed token whether it ever named anything. A spent or expired row is deleted on the way past rather than waiting for a sweeper, because it has no reason to keep its ciphertext. Its own plugin rather than a Tech Tool: that plugin is browser-only by design, and this needs storage, expiry and a view counter. Not an asset extension either, so no assetid and no asset type - the scaffold assumes one and it was removed. FOUND BY ITS OWN TESTS: expiryhours=0 returned 201. `int(x or DEFAULT)` treats an explicit zero as absent, so "expire immediately" silently became "expire in a day". A caller who sends a number is now told when it is not allowed rather than quietly given a different one. BEFORE ANYONE RELIES ON THIS, test that your link rewriter preserves URL fragments: create a secret, send the link to yourself in Teams, click it from the message, and check the key is still after the #. If Safe Links strips it the secret is unrecoverable and the server cannot help, which is the point of the design. The fallback is passphrase mode, worth building only if that test says so. Three product gates caught what a new plugin has to declare, and each was right to: the route-parity gate wanted the endpoints described in api-inventory.json, the lean-build guard wanted the plugin listed in the universal profile the released installer builds from, and the authz sweep wanted the public redeem route named in EXEMPT_ENDPOINTS with a reason. That last one matters most - a mutating route that answers an unauthenticated caller is a decision somebody has to write down, not a default, and reading a secret is a mutation because it spends it. |
||
|
|
2d09fa3201 |
Collect what bays actually have, separately from what they are told to have
ShopDB knew what a bay SHOULD have and nothing about what it DOES. Adding the observed half makes a rollout a review instead of a typing exercise: the floor reports itself in, you look, and you adopt. The collection uses the mechanism that already exists rather than a new one. POST /api/collector/printers dispatches to the printers plugin's apply_collector_payload, the same ADR-006 hook the computers and backups plugins implement. New client script, new plugin-owned table, no new transport and no new credential. OBSERVED AND ASSIGNED STAY APART, and that is the point rather than a detail. A collector report can never write an assignment row: _reconcile_edges is the only function that writes usesprinter/defaultprinter, it has two call sites, and both are authenticated routes a human calls. If a drifted bay's own state were allowed to become what it is told to install, every configuration error would become permanent the next time that PC checked in. Seeding an assignment from observed state is explicit - POST /assignments/seed-from-observed - because a rollout adopts many machines at once. It routes through the same _reconcile_edges as the editor, so there is one write path with two doors, and a queue matching no known printer is REFUSED rather than guessed into an assignment. That last rule is the lesson from the measuring tools: adopting on a weak key produced 43 duplicate instruments. Two fixes on top of what the agents built. The replace deleted a host's previous rows by exact case-folded name while the read path treats a short name and its FQDN as one machine, so a PC that changed spelling appeared to hold every queue twice - which reads as drift that is not there. And the client sent 'reportedat' where the declared schema said 'observedat'. Also here: the legacy loader now imports machines.printerid, the classic system's record of each machine's default printer, which it silently dropped - the production import would have lost every one. And Set-ShopdbPrinters.ps1 finally registers the per-user logon task, staging Apply-ShopdbDefaultPrinter.ps1 to C:\ProgramData first because the share it lives on is mounted only during the enforcement cycle and the task runs at logon when it is gone. VALIDATED ON WINDOWS 11 (build 26200), not just on Linux pwsh, which parses these scripts happily and executes none of the spooler branches. The reporter: posts a correct payload with the X-API-Key header; resolves BaseUrl and CollectorKey from HKLM when given no arguments; suppresses the virtual queues by port; resolves port addresses; and reads the CONSOLE USER's default out of HKU rather than SYSTEM's own, which is a different and usually wrong answer. Two results matter more than the rest. With the spooler stopped, both the cmdlet and the CIM path fail and the script posts NOTHING - verified against a capture server that recorded zero requests, where an empty list would instead have erased that host's observed rows and read as a bay that lost its printers. A genuinely empty host still posts [], because that is a real and different fact. The logon task registers as the Users group at Limited, and falls back to the well-known SID S-1-5-32-545 when the group name will not resolve, as it will not on localised Windows. It was then run with the source directory RENAMED AWAY, to stand in for the share being unmounted, and it still moved the user's default - which is the whole reason the script is staged to C:\ProgramData rather than run from where it lives. The guarantees against damage were re-checked rather than assumed: an empty assignment changes nothing, an unreachable server changes nothing, -WhatIfOnly leaves no queue, no task, no staged file and no registry value behind, and a drifted queue is repointed IN PLACE with Set-Printer so whoever has it as their default keeps it. Not covered by any of this: the driver-staging path, which needs a real vendor package rather than the class drivers a VM ships with. |
||
|
|
c7dffce81e |
Serve an uploaded file as data, not as a document that can run
An SVG is an XML document that may carry a script, and it is an accepted image type because floor-plan maps and branding genuinely want vector. Loaded through an img tag that script never runs, so the tiles and maps were never the risk. Opening the file's own URL is - and the application image route is public, so that URL needs no session. Every route that serves an upload now goes through one helper that sends Content-Security-Policy: default-src 'none'; sandbox, and nosniff. Seven routes across core and five plugins, so a new one added later starts from the same place rather than repeating the reasoning. Banning the format instead would have cost the maps their only sensible one. The app also sent no security headers at all. It now sets nosniff, frame-ancestors self (as X-Frame-Options too, for the display bays' browsers) and a referrer policy. Deliberately NOT a page-wide CSP: this serves an SPA with inline styles, so a real script-src policy is a change worth making with the frontend in front of you, and a permissive header claiming one would be worse than having none. Contract 0.19.0. send_upload is on the shopdb.api surface, because a plugin serving user-supplied bytes should not have to remember these headers. The same bump records that get_dashboard_widgets has taken data and shape rather than a component name since the dashboard was rebuilt - that shipped without a bump, while BasePlugin and PLUGIN-HOOKS.md both still documented the shape nothing renders, which is how five plugins came to declare widgets pointing at components nobody had written. |
||
|
|
38deefe619 |
migrations: commit the plugin chains too, and bound what re-ran
Core's Alembic env got connection.commit() when the stamp bug was found; the per-plugin template did not. MySQL commits DDL implicitly, which flushes everything queued before it including the previous migration's version stamp, and the LAST migration of a run has no DDL after it - so its stamp rolled back at close while its schema change survived. flask plugin upgrade-all then exited 0 having silently re-run that migration, and re-ran it again on every deploy after. Invisible for exactly as long as every plugin head happened to be idempotent. Two were not. backups 0003 cleared lastseenat for EVERY row, which is correct once and destroys evidence on each repeat. It is now scoped to the backfill's actual signature, COALESCE(collectedat, createdat) - the expression 0002 wrote - plus a date bound. Both conditions are needed. Matching on collectedat alone misses every row whose collectedat is NULL, so precisely the rows carrying the most invented value would have kept it forever; and value equality is not a signature on MySQL, where db.DateTime is second-precision and the collector writes both stamps in one statement, so a genuinely fresh revision would read as a backfill and be wiped. SQLite keeps microseconds, which is why no test could show it. geenforce 0003 added a column unconditionally, so it failed on a fresh database built from the models and on any re-run. Guarded like network0003prefix. |
||
|
|
fca775c737 |
backups plugin: per-asset config backups with revision history
Adds a kind-pluggable backups plugin. Configuration captured from a PC is filed against the MACHINE it controls, with a revision history and download back to the native format. NTLARS/DNC is the first kind. Settings live in the controlling PC's registry but describe the machine, so revisions attach to the machine's asset and carry no foreign key to the PC: history survives a PC being replaced or deleted, and sourcehostname records the handover. Storage splits by kind. Parseable kinds store a dialect-neutral JSON projection in ShopDB and re-render on download; opaque vendor formats (part marker and similar) keep their bytes on the SFLD share with ShopDB holding metadata and the UNC pointer. Two .reg dialects exist in the wild: NTLARS's own Save... export omits the WOW6432Node path segment, scripted exports include it. Parsing strips whichever root matched, so a stored revision commits to neither and download offers both (NTLARS Load... by default, WOW6432Node for direct reg import). Getting this backwards is silent, so the dedup hash deliberately excludes sourcedialect and both dialects of one config dedup to a single revision. Dedup is load-bearing: the collector runs every GE-Enforce cycle across the fleet, so a revision is inserted only when the content hash differs from that asset's latest for that kind. A freshly imaged PC opens NTLARS with a blank General tab. Recording that would make an empty config the newest revision exactly when someone needs the last good one, so a blank MachineNo is rejected rather than accepted as a change. Two of the 320 known-good backups on the share already have that shape. DNC Info card summarises the latest revision on the machine page: General (Cnc, NcIF, HostType), eFocas, Serial, NTSHR when populated (only 18 of 147 machines), and MARK when the machine is a marker. MARK is gated on Cnc=MARKER or the ShopDB machine type, not on the MARK key having content: MARK carries serial defaults on 145 of 147 machines and DncPatterns reads YES on 103 including ordinary lathes, so neither identifies a marker. The info card is owned by the kind (BackupKind.infopanel/buildinfo) and served by a generic endpoint, so the expected successor to DNC ships its own card by adding a class rather than changing the plugin or the panel wiring. Also: schedule and retention settings with a prune that never drops the newest or the oldest revision, and scripts/import_ntlars_backups.py to seed history from the existing per-machine .reg files (144 of 147 resolve to assets). Codec verified against all 320 real backups: round-trips clean through both dialects. Bay-side generation verified on Windows against reg.exe export. |
||
|
|
593dd46525 |
Show the kiosk label prefix, and let a plugin declare the settings it owns
Three defects, all found on printedparts_label_prefix, all one root cause: nothing in the framework knew that setting existed. The parts kiosk runs logged out. An unauthenticated read of a setting is limited to an allowlist, the key was not on it, so the kiosk got a 404 and fell back to no prefix. An admin previewing the same page while logged in saw the prefix, which is why it looked like it worked. 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 the placeholder category the settings API uses for keys it does not recognise, where the plugin's settings page, which lists by category, could no longer see it. The value was in the database the whole time. And the row was missing in the first place because seeding ran from on_install / on_enable, which fire only on a state transition. Neither runs again on an upgrade, so a setting added in a later plugin version never reached a site that installed an earlier one. The comment claiming enable ran every upgrade cycle was simply wrong. A plugin now declares the settings it owns in get_settings_defaults(): key, default, type, category, description, and whether a logged-out page may read it. The framework seeds declared keys at install, at enable, and on every flask plugin upgrade-all; files a first-time write under the declared category; re-homes any row left in the placeholder category, value untouched; and answers an anonymous read for keys marked public. Core carries no list of any plugin's keys. Contract 0.16.0 (additive optional hook). printedparts and printers move to the hook and floor their core_version at 0.16.0. The dev database had two rows in the misfiled state (printedparts_alert_email, employee_db_host); the first repairs itself on the next upgrade pass. |
||
|
|
2c415a1712 |
fix(installer): correct a false security claim, and clear the should-fix list
CLIENT IP / SPOOFABILITY. docs/geenforce-api-cutover.md claimed that removing the IIS rewrite rule made the allowlist fail closed and that it does NOT become spoofable. The opposite is true. IIS never sets X-Forwarded-For on its own; the rule is the only thing that does. Remove it and IIS still forwards whatever X-Forwarded-For the CALLER sent, waitress trusts it because it arrives from 127.0.0.1, and remote_addr becomes attacker-controlled - so a token-less caller can fetch manifests from anywhere on the network. The document and the _trusted_client_ip docstring now say so, waitress runs with --trusted-proxy-count=1, and stage 5 checks the rule is actually live rather than assuming it. The wizard question is rephrased to something an operator can verify with their network team instead of guessing at. NON-ASCII. The style gate only ever checked .py/.vue/.js/.ts, so documentation accumulated em-dashes, arrows and box-drawing characters against this repo's own convention - including in files added this week. Cleaned, and the gate now uses INCLUDES_ALL so Markdown, JSON and YAML are covered. PLUGIN DEFAULTS. The wizard pre-ticked measuringtools and printedparts, both of which ship default_enabled=false, so every site taking the defaults installed and enabled them against their manifests. Inno has no JSON parser so the list must be hardcoded, but tests/test_installer_defaults.py now fails when it drifts. UPGRADES. The payload copy merges, so a plugin dropped from a site's profile kept its code forever - which defeats a lean build and leaves core's optional-import guards succeeding for a plugin the site no longer has. Stale plugin directories are now deregistered and removed before the copy. add-plugin used 'plugin install', which for the five default_enabled=false plugins left them installed but DISABLED - and printed a green success line anyway. It now goes through apply-profile, and the success line is gated on the exit code. Invoke-Flask records its own exit status, because $LASTEXITCODE keeps a stale value when flask.exe is missing and no native command runs. CHARSET. The utf8mb4 compiler hook lived inline in migrations/env.py, so it covered the CORE chain only: plugin baselines inherited the server default, which on a latin1 server means two charsets in one database. It is now shopdb/utils/mysql_charset.py, imported by both, and preflight reports the database's default charset. BACKUP HONESTY. The dump was described as 'all of your asset data'. Uploaded branding and floor-map images live in instance\ on disk, not in the database, so a restore from the .sql alone comes back with no map. backup now archives instance\ alongside it and says both are needed. VERSIONING. AppVersion was hardcoded at 0.9.0 while the product, the frontend and the newest tag said 0.7.0 - and 0.9.0 collides with a retired contract version. Both builders now generate version.iss from shopdb/__init__.py. Smaller: rollback overwrites .env before deleting it, as uninstall already did; appcmd unlocks are scoped to this site's location rather than server-wide, with the wide unlock as a fallback; DEVELOPMENT-SETUP says Python 3.14; the README plugin list gains printedparts; prune-schema --force is documented as first-provisioning-only; HTTPS is documented as not-the-default with the steps to add it; the DBA SQL is on the wizard's database page; the features page says unticking does not remove an installed feature; and the installer README states that bundle-lock cannot vouch for the exe itself - that needs signing or an out-of-band hash, neither of which is wired up. |
||
|
|
9d65ef103d |
geenforce: display-readiness batch (server hardening, PS client wiring, display scope)
Get GE-Enforce closer to running on credential-less Intune/Entra display PCs that pull manifest + payloads over HTTPS instead of SMB. Server (plugins/geenforce/api/routes.py): - Rate-limit + 512MB served-size ceiling on GET /payload/<sha256> (reuses the login limiter's cache pattern, config-overridable via GEENFORCE_PAYLOAD_*). - New tests: payload hardening, manifestblobs model-vs-migration parity, and a report-contract test locking the lowercase per-entry report keys. PS client (plugins/geenforce/client/): - Fix New-ShopdbReport per-entry key casing to lowercase (name/action/selfhealed/ exitcode/message) to match what the server reads; the engine emits PascalCase. - Enforce TLS 1.2 in the network functions. - Fetch + merge the fleet-wide common scope alongside the pctype scope (pctype wins on conflict; -NoCommon opt-out). - Normalize whatever the engine returns into a well-formed summary. - Make the empty-cache fail-safe observable: event-log entry + report ping instead of a silent exit 0. Manifest (plugins/geenforce/seed_display_scope.py + docs/GE-ENFORCE-DISPLAY.md): - Seed a gea-shopfloor-display scope: 4 Edge kiosk drift-heal registry entries + 1 data-driven dispatcher (Dashboard/Lobby/3DPrintRoom via display-type.txt). Kiosk EXEs stay image-baked; the manifest heals policy/config drift only. - Documents the common SMB-payload audit (entries needing http/inline before a share-less display can inherit common). Migration registry (shopdb/plugins/alembic_template.py + test): - Register the pre-existing manifestblobs and the new printersupplyalerts tables in PLUGIN_TABLE_OWNERS; update EXPECTED_HEAD_REVISION for geenforce (0002blobs), printers (0002supplyalerts), and printedparts (0004txnrev) which had drifted. |
||
|
|
c386e211df |
ADR-014 Phase 2: flask plugin prune-schema for lean per-site DBs
A lean site still gets every plugin's tables from the shared core Alembic baseline. prune-schema drops the tables of plugins not installed on this site, leaving core + chosen-plugin tables, with no edit to any released migration (the relocate-into-plugin-baselines alternative would mean rewriting ~15 released core migrations for a cosmetic gain - see ADR-014). - shopdb/plugins/cli.py: prune-schema command. Dry-run by default; --yes to execute; refuses non-empty tables without --force. Drops by table name (no plugin import) so it works on a lean image. MySQL: private AUTOCOMMIT engine (db.engine's pooled connections sit idle-in-transaction in a CLI context and would deadlock the DROP on a metadata lock). SQLite: db.engine, restoring the prior foreign_keys pragma so the StaticPool connection is not left changed. - tests/test_plugin_prune_schema.py: drop-only-not-installed, full no-op, refuse-non-empty, force-drops-non-empty. - docs/DEPLOY.md: lean provisioning step after upgrade-all. - ADR-014 ACCEPTED; index updated. Verified on MySQL: full install then prune = no-op (86 tables); lean install (machines+printers) then prune drops the other 19 plugin tables; second run no-op. Full suite 1077 passed. |
||
|
|
42ca8d75c3 |
ADR-014: schema-lean per-site (investigation + idempotent create_plugin_tables)
Cross-plugin FK blocker ADR-013 cited is already resolved: the FKs into machines were held only by dead legacy tables (machinerelationships, printerdata, installedapps, communications.machineid) that existing migrations 7a01/7c01 already drop. No live plugin table hard-FKs another plugin. Schema-lean is unblocked. Enabling change: create_plugin_tables now skips already-existing tables (idempotent) so a plugin anchor can create its tables on a fresh lean install and no-op on a database that has them from the pre-cutover baseline. The load-bearing baseline lift is staged as ADR-014 Phase 2. |
||
|
|
e3c4b90afe |
ADR-013 Phase 3: generic asset-panels renderer (Path A)
Wires the ADR-010 get_asset_panels hook to a generic frontend renderer so a plugin adds detail-page UI as JSON, no Vue. This is the Path A foundation that lets simple plugins ship UI without a frontend build. - components/PluginAssetPanels.vue + pluginAssetPanels.js: fetches /api/pluginui/asset-panels for an asset, then each panel's data endpoint, and renders by mode: list (title + status badge + meta lines via a field map), keyvalue, table (declared or inferred columns), badge. Pure mapping logic is in the .js module and unit tested (9 specs), same pattern as entryForm.js. - New 'list' render mode with a declarative field map (title/badge/meta), documented on the hook in base.py. - Warranty migrated to it: get_asset_panels now declares a 'list' panel + map that reproduces WarrantyPanel's output (vendor title, status badge with color + label map, servicelevel/ends/tag meta, manage link) with zero warranty-specific frontend code. - MachineDetail swapped from <WarrantyPanel> to <PluginAssetPanels> (pilot); the hero warranty badge is unchanged. Verified end to end: the API serves the list panel + map and the warranty rows; the page renders without error. Rollout of the other 4 detail pages (PCDetail, PrinterDetail, NetworkDeviceDetail, MeasuringToolDetail) and the map-overlays / asset-presentation renderers are follow-up Phase 3 commits. 58 vitest, build clean, 1067 backend pass, naming green. |
||
|
|
beea6c0c9f |
ADR-013 Phase 2: guard hash-gates a .py sibling of an init-less dir
Fourth review found the last import-path bypass: the is_dir() branch returned None for a name whose dir has no __init__.py, without checking a same-name sibling file. FileFinder loads a file over an init-less namespace dir, so an attacker could overwrite a signed foo.py with malicious bytes, mkdir an empty foo/ next to it (PROVENANCE untouched, still verifies), and any import of that name ran the unverified foo.py - RCE with only plugins/ write access. Fix: the dir-with-no-__init__.py branch no longer returns early; it falls through to the leaf .py hash gate and the non-source refuse check. Invariant: find_spec returns None for a plugins.* name ONLY where FileFinder would also find nothing on the same __path__. Everything else was confirmed sound this round: the owned plugins root, exec of exact verified bytes (never .pyc/.so), the extension/bytecode refusal, plugin.py read-once, the provenance signature gate, dev-exemption scoping, and #3/#4. Symlink, suffix-ordering, cache-lifecycle, and loader-internal angles cleared. 2 regression tests (tampered .py + sibling dir; unsigned .py + sibling dir). All 13 bundled plugins still load under enforcement; 1067 pass, naming green. |
||
|
|
c59d2dab56 |
ADR-013 Phase 2: import guard fails closed on non-.py + owns package root
Third review found the meta_path guard leaked exactly where it delegated to the stdlib import system: 1. Non-.py submodules (CRITICAL). When a name had no dir and no .py, find_spec returned None and the stdlib loaded a planted .so (ExtensionFileLoader) or a sourceless .pyc unverified - an attacker deletes a signed .py and drops a same-named .so with arbitrary init code, run on a normal request via core's `from plugins.<name>.models import ...`. The guard now refuses any name for which a non-source importable candidate (EXTENSION_SUFFIXES + BYTECODE_ SUFFIXES) exists on disk; None is reserved for genuinely-absent modules. 2. Top-level plugins/__init__.py (CRITICAL). It is in no plugin's provenance, is attacker-writable, and Python runs it before any guarded submodule. The guard now owns `plugins`: it execs an EMPTY package body (search points at the plugins dir), so an overwritten plugins/__init__.py never runs. Also: specs are built with spec_from_file_location so loaded modules get __file__/__path__ (Flask blueprint root paths need it) while the loader still execs the verified in-memory bytes - never re-reading the file. Verified end to end: under PLUGIN_REQUIRE_SIGNED with all 13 bundled plugins stamped, the app boots and loads every plugin through the guard; a tampered plugin file is refused at load. 4 new guard tests (planted .so, sourceless .pyc, absent-module defer, neutralized package root). Prior fixes #3/#4 confirmed still sound by the review. 1065 pass, naming green. |
||
|
|
dd30ca0c3f |
ADR-013 Phase 2: verify every plugins.* import via a meta_path guard
A re-review showed the previous "single import choke point" claim was wrong: `plugins` is a normal importable package, so core request handlers that do `from plugins.<name>.models import ...` never passed through the loader and ran unverified - an attacker who dropped a file into plugins/<name>/ got arbitrary in-process code execution on an ordinary HTTP request (and a planted .pyc ran from cache). Gating load_plugin_class covered only plugin.py, one path of many. Fix: importguard.py installs a sys.meta_path finder (under enforcement) that intercepts EVERY plugins.<name>.* import, verifies the plugin's signed provenance once, then verifies each module file against it and execs the exact bytes it hashed - read once, compiled, exec'd, never a .pyc, never a re-opened file. This closes the submodule bypass and the planted-bytecode read, and the read-once exec closes the verify-vs-exec TOCTOU on the import path. The import system, not one method, is the real choke point. - init_app installs the guard when PLUGIN_REQUIRE_SIGNED, clears it otherwise. - load_plugin_class now verifies plugin.py from a single read and execs that buffer (finding #3 on that file); its submodule imports flow through the guard. - docs: stamp-bundled must cover every plugin dir present (a disabled plugin's module can be imported by core); recommend a read-only plugins/ owned by the deploy user as defense in depth (closes the residual migrate-time race an attacker with concurrent write could otherwise attempt). Earlier review's fixes #3 (migrate code paths) and #4 (shelf content binding) were confirmed sound and are unchanged. 7 import-guard tests (submodule verify, tamper, unsigned refused, planted .pyc ignored, real import through the guard, install/uninstall). 1061 pass, naming green. |
||
|
|
55a6f1b8d3 |
ADR-013 Phase 2: fix four bypasses found by adversarial review
An adversarial security review of the Phase 2 trust model found four real bypasses (two remote-triggerable to in-process code execution). Root cause for three: the set of bytes verification covered was smaller than the set that determined execution. Fixes: 1. Bytecode-cache blind spot (CRITICAL). verify_dir excluded __pycache__/.pyc, so a planted cache ran while escaping the hash map. verify_dir now flags any bytecode as an unexpected file; the loader strips bytecode before verify and imports under sys.dont_write_bytecode, so only verified source executes. 2. Unauthenticated verify-at-load bypass (CRITICAL). load_plugin_class imported plugin.py with no gate, reachable via discover_available / an anonymous GET /api/plugins. The verify+strip gate moved INTO load_plugin_class - the single import choke point every path flows through - so an unsigned/tampered plugin is never imported. discover_available skips a refused plugin instead of 500. 3. Ungated migration entrypoints (HIGH). downgrade_plugin and get_current_head (ScriptDirectory imports version modules) ran plugin code with no check. All alembic-invoking methods now pass through _verify_ok (strip + verify) first and run under no-bytecode. 4. Revocation/content bypass (HIGH). The signed index bound a filename, not content; adopt did not bind the delivered bytes to the resolved version, so revoked bytes could be served under a live filename. The index now records a per-artifact SHA-256; adopt verifies the on-disk digest and requires the artifact's own signed manifest version to equal the resolved version. Enforcement stays default-off; strip/no-bytecode run only under enforcement, so the unsigned path is unchanged. 6 regression tests (planted bytecode, the discover import path, downgrade gate, version-swap). 1054 pass, naming green. |
||
|
|
5b19f3b554 |
ADR-013 Phase 2: enforcement + signed shelf + adopt
Completes the marketplace security model. Verification stops being advisory: a plugin only loads or migrates when its tree matches a trusted signature, and plugins are pulled from a signed shelf with anti-rollback and revocation. Enforcement (default OFF - existing deploys unchanged): - verification.py PluginVerifier, shared by the loader (verify-at-load, before plugin.py is imported) and the migration manager (verify-at-migrate, before any DDL). Fail-closed: an unsigned/tampered/wrong-key plugin does not run. - Gated by PLUGIN_REQUIRE_SIGNED. PLUGIN_DEV_TRUST_DIRS exempts named dirs but only under DEBUG/TESTING; production ignores it. - flask plugin stamp-bundled writes provenance into in-tree plugins so verify-at-load applies to bundled plugins too (image build step). - tier:core manifest guard: uninstall/disable refuse a core-tier plugin. Shelf (shelf.py): - Signed shelf-index.json (+ .sig): monotonic serial (a site refuses an older index - anti-rollback), revoked list carried across builds, per-entry version/tier/core_version for browse. Index is a browse layer only; adopt reads security-bearing fields from the verified artifact. - flask plugin shelf-build / shelf-list / adopt / audit. adopt verifies index + artifact (signature + every file hash), unpacks to staging, re-verifies, then atomically moves into place and installs+enables the closure. Refuses a downgrade without --force-downgrade. Anti-rollback serial stored in instance/shelf-state.json. - config PLUGIN_SHELF_DIR; the app only reads the folder, never speaks a network. .env.example + docs/PLUGIN-SIGNING.md document the flow. 22 tests: verifier policy (off / no-keys / signed / tampered / wrong-key / dev-exempt), verify-at-load + verify-at-migrate integration, tier guard, index sign/verify + tamper/wrong-key, serial state, revocation, version resolution, verified atomic unpack + tamper refusal. Live-smoked keygen->pack->shelf-build ->list->adopt->audit + serial guard. 1050 pass, naming green. |
||
|
|
86f5f1be68 |
ADR-013 Phase 1: signed plugin artifacts (pack/validate/keygen)
Packaging + provenance for the plugin marketplace. No runtime behavior change yet - verification is available on demand; enforcing it at plugin load/migrate and pulling from a shelf are Phase 2. - signing.py: ed25519 key pairs + provenance. Provenance is a sorted per-file SHA-256 map plus metadata; the detached signature covers the exact serialized provenance bytes, so verifying is re-hash files, re-serialize, check signature. verify() accepts any of several trusted keys (rotation). Uses cryptography (already a dependency). - packaging.py: pack() builds a signed <name>-<version>.shopdbplugin (zip + PROVENANCE.json + PROVENANCE.sig). verify_artifact()/verify_dir() re-hash and check the signature, and flag a tampered file, an unexpected file, a wrong/absent key - all fail closed. - CLI: `flask plugin keygen` (publisher key pair), `flask plugin pack <name> --key` (validates then signs), and `flask plugin validate` extended to a signed artifact by path (--pubkey, else PLUGIN_TRUSTED_KEYS). - config PLUGIN_TRUSTED_KEYS: os.pathsep-separated public-key PEM paths, delivered with the site config, never read from the shelf. .env.example documents it. - docs/PLUGIN-SIGNING.md: curator flow (keygen offline, review, pack, publish, pin keys, rotate). The signature proves an artifact is exactly what a curator signed, not that the code is safe - human review before signing is the control. 11 tests: sign/verify round trip, wrong key, provenance excludes noise, serialize determinism, pack + verify, tamper -> hash mismatch, extra file, no-key fail-closed, verify_dir. 1028 pass, naming green. |
||
|
|
d178726687 |
ADR-013 Phase 0: plugin lifecycle groundwork
Additive, zero-risk-to-running-sites prep for the plugin catalog. No distribution or lean-build behavior yet; fixes latent bugs and adds the declarative + validate tooling later phases build on. Fixes: - upgrade_all_plugins iterates registry.get_all(); only adopted plugins are migrated. Removes the phantom hasattr(registry, 'list_installed') probe that always fell through to migrating every folder on disk (unadopted DDL ran with full DB rights on every deploy). - Reverse-dependency checks on uninstall/disable read dependencies from the manifest on disk via _installed_dependents, so an installed-but-unloaded or disabled dependent is counted. Uninstall blocks on any installed dependent; disable blocks on an enabled dependent. - _sort_by_dependencies detects a dependency cycle (back edge in the DFS) and raises PluginDependencyError instead of looping or dropping a plugin. New: - flask plugin validate <name>: manifest loads + name match, manifest-schema check, core_version admits the framework contract, declared dependencies exist on disk. No new dependency (lightweight checker); schema ships in the package at shopdb/plugins/manifest_schema.json (docs/ is stripped on publish). The check caught that provides is an object, not an array. - flask plugin apply-profile <file>: declarative install AND enable of a chosen plugin set plus its hard-dependency closure, in dependency order, idempotent. Replaces the hand-ordered runbook sequences that could enable a plugin that was never installed. deploy/site-profile.example.json template. - Dockerfile header corrected (all 13 catalog plugins, not "eleven core"). 10 new lifecycle tests (reverse-deps from disk, cycle detection, upgrade-all scope, profile closure, schema, all 13 manifests match schema). 1018 pass, naming green. |
||
|
|
aa4bfcd41c |
printedparts stage 15: print-file revision history + role-based alerts
printeditemfiles lands as the plugin's first incremental migration (0002 on the plugin chain - the ADR-008 payoff). Revisions are append-only per item: upload assigns the next number, records the uploader from the JWT, enforces an extension allowlist and a 100 MB cap; download serves the original filename; a permission-gated delete covers wrong-file mistakes. The detail page gains the revision table with a current badge. Unique storedfilename is sized 191 so the index fits MySQL's 767-byte prefix - the per-plugin chain does not apply the core env's ROW_FORMAT hook. Alert recipients gain roles: Role joins the 0.13.0 surface, a role picker on the settings page, and every active member of the selected roles is folded into the deduped recipient list. |
||
|
|
f5cfac33b4 |
printedparts stage 2: models, real 0001 baseline, tables live
PrintedItem (catalog: code, name, image, cached quantityonhand, per-item threshold, bin) and PrintedItemTransaction (the ledger: signed quantity change attributed to a badge-resolved employee). Both registered in PLUGIN_TABLE_OWNERS; 0001 is a post-cutover real baseline. The migration-guard test learns the new expected head. Routes are a placeholder ping until the next stage - the scaffold's list route imported the deleted scaffold model, which surfaces as an empty 'Migration error' because the alembic env imports the models package. |
||
|
|
85a6ab8645 |
Setup wizard: plugin display names + GE-Enforce next-steps pointer
Plugins list now carries a displayname (manifest display_name, else the machine name title-cased). Adds display_name to the four whose title-case was wrong: GE-Enforce, USB, Measuring Tools, Knowledge Base. The setup wizard Features step and Settings > Plugins render it, so "Geenforce"/"Usb" are gone. Finish step shows a pointer when GE-Enforce is enabled: it still needs a scoped service token (Settings > API Tokens) and a share export root (GE-Enforce page) before the fleet uses it - operational config the wizard does not collect. frontend build green; naming green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
6dc31c6149 |
Add GE-Enforce observed-state reporting: receipt + self-heal from PCs
PCs now report enforcement results back to shopdb, closing the desired-vs-observed loop. - POST /api/geenforce/report (geenforce.report service token): each cycle a PC posts the published version it applied, install/skip/fail/filtered counts, and per-entry outcomes. - Two tables: manifestenforcementreports (latest-per-host + history: applied version, enforcer version, counts, derived status ok/selfhealed/failed) and manifestenforcementresults (per entry: action installed/skipped/failed, selfhealed flag, exit code, warning/error message). - RECEIVED: reports carry the applied version; the admin view derives receivedlatest by comparing it to the scope's current published version, so the fleet view shows which PCs picked up an update. - SELF-HEAL: per-entry action captures drift correction (installed when it should already be present) vs skipped (already good) vs failed, with messages. - Admin reads: GET /reports (fleet compliance rollup) and GET /reports/<id> (per-entry detail). New geenforce.report permission. - Tables added to the (undeployed) 0001 baseline; geenforce.post_report is a service-token endpoint so it is exempt from the JWT authz sweep, like the collector blueprint. 8 reporting tests; full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
d85b33bd68 |
Build GE-Enforce manifest-store plugin (P0/P1): model, importer, parity gate
First execution phases of docs/proposals/ge-enforce-plugin.md. The GE-Enforce manifest becomes shopdb data. P0 scaffold: new geenforce plugin (api_prefix /api/geenforce, default_enabled false, core_version >=0.7.0). Registered in PLUGIN_TABLE_OWNERS (ADR-008); its 0001 baseline really creates the tables. P1a model: one wide manifestentries table + entrytype discriminator (not STI, not JSON blob), manifestscopes (UNIQUE scopename+phase), the three multi-value filter child tables, inusechecks + processes, immutable manifestpublishedversions (frozen rendered JSON), manifestpayloads (inline, capped), pctypealiases (mirror of the engine lib's alias graph). regvalue stored as its raw JSON literal so DWord typing survives. P1c importer + exporter: parse common + gea-shopfloor-* + preinstall.json into draft rows and rebuild the JSON verbatim from rows in sortorder. P1d parity harness (GATE A): filters.py mirrors the engine's four filter functions + alias graph; parity.py proves import+export is behaviorally lossless (field-identical + same-entries-fire across 18 machine-profile fixtures) WITHOUT byte-diffing. Verified PASS against all 11 real reference manifests (64 entries) and a synthetic site-neutral fixture covering every type/filter (the CI gate). First slice (gea-shopfloor-cmm shape): service layer (import/publish/rollback/ export-to-share), CLI (parity, import-share, publish, export-share), and the client endpoint GET /api/geenforce/manifest serving the current published snapshot (never the draft) with ETag/304. Split permissions geenforce.manage/publish/fetch. Tests prove import->publish->serve, draft edits never change served bytes, publish+rollback, and auth (401 unauth/wrong-scope). Contract 0.11.0: added service_token_authorized(scope) to shopdb.api so plugin service endpoints authorize a scoped managed token without importing core token internals. Documented in PLUGIN-HOOKS.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
7d309aabeb |
Final-pass polish: support contact UX, audit tooltip, doc refresh
Support teams: contact management moved from a row expander to a modal (Contacts (N) button per team); application detail Support card and the modal show Email (mailto) and Teams chat buttons for contacts with an SSO, derived as sso@ + a new contact_email_domain site setting (default geaerospace.com, blank hides the buttons). Audit log: hovering a user SSO shows the full name, resolved best-effort from the employee directory in either mode. Docs/hygiene from a standards review: CLAUDE.md active-state, CONTRACT-STABILITY.md and README brought to contract 0.10.0 / 11 plugins / migration head 7d22; get_asset_panels endpoint path fixed in the hook docstring; leftover debug console.logs removed. 781 tests pass; contacts modal, action-button hrefs, and the audit tooltip verified live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7dfbe7bf8a |
Add the get_permissions plugin hook (contract 0.10.0)
Plugins declare their own RBAC permissions instead of core accumulating them: 36 permissions moved out of the core catalog into the 9 owning plugins (core keeps the 19 its own blueprints enforce). The catalog is resolved dynamically (core + enabled plugins) and feeds the roles grid, the token scope picker and ceiling, and flask seed permissions; installing or enabling a plugin seeds its permissions automatically. A disabled plugin drops out of the assignable catalog while existing role links keep working. New plugins - bundled or external - now bring their permissions with zero core edits. 781 tests pass; live-verified with a machines.edit-scoped token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
24f67d6ac5 |
Accept + implement ADR-010 frontend plugin hooks (contract 0.7.0)
Four data-only hooks on BasePlugin (get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation) with a GET-only /api/pluginui consumer surface copying the dashboard-widgets semantics. Pilots: warranty declares its asset panel; measuringtools supplies its settings card, presentation, and calibration overlay - the last hardcoded settings-nav entry is now hook-sourced. Generic renderers for panels/overlays/presentation deferred per the ADR's incremental adoption plan (documented in CONTRACT-STABILITY.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
48d3160bc5 |
Rename the equipment domain to machines; retype the models catalog (ADR-011)
The equipment plugin is now the machines plugin, ending the UI-vs-code vocabulary split while the contract is pre-1.0 and nothing external depends on the old names. - plugins/equipment -> plugins/machines: manifest, class, /api/machines, machines.* permissions, registry key (with an auto-migrating load shim for existing installs). - Tables: equipment -> machines (equipmentid -> machineid) and equipmenttypes -> machinetypes, renamed in the plugin's own migration chain (machines0002rename), idempotent for both upgrading and fresh installs. - The legacy core machinetypes lookup actually types the vendor MODELS catalog, so it is renamed losslessly to modeltypes (models.modeltypeid, /api/modeltypes, Model Types settings page) rather than collapsed, freeing the machinetypes name. Core migration 7d17_machines_rename also flips data in place: assettypes row equipment -> machine, auditlog entitytype, identifier_/search_ settings keys, permission rows, and renames alembic_version_equipment. - Frontend: machinesApi/modeltypesApi, item.machine response shape, assettype value compares 'equipment' -> 'machine' (map, search, custom fields, relationships), routes machines.js with plugin gating retagged, /print/machine-badge, Machine Types (subtypes) and Model Types (catalog) settings pages, machines-by-type report id. - Docs swept; ADRs left as history per the authoring rule. Upgrade: flask db upgrade then flask plugin upgrade-all. Verified: dev DB flipped live (262 machines, 35 modeltypes, 95 models retyped, zero equipment tables remain); fresh scratch-MySQL install produces the new names; 341 tests green; naming/style green; frontend builds; live E2E on machines list/detail, PC relationships, map, reports, and both settings pages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
529b9f2fed |
Ship plugin framework shore-up: frontend scaffold, sister-site adoption kit
- flask plugin new now scaffolds the frontend too: List/Detail/Form views on the global styles, a gated route module (ADR-009), and an api-client snippet emitted into the plugin dir. Views are written before the route file so a partially generated plugin cannot 500 the dev server. - docs/PLUGIN-EXTERNAL-REPO.md + scripts/test-external-plugin.sh: how a sister site develops a plugin in its own repo and runs the framework contract tests in CI against a pinned framework ref (script verified to fail on a broken core_version pin). - docs/CONTRACT-STABILITY.md: settled vs churning contract surface and the provisional 1.0 criteria. - CLAUDE.md active-state refresh (contract 0.6.0, 11 plugins, 340 tests, measuringtools done). Known limitation documented: Path.rglob does not descend symlinks, so the import-surface contract test skips symlinked external plugins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
22e623c1f6 |
Plugin framework maturation, reports overhaul, theming, and USB frontend repair
Framework: - Per-plugin Alembic migration chains (ADR-008): every bundled plugin carries its own chain with a stamp-only anchor at the ownership cutover; new plugin schema lands in plugins/<name>/migrations/, never the core chain. Deploys add flask plugin upgrade-all. Fixed a latent bug in the shared alembic template (engine URL resolution) and taught the metadata filter to include FK-referenced core tables. - Frontend plugin route gating (ADR-009): plugin routes carry meta.plugin; a disabled plugin's pages redirect to the dashboard via a cached, fail-open check against the new public GET /api/plugins/enabled. - get_reports() plugin hook (contract 0.5.0 -> 0.6.0): plugins contribute report cards; warranty and toner cards moved off the hardcoded list. Reports: - Hub grouped by category with search; inline reports render at the top, are URL-backed (?report=id, back-button and deep links work), expose their server-side filter params as controls, and export CSV. Warranty and Toner pages gained CSV export. - Deleted the dead legacy Warranty Status report (always-zero buckets from a retired column). Theming and fonts: - Inter (variable) bundled locally via @fontsource, replacing the Google Fonts Roboto import - air-gapped installs now render correctly; tables use tabular numerals. - Optional brand_primary_dark_color, brand_accent_color, brand_sidebar_color settings applied to CSS vars at bootstrap. USB frontend repair (views were reading a dead legacy shape): - List/detail/form and the employee profile USB panels remapped to the real API shape (device_id/device_desc/checkinoutlog); employee panels now use /usb/checkouts endpoints; external-mode /usb/checkouts/active honors the badge filter; dead client methods pruned. Also: warranties list page no longer requires login (matches app convention); collector doc rewritten with a GE-Enforce integration guide and paste-ready PowerShell reporter; ADR index and CHANGELOG updated. Verified: 323 tests pass, naming/style green, frontend builds, plugin migration dry-run green on scratch MySQL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b8c22244a1 |
Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.
Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
prefixes, enable toggle. Defaults point at the current
geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
ships as the map default and sites upload their own blueprint.
Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).
Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.
Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
(naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
plugin contract purity) and the USB label page field mapping (both usb
modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.
248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
0e0bbc0604 |
Employees + USB default-disabled with an enable-time provisioning note
Both plugins provision extra tables, so they now install disabled and explain
themselves before a site opts in.
- Plugin contract gains get_provisioning_note() -> {tables, note, docs}.
Employees and USB implement it (what tables get created in shopdb, how they
are referenced, link to the schema README; USB references the captured
DLP/reminder plans).
- Manifest default_enabled=false for employees + usb; the plugins list API
returns provisioning_note + default_enabled; install now registers a plugin
disabled when default_enabled is false.
- Setup wizard Features step renders the provisioning note the moment a plugin
with one is enabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
087ece0f8c |
Setup wizard P2: per-plugin config schema + settings-first creds
- Plugin contract gains get_config_schema(); the plugins list API returns it. Employees plugin declares its directory-DB fields (host/name/user + password). - employee_connection reads host/name/user settings-first (env fallback); the password stays env-only. - Setup wizard Features step renders each enabled plugin's config: non-secret fields save to settings; secrets are never stored - the wizard emits .env lines to paste. Fixed the plugins-list data path (data.plugins). - Settings PUT now upserts (creates the row on first write) so plugin-config keys can be saved without pre-seeding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
60641161d5 |
Second-pass review fixes: kill last hardcoded creds, wire get_services, dedup
Verification audit (re-run of the 6 skill lenses) confirmed the prior fixes hold and surfaced a few misses: Security (HIGH): - search.py _check_smart_redirect still opened a raw pymysql connection with root/rootpassword (reachable on any 9-digit SSO query). Now uses the shared env-backed employee_connection helper. - Deleted dead shopdb/core/services/employee_service.py (zero importers; carried another root/rootpassword literal). No hardcoded credentials remain in app logic; config.py dev defaults stay gated by ProductionConfig.validate. Dead hook: - get_services was implemented by the printers plugin but had no consumer (docs claimed otherwise). Added PluginManager.get_service(name) that resolves a service from enabled plugins; updated PLUGIN-HOOKS.md. Tests: - search disabled-plugin exclusion (the high-value gap): enabled plugin's hostname appears, disabled plugin's hostname drops out (searched by a hostname distinct from assetnumber so only the gated domain can match). - get_service consumer test (unknown name -> None). Simplify: - Extract the triplicated GE_LOGO_SVG + loadLogo + drawLogoOverlay into shared frontend/src/views/print/qrLogo.js (renderQrDataUrl); both QR views use it. - applications.py: lift the misplaced pagination import to the top; drop unused Computer unpacking in the 3 endpoints that only touch ComputerInstalledApp. 154 tests pass, naming/style green, app boots, QR render verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a515c28e3b |
Decouple core from plugins; wire widgets hook; drop dead search hook
Architectural pass from the skill review ("plugin is the product" boundary).
Core no longer imports plugin models at module load (was a hard import-time
dependency that broke core if the computers plugin was absent/disabled):
- collector.py, applications.py, reports.py: lazy + guarded imports of the
computers plugin models. Endpoints that need install-tracking now return 503
when the plugin is absent instead of failing at import.
Search honors runtime enable/disable:
- search.py: _require_enabled(name) raises ImportError for a disabled plugin,
so each plugin-scoped block skips it (a disabled plugin's rows leave search).
- Replace hardcoded root/rootpassword employee-DB connection in _search_employees
with the shared env-backed employee_connection helper.
Plugin hooks (integrating-plugin-hooks: every hook needs a consumer):
- get_dashboard_widgets: add the consumer GET /api/dashboard/widgets (5 plugins
already implemented the hook; it had none). Skips disabled, isolates in prod.
- get_searchable_fields: REMOVED. Zero plugins implemented it and there was no
consumer; global search is a core concern over the asset model. Contract
reduction, __contract_version__ 0.3.0 -> 0.4.0.
Docs/contract: PLUGIN-HOOKS.md (widgets consumer note, searchable-fields removal,
0.4.0), PLUGIN-QUICKSTART.md, ADR-001 hook list. Tests: widgets endpoint
aggregate + disabled-skip; contract tests for the removed/added hooks.
151 tests pass, naming/style green, app boots all 6 plugins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
5fa5160420 |
Apply skill-driven review fixes: security, hook isolation, tests, docs
Addresses findings from a 6-lens review against the project skills (defining-asset-contract, enforcing-plugin-contract, hardening-flask-config, integrating-plugin-hooks, pinning-flask-behavior, simplifying-python). Security (hardening-flask-config): - Load per-plugin COLLECTOR_API_KEY_<PLUGIN> from env in create_app. from_object only copies class attributes, so per-plugin keys (ADR-006) were dead in real deploys and silently fell back to the shared key. - EMPLOYEE_DB_USER/PASSWORD no longer default to root/rootpassword (no safe default for a secret; unset fails loud). Documented in .env.example + DEPLOY.md. - COLLECTOR_API_KEY + per-plugin + EMPLOYEE_DB_* added to .env.example/DEPLOY.md. Hook isolation (integrating-plugin-hooks): - collector _collector_plugins and dashboard get_navigation now re-raise in dev/test and log+isolate in prod, instead of silently swallowing a broken plugin hook. Plugin loader (enforcing-plugin-contract): - enable_plugin/install_plugin read dependencies+version from the manifest instead of instantiating the plugin class. - _register_plugin_components rejects a second plugin claiming an already-used api_prefix (reset per app in init_app). Tests (pinning-flask-behavior): - test_identifiers.py: gauge/maintenance round-trip on computer/printer/network create+update; per-type seed yields the 12 identifier keys. - contract tests for apply_collector_payload presence + schema-declarers-implement. - security tests for per-plugin key env loading + no employee-db password default. Docs/contract sync (defining-asset-contract): - PLUGIN-HOOKS.md documents apply_collector_payload; stale 0.2.0 -> 0.3.0. - ADR-006 documents apply_collector_payload + single-dispatch rationale. - ADR-001 enumerates the expanded shopdb.api import surface. Simplify (simplifying-python): - De-duplicate the 21-entry settings defaults: shared build_default_settings() used by both the /settings/seed route and the CLI (were drifting copies). - Remove dead AssetStatus import + redundant AssetType local import in computers plugin; comment the statusid=1 collector default. 153 tests pass (was 145), naming/style green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f663cc5bbe |
Enforce plugin contract purity: single import surface via shopdb.api
Plugins were reaching into internal core paths (shopdb.core.models.*, shopdb.extensions, shopdb.utils.*), coupling them to core's file layout and violating the ADR-001 contract. Consolidate onto one versioned surface. - shopdb.api: expand from 2 helpers to the full plugin import surface - db, cache; BaseModel, AuditMixin; core models (Asset, AssetType, AssetStatus, Vendor, Model, Communication, CommunicationType, Location, Setting, AuditLog, Application, AppVersion, OperatingSystem); response + pagination helpers; employee_connection. Documented in PLUGIN-HOOKS.md. - Migrate all 22 plugin source files to import only from shopdb.api (plus shopdb.plugins.base for the ABC). - Drop the printers plugin's legacy MachineType dependency: remove _ensure_legacy_machine_types and the seed_supplies machinetypeid lookup (Model.machinetypeid is nullable; printers carry type via PrinterType). - Guard test test_plugins_only_import_contract_surface scans plugin source and fails on any core import outside shopdb.api / shopdb.plugins.base. - Scaffold templates updated so generated plugins are contract-pure. - Bump __contract_version__ 0.2.0 -> 0.3.0 (additive surface expansion; manifests pin <1.0.0 so they still satisfy). 145 tests pass, naming/style green, app factory boots all 6 plugins. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b567de14ac |
Add ADR-006 generic plugin collector contract
Implements the plugin collector contract from ADR-006 so any plugin can accept idempotent inventory ingest, not just PCs. - base.py: add apply_collector_payload hook (companion to get_collector_schema), raises NotImplementedError by default for plugins that declare a schema but do not implement the upsert. - collector.py: generic POST /api/collector/<plugin> dispatch with per-plugin API key (COLLECTOR_API_KEY_<PLUGINNAME> with COLLECTOR_API_KEY fallback), schema-driven identity validation, idempotent upsert, ADR-006 response contract (status, action, assetid, identityvalue, warnings), audit log. JWT-protected GET /api/collector/_schemas lists registered schemas. Legacy /pc, /apps, /heartbeat, /bulk kept for back-compat. - computers plugin: implements get_collector_schema (identityfield hostname) and apply_collector_payload (create-or-update Asset+Computer, serialnumber, loggedinuser, lastboottime, primary IP communication, installed apps). - tests: 7 collector-contract tests (auth, 404, validation, create/idempotent update, per-plugin key precedence, JWT schema listing). A single dynamic dispatch route is used instead of per-plugin blueprint registration, avoiding Flask's register-blueprint-after-first-request error. 144 tests pass, naming/style check green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
4d23f5b0fd |
Fix internal error when enabling a plugin
enable_plugin called register_blueprint at runtime, which Flask forbids after the first request (AssertionError -> 500). Enabling now flips the registry flag and fires on_enable best-effort; routes register on the next restart, symmetric with disable. Nav reflects the re-enable immediately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
919e0b8600 |
Plugin system: fix PluginMeta core_version default + add /api/plugins
- PluginMeta.core_version defaulted to >=1.0.0, which would reject the current 0.2.0 framework for any plugin relying on the default. Set to >=0.2.0,<1.0.0. - Add GET /api/plugins introspection: lists loaded plugins (name, version, core_version, api_prefix, dependencies) + the framework contract version. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b0a0670fcd |
Retire the legacy Machine model (ADR-001 cutover)
The asset/computer model is now the single source of truth. Remove the Machine instance layer end to end: - Delete models Machine, MachineStatus, PCType, MachineRelationship, InstalledApp, PrinterData; keep MachineType (models.machinetypeid still references it). - Delete the /api/machines, /api/statuses, /api/pctypes blueprints and the legacy /api/printers/legacy (PrinterData) blueprint. - Drop the deprecated communications.machineid column and its FK. - Migration 7c01 drops tables machines, machinestatuses, pctypes, machinerelationships, installedapps, printerdata (idempotent). - Fix remaining readers (applications install counts) to ComputerInstalledApp. - Frontend: remove dead machinesApi/statusesApi/pctypesApi wrappers; repoint the PC Types settings page at computer types. 143 tests pass; all asset/computer/dashboard/report/collector endpoints 200. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
0436e8b0af |
Asset-side printer save, status CRUD, real printer types, network fix
- Printers save through the asset blueprint (PUT /printers) instead of the legacy machines API; restrict supply-model picker to printer models. - Asset statuses get full CRUD (PUT/DELETE with in-use guard); canonical set. - Printer types reseeded to a real classification set + list filter. - Equipment accepts gauge/maintenance references. - Fix network list emitting network_device instead of networkdevice (View 404). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
42ae985d72 |
Phase 7B: per-plugin Alembic chains for bundled plugins
Each of the six bundled plugins (computers, equipment, network,
notifications, printers, usb) now has its own Alembic chain with a
baseline migration. Sister sites adopting one of these plugins can
manage its schema via `flask plugin migrate <name>` instead of relying
on db.create_all to bootstrap everything.
Existing single-site deploys that bootstrap via db.create_all continue
to work unchanged. The chains coexist; the bootstrap path stays the
operator's choice.
Framework
- shopdb/plugins/alembic_template.py: shared env.py logic + helpers.
PLUGIN_TABLE_OWNERS pins which tables belong to which plugin (explicit
registry, not import-side-effect). _get_plugin_metadata filters
db.metadata to only the named plugin's tables. create_plugin_tables /
drop_plugin_tables emit DDL via SQLAlchemy CreateTable so the table
definitions stay sourced from the models, not duplicated.
- shopdb/plugins/__init__.py: PluginManager.upgrade_all_plugins() runs
pending migrations across every discovered plugin and returns a status
dict. Idempotent (Alembic skips applied revisions).
CLI
- `flask plugin upgrade-all` runs pending migrations for every plugin.
Used on a fresh deploy after the core schema is in place.
Per-plugin scaffolding
- plugins/{computers,equipment,network,notifications,printers,usb}/
migrations/{alembic.ini, env.py, script.py.mako, versions/0001_baseline.py}
- Each env.py is a 5-line shim that sets PLUGIN_NAME and delegates to
the shared template. Each 0001_baseline calls create_plugin_tables(name)
/ drop_plugin_tables(name); no duplication of column definitions.
Tests
- tests/test_plugin_migrations.py (18 cases): every bundled plugin has
an entry in PLUGIN_TABLE_OWNERS, has the on-disk Alembic scaffolding,
and the filtered MetaData contains every owned table (catches drift
between the template's table list and what the models declare).
- 129 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
5de1fbe8a8 |
Phase 4: plugin scaffolding (flask plugin new) with canary tests
Lowers the barrier for sister sites to build their own plugins. Generated output satisfies the framework contract out of the box. CLI command (shopdb/plugins/cli.py): - `flask plugin new <name> --description "..."` generates a plugin skeleton under plugins/<name>/. Validates the name against CONTRIBUTING.md rules (lowercase letters/digits only, no underscores or hyphens, not in the reserved list) and refuses to overwrite existing plugins unless --overwrite is passed. - Output prints the next steps (install, migrate, test). Scaffolder (shopdb/plugins/scaffolder.py): - validate_name: enforces the naming rules - pascal_case: lowercase-to-PascalCase for class names - scaffold_plugin: copies templates with string.Template substitution. Three placeholders: $name, $Name, $description. Files with `model.py` in the path get renamed to <name>.py. Templates (shopdb/plugins/templates/): - manifest.json.tmpl: name, version 0.1.0, description, core_version range >=0.1.0,<1.0.0 (broad enough to survive minor framework bumps) - plugin.py.tmpl: <Name>Plugin class extending BasePlugin with all required hooks implemented (meta from manifest, get_blueprint returning the bp, get_models returning the example model). Includes on_install hook that seeds the AssetType row. - models/__init__.py.tmpl + models/model.py.tmpl: Asset extension table keyed by assetid with one example field. TODO comment marks it as a placeholder. - api/__init__.py.tmpl + api/routes.py.tmpl: Blueprint with list and detail endpoints using the framework's pagination + response helpers. - schemas/__init__.py.tmpl: marshmallow schema stub. - tests/__init__.py.tmpl + tests/test_plugin.py.tmpl: smoke tests asserting plugin loads, get_blueprint returns Blueprint, get_models returns at least one model. - README.md.tmpl: one-pager for plugin authors with common edits and next-step references. Canary tests (tests/test_plugin_scaffold.py): - 14 tests asserting the scaffold output passes contract checks. - Validates name rules (lowercase, reserved, hyphens, digits, etc.) - Verifies all expected files generated, manifest fields present. - Loads the generated plugin via PluginLoader (spec_from_file_location bypasses the real `plugins` package shadowing). - Asserts subclasses BasePlugin, get_blueprint returns Blueprint, get_models returns model with __tablename__. - Module-scoped fixture; cleans up sys.modules + SQLAlchemy metadata on teardown to avoid cross-test contamination. Quickstart docs (docs/PLUGIN-QUICKSTART.md): - 30-minute walkthrough: scaffold -> edit model -> add routes -> install -> verify -> add hooks. Cross-links to PLUGIN-HOOKS.md and the ADRs. Includes common-errors table. Test count: 87 -> 101 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9d3482fa21 |
Phase 3 (part 1): manifest-first loader, shopdb.api namespace, auto-register
Hardens the plugin framework so sister-site adoption is safe. Loader rewrite (shopdb/plugins/loader.py): - Reads manifest.json directly. Dependency sort and version checks no longer instantiate plugin classes (avoids __init__ side effects). - Fail-loud policy: in dev/test (DEBUG or TESTING true), plugin errors re-raise. In production, errors log with full context and the plugin is excluded from registration. Framework keeps booting. - Contract-version range check via packaging.SpecifierSet. Plugin's manifest.core_version must include the framework's __contract_version__ or load fails per the policy above. - Manifest validation: required fields (name, version, description), name matches directory, JSON parseable. Exceptions (shopdb/exceptions.py): - PluginNotFoundError, PluginContractError, PluginVersionError, PluginDependencyError. Specific types replace generic Exception swallowing. Auto-register core blueprints (shopdb/__init__.py): - CORE_BLUEPRINT_NAMES tuple drives registration. Adding a core resource is one entry, not three lines (import + register call). - Replaces 27 hand-coded register_blueprint calls. - Asserts each blueprint is exported by shopdb.core.api at boot. Public API namespace (shopdb/api/__init__.py): - audit_log: thin wrapper over AuditLog.log() with stable signature. - resolve_asset_position: implements ADR-001 position resolution (asset > related > location). Asset.mapx/mapy and AssetRelationship.inheritsposition columns are part of the locked contract surface but not yet in models; helper degrades gracefully to location-only fallback until the migration lands. BasePlugin helpers (shopdb/plugins/base.py): - get_setting(key, default), set_setting(key, value, ...). Settings namespaced as plugin.<pluginname>.<key> so two plugins can use the same key without colliding. Manifest version compatibility (plugins/*/manifest.json): - Bumped core_version from ">=1.0.0" to ">=0.1.0,<1.0.0" so all bundled plugins satisfy the new range check. Contract version bump (shopdb/__init__.py): - 0.1.0 -> 0.2.0. Additive surface change (Setting helpers, shopdb.api namespace) per ADR-002 minor-bump rules. Tests (tests/test_plugin_loader.py, tests/test_api_namespace.py): - 13 loader tests: manifest validation failures, version range checks, plugin.py import errors, strict-vs-isolate behavior under TESTING vs production-like config, manifest-first dependency sort. - 8 api-namespace tests: audit_log roundtrip, resolve position fallback chain, plugin.get_setting/set_setting roundtrip with per-plugin namespacing. Test count: 66 -> 87 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |