From 534f179ee6c08e6a3ff3735972c1e23429496c23 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sat, 19 Sep 2026 11:24:40 -0400 Subject: [PATCH] Make main the lean product a site receives main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names. --- .dockerignore | 4 - .dockerignore.prebuilt | 22 + .env.example | 61 +- .gitattributes | 6 - .github/workflows/ci.yml | 57 +- .github/workflows/scan-darkwing-portable.yml | 32 - .github/workflows/scan-darkwing-pr.yml | 62 - .gitignore | 1 - Dockerfile | 20 +- Dockerfile.prebuilt | 47 + README.md | 78 +- agent/README.md | 25 +- agent/ShopDbEndpointAgent.Core/AgentConfig.cs | 106 +- .../AgentCredentials.cs | 269 + .../AgentHttpFactory.cs | 387 + .../AgentPipeClient.cs | 180 + .../AgentPipeServer.cs | 562 + .../CollectorClient.cs | 119 +- .../ShopDbEndpointAgent.Core/CommandClient.cs | 31 +- .../CommandExecutor.cs | 1289 +- .../ShopDbEndpointAgent.Core/CommandWorker.cs | 31 +- .../ShopDbEndpointAgent.Core/ConsoleWindow.cs | 51 + agent/ShopDbEndpointAgent.Core/Contracts.cs | 71 + .../CuratedEventCatalog.cs | 8 + .../EndpointCollector.cs | 836 +- .../EnrollmentService.cs | 357 + .../EventWatermarkStore.cs | 132 + .../FileLoggerProvider.cs | 62 +- .../FleetAlertClient.cs | 167 + .../FleetAlertStore.cs | 190 + .../InteractiveProcessLauncher.cs | 133 +- .../ProtectedFileSystem.cs | 285 + .../ScheduledTaskPolicy.cs | 159 + .../ShopDbEndpointAgent.Core.csproj | 9 + .../ShopDbEndpointAgent.Core/StartupJitter.cs | 51 + .../StrictModePreamble.cs | 293 + .../TcpTableReader.cs | 128 + .../TelemetryWorker.cs | 155 +- .../UserContextProbe.cs | 362 + .../UserContextRefreshChannel.cs | 179 + .../UserContextSpool.cs | 389 + .../Program.cs | 51 + .../ShopDbEndpointAgent.PolicyProbe.csproj | 4 + .../FleetAlertForm.cs | 457 + .../FleetAlertWorker.cs | 269 + .../ShopDbEndpointAgent.Tray.csproj | 113 +- agent/ShopDbEndpointAgent.Tray/TrayContext.cs | 444 +- agent/ShopDbEndpointAgent/Program.cs | 281 +- .../ShopDbEndpointAgent.csproj | 249 +- agent/ShopDbEndpointAgent/appsettings.json | 2 + agent/installer/install-shopdbagent.ps1 | 355 - agent/test-fingerprint.ps1 | 76 + agent/utilities/shopdb-dellupdates.ps1 | 119 + deploy/ci/buildkit-remote.yaml | 7 +- deploy/site-profile-universal.json | 7 +- deploy/windows/installer/.gitignore | 6 - deploy/windows/installer/README.md | 247 - deploy/windows/installer/ShopDBFlask.iss | 1340 - deploy/windows/installer/build-installer.ps1 | 301 - deploy/windows/installer/build-installer.sh | 141 - deploy/windows/installer/bundle-lock.json | 240 - deploy/windows/installer/bundle-lock.ps1 | 261 - deploy/windows/installer/make-branding.py | 159 - .../windows/installer/refresh-bundle-lock.ps1 | 110 - deploy/windows/installer/shopdb-admin.ps1 | 1045 - deploy/windows/installer/shopdb-install.ps1 | 2805 - deploy/windows/installer/shopdb-preflight.ps1 | 562 - deploy/windows/installer/shopdb.ico | Bin 52695 -> 0 bytes deploy/windows/installer/swatch-bad.bmp | Bin 822 -> 0 bytes deploy/windows/installer/swatch-ok.bmp | Bin 822 -> 0 bytes deploy/windows/installer/swatch-warn.bmp | Bin 822 -> 0 bytes .../installer/tests/test-install-state.ps1 | 130 - .../windows/installer/verify_bundle_lock.py | 171 - deploy/windows/installer/wizard-image.bmp | Bin 154542 -> 0 bytes deploy/windows/installer/wizard-image@125.bmp | Bin 222390 -> 0 bytes deploy/windows/installer/wizard-image@250.bmp | Bin 889398 -> 0 bytes deploy/windows/installer/wizard-small.bmp | Bin 9294 -> 0 bytes deploy/windows/installer/wizard-small@125.bmp | Bin 12342 -> 0 bytes deploy/windows/installer/wizard-small@250.bmp | Bin 57462 -> 0 bytes deploy/windows/shopdb-diagnose.py | 422 - deploy/windows/web.config | 140 - docker-compose.airgap.yml | 8 +- docker-compose.channel.yml | 2 +- docker-compose.prebuilt.yml | 112 + docker-compose.yml | 8 +- docs/ADOPTING-AT-ANOTHER-SITE.md | 291 + docs/API-REFERENCE.md | 89 + docs/ASSET-COMPOSITION.md | 158 + docs/BACKUP-KINDS.md | 52 + docs/BACKUP-RESTORE.md | 202 + docs/BACKUP-STALENESS.md | 104 + docs/COLLECTOR-AUTH.md | 292 + docs/COLLECTOR-CLIENT-SETUP.md | 147 + docs/COLLECTOR-INTEGRATION.md | 85 + docs/COLLECTOR-PAYLOADS.md | 399 + docs/CONFIG.md | 352 + docs/CONTRACT-STABILITY.md | 149 + docs/CSV-IMPORT.md | 139 + docs/DEPENDENCY-SECURITY.md | 108 + docs/DEPLOY-AIRGAP.md | 180 + docs/DEPLOY.md | 500 + docs/DEV-BUILD-TEST.md | 56 + docs/DEVELOPMENT-SETUP.md | 390 + docs/EVENTSAVER.md | 144 + docs/FLEET-ARCHITECTURE.md | 144 + docs/FLOOR-MAP.md | 137 + docs/GUIDES.md | 237 + docs/IMPORT-ADOPTION.md | 96 + docs/IMPORT-API.md | 283 + docs/IMPORT-LEGACY-ASP.md | 163 + docs/INDEX.md | 178 + docs/PLUGIN-EXTERNAL-REPO.md | 286 + docs/PLUGIN-GUIDE.md | 194 + docs/PLUGIN-HOOKS.md | 866 + docs/PLUGIN-QUICKSTART.md | 193 + docs/PLUGIN-SIGNING.md | 149 + docs/PLUGINS.md | 134 + docs/PRINTER-INSTALLER.md | 140 + docs/PROJECT-MAP.md | 94 + docs/ROADMAP.md | 210 + docs/SECURE-SEND.md | 157 + docs/START-HERE.md | 106 + docs/UPGRADE.md | 150 + .../adr/ADR-001-asset-as-platform-contract.md | 200 + docs/adr/ADR-002-plugin-versioning.md | 68 + docs/adr/ADR-003-plugin-distribution.md | 76 + docs/adr/ADR-004-deployment-topology.md | 100 + .../ADR-005-equipment-vs-measuringtools.md | 163 + docs/adr/ADR-006-collector-contract.md | 156 + ...ADR-007-product-versioning-and-releases.md | 123 + .../adr/ADR-008-plugin-migration-ownership.md | 131 + docs/adr/ADR-009-frontend-plugin-gating.md | 156 + docs/adr/ADR-010-frontend-plugin-hooks.md | 326 + docs/adr/ADR-011-machines-rename.md | 75 + .../ADR-012-geenforce-manifest-ownership.md | 137 + .../ADR-013-plugin-catalog-and-lean-builds.md | 435 + docs/adr/ADR-014-schema-lean-per-site.md | 150 + .../ADR-015-site-specific-configuration.md | 156 + docs/adr/ADR-016-credential-delivery.md | 204 + docs/adr/ADR-017-buildings-and-levels.md | 151 + ...-018-metrology-instruments-are-machines.md | 132 + .../ADR-019-per-pc-endpoint-credentials.md | 217 + docs/adr/README.md | 69 + docs/api-inventory.json | 13107 +++++ docs/deployment/DOCKER-PREBUILT-APPROACH.md | 87 + docs/deployment/PRODUCTION-READINESS.md | 372 + docs/llms.txt | 125 + docs/openapi.json | 48940 ++++++++++------ docs/proposals/README.md | 27 + docs/proposals/dashboard-live-fleet.md | 179 + docs/proposals/device-identity.md | 235 + docs/proposals/printer-assignment.md | 346 + frontend/src/api/index.js | 309 +- frontend/src/assets/style.css | 23 + .../src/components/AssetRelationships.vue | 5 +- frontend/src/components/AssetTriageBand.vue | 2 +- frontend/src/components/EmployeeSearch.vue | 272 - .../src/components/NetworkAllowlistPicker.vue | 129 + frontend/src/components/PluginAssetPanels.vue | 142 +- frontend/src/components/ShopFloorMap.vue | 4 +- .../src/components/dashboardCards.spec.js | 24 +- .../src/components/emptyTablePanel.spec.js | 51 + frontend/src/components/panelGroups.spec.js | 121 + frontend/src/components/pinnedPanels.spec.js | 208 + frontend/src/components/pluginAssetPanels.js | 64 +- .../src/components/pluginAssetPanels.spec.js | 21 +- .../src/components/tabSectionsTable.spec.js | 99 + frontend/src/composables/employeeResolve.js | 2 +- .../src/composables/enabledPlugins.spec.js | 8 +- .../src/composables/identifierSettings.js | 2 +- frontend/src/composables/settingsCatalog.js | 9 +- frontend/src/composables/systemSettings.js | 14 +- frontend/src/main.js | 12 + frontend/src/router/index.js | 8 +- frontend/src/router/routes/core.js | 513 +- frontend/src/stores/auth.js | 4 +- frontend/src/utils/assetRoute.js | 4 +- frontend/src/utils/assetRoute.spec.js | 3 +- frontend/src/utils/assetTypes.js | 3 - frontend/src/utils/codes.js | 8 +- frontend/src/utils/mapColors.js | 5 +- frontend/src/utils/mapColors.spec.js | 7 +- frontend/src/utils/reportTable.spec.js | 2 +- frontend/src/utils/siteSettings.js | 53 +- frontend/src/utils/siteSettings.spec.js | 119 + frontend/src/views/AppLayout.vue | 59 +- frontend/src/views/Dashboard.vue | 14 +- frontend/src/views/MapEditor.vue | 6 +- frontend/src/views/MapView.vue | 5 +- frontend/src/views/SearchResults.vue | 18 +- frontend/src/views/SetupWizard.vue | 46 +- frontend/src/views/print/AssetLabel.vue | 7 +- frontend/src/views/print/AssetLabelBatch.vue | 2 +- frontend/src/views/print/assetLabel.js | 17 +- frontend/src/views/reports/ReportsIndex.vue | 19 +- frontend/src/views/settings/ApiTokensList.vue | 5 + .../src/views/settings/AssetConfigHub.vue | 111 + frontend/src/views/settings/AssetTypeHub.vue | 306 + .../views/settings/AuthenticationSettings.vue | 112 +- .../src/views/settings/IntegrationsHub.vue | 112 + .../src/views/settings/IntegrationsLayout.vue | 90 + frontend/src/views/settings/LocationsHub.vue | 104 + .../src/views/settings/ModelCatalogHub.vue | 152 + frontend/src/views/settings/NetworkHub.vue | 104 + .../src/views/settings/PrintingSettings.vue | 43 - frontend/src/views/settings/SecurityHub.vue | 118 + .../src/views/settings/ServiceNowSettings.vue | 63 +- frontend/src/views/settings/SettingsIndex.vue | 5 +- .../src/views/settings/SettingsLayout.vue | 4 +- .../src/views/settings/ShopdbBackupsList.vue | 864 + frontend/src/views/settings/SiteConfigHub.vue | 111 + frontend/src/views/settings/SiteSettings.vue | 10 +- frontend/src/views/settings/UsersList.vue | 223 +- .../settings/asset-types/SubtypesTab.vue | 303 + .../settings/asset-types/SuppliesTab.vue | 29 + .../settings/asset-types/TypesTabWrapper.vue | 45 + frontend/src/views/settings/settingsNav.js | 62 +- .../settings/types/NotificationTypesTab.vue | 286 + .../FIX_LOCATIONONLY_EQUIPMENT_TYPES.md | 89 - .../MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md | 92 - migrations/PRODUCTION_MIGRATION_GUIDE.md | 165 - .../versions/7d42_split_endpoints_deploy.py | 151 + migrations/versions/7d43_userpermissions.py | 59 + .../versions/7d44_application_monitorable.py | 42 + .../versions/7d45_purge_inactive_apps.py | 86 + migrations/versions/7d46_nest_app_packages.py | 136 + .../7d47_endpoints_operatorreports.py | 97 + .../versions/7d48_script_workingdirectory.py | 41 + migrations/versions/7d49_script_errormode.py | 46 + .../versions/7d50_script_captureoutput.py | 48 + .../versions/7d51_endpoint_logcollections.py | 92 + .../frontend/views/ApplicationDetail.vue | 17 +- .../frontend/views/ApplicationForm.vue | 283 +- .../frontend/views/ApplicationsList.vue | 48 +- plugins/backups/models/backup.py | 2 +- plugins/computers/api/policy_routes.py | 163 +- .../computers/client/Report-AssetToShopDB.ps1 | 325 +- .../computers/client/Set-ShopDBDeviceId.ps1 | 264 + .../computers/frontend/views/ActionPicker.vue | 9 + .../views/ConfigurationPoliciesList.vue | 24 +- plugins/computers/frontend/views/PCDetail.vue | 2808 +- plugins/computers/frontend/views/PCForm.vue | 12 +- .../frontend/views/PCTypeMappingSettings.vue | 12 +- .../computers/frontend/views/PCTypesList.vue | 5 +- plugins/computers/frontend/views/PCsList.vue | 2 + .../frontend/views/ScopeNodeEditor.vue | 11 +- .../views/configurationPolicyEditable.spec.js | 27 + .../frontend/views/pcDetailCredential.spec.js | 58 + .../frontend/views/pcDetailRunScripts.spec.js | 95 + .../migrations/versions/0004_policies.py | 2 +- .../versions/0005_policyactionholds.py | 68 + .../0006_policyactionhold_indefinite.py | 85 + plugins/computers/models/__init__.py | 6 + plugins/computers/models/jsontext.py | 2 +- plugins/computers/models/policy.py | 110 +- plugins/computers/plugin.py | 41 +- plugins/computers/services/apppolicy.py | 202 + plugins/computers/services/policyresolver.py | 66 +- plugins/computers/services/policyscope.py | 29 +- plugins/employees/README.md | 133 - plugins/employees/__init__.py | 5 - plugins/employees/api/__init__.py | 5 - plugins/employees/api/routes.py | 682 - plugins/employees/frontend/routes.js | 23 - .../frontend/views/EmployeeDetail.vue | 364 - .../frontend/views/EmployeeDirectory.vue | 303 - plugins/employees/manifest.json | 15 - plugins/employees/migrations/env.py | 14 - plugins/employees/migrations/script.py.mako | 24 - .../versions/0001_employees_anchor.py | 28 - .../versions/0002_employee_photofilename.py | 40 - plugins/employees/models/__init__.py | 5 - .../employees/models/directory_employee.py | 43 - plugins/employees/plugin.py | 115 - plugins/endpoints/ADOPTION-TOOL-SPIKE.md | 77 - plugins/endpoints/AGENT-CONTRACT.md | 698 +- plugins/endpoints/DELIVERY-TESTING-notes.md | 112 - plugins/endpoints/api/__init__.py | 5 + plugins/endpoints/api/agentauth.py | 293 + plugins/endpoints/api/agentregistry.py | 127 + plugins/endpoints/api/agentstore.py | 107 + plugins/endpoints/api/agentupgradescript.py | 144 + plugins/endpoints/api/agentupload.py | 85 +- plugins/endpoints/api/bootstrap.py | 786 + plugins/endpoints/api/enrollment.py | 1369 + plugins/endpoints/api/ratelimit.py | 52 + plugins/endpoints/api/routes.py | 3108 +- plugins/endpoints/api/siteagent.py | 772 +- .../api/templates/upgrade-shopdbagent.ps1 | 658 + .../endpoints/client/Get-ShopDBServerPin.ps1 | 157 + .../Install-ShopDBServerCertificate.ps1 | 194 + .../endpoints/client/Set-ShopDBServerPins.ps1 | 212 + plugins/endpoints/commands.py | 511 +- .../docs/PER-PC-CREDENTIALS-ROLLOUT.md | 477 + plugins/endpoints/eventcatalog.py | 16 +- plugins/endpoints/frontend/routes.js | 38 +- .../frontend/views/AgentKeySettings.vue | 628 +- .../frontend/views/DeliveryTestBench.vue | 327 - .../frontend/views/EndpointsList.vue | 104 +- .../frontend/views/EnrollmentSettings.vue | 1254 + .../frontend/views/ServerCertSettings.vue | 640 + .../frontend/views/SystemScriptsSettings.vue | 443 + .../views/agentBootstrapInstall.spec.js | 210 + .../frontend/views/agentKeySettings.spec.js | 133 + .../frontend/views/endpointsList.spec.js | 99 + .../frontend/views/enrollmentSettings.spec.js | 345 + .../frontend/views/serverCertSettings.spec.js | 98 + .../views/systemScriptsSettings.spec.js | 124 + plugins/endpoints/manifest.json | 2 +- .../versions/0006_endpoint_monitors.py | 5 +- .../versions/0007_shipped_monitor_policy.py | 22 +- .../versions/0012_endpoint_eventrecordid.py | 80 + .../0013_endpoint_inventory_columns.py | 93 + .../versions/0014_endpoint_diskhealth.py | 56 + .../0015_endpoint_system_telemetry.py | 78 + .../versions/0016_endpoint_dellupdates.py | 51 + .../versions/0017_endpoint_health_rollup.py | 133 + .../versions/0018_endpoint_locks.py | 87 + .../0019_split_shipped_monitor_policies.py | 320 + .../versions/0020_app_delta_fingerprints.py | 90 + .../versions/0021_client_certificates.py | 26 + .../versions/0022_client_cert_revocation.py | 24 + .../versions/0023_endpoint_credentials.py | 181 + .../0024_endpoint_bootstrap_tokens.py | 65 + plugins/endpoints/models/__init__.py | 12 + plugins/endpoints/models/bootstrap.py | 78 + plugins/endpoints/models/credential.py | 165 + plugins/endpoints/models/endpoint.py | 334 +- plugins/endpoints/models/health.py | 267 + plugins/endpoints/plugin.py | 929 +- plugins/endpoints/services/health.py | 416 + plugins/geenforce/__init__.py | 5 - plugins/geenforce/api/__init__.py | 5 - plugins/geenforce/api/routes.py | 1397 - .../geenforce/client/Install-GEEnforce.ps1 | 180 - .../geenforce/client/Invoke-ShopdbEnforce.ps1 | 227 - .../geenforce/client/Invoke-ShopdbShadow.ps1 | 128 - .../geenforce/client/ShopdbEnforceClient.psm1 | 480 - plugins/geenforce/filters.py | 144 - plugins/geenforce/frontend/routes.js | 43 - .../frontend/views/EnforcementReports.vue | 296 - .../frontend/views/GeEnforceLayout.vue | 46 - .../frontend/views/GeEnforceSettings.vue | 142 - .../frontend/views/ManifestEditor.vue | 1074 - plugins/geenforce/frontend/views/entryForm.js | 183 - .../frontend/views/entryForm.spec.js | 321 - plugins/geenforce/importer.py | 124 - plugins/geenforce/manifest.json | 14 - plugins/geenforce/migrations/env.py | 14 - plugins/geenforce/migrations/script.py.mako | 24 - .../versions/0001_geenforce_baseline.py | 246 - .../versions/0002_manifest_blobs.py | 47 - .../versions/0003_report_subtype.py | 46 - .../versions/0004_scope_minlibversion.py | 49 - plugins/geenforce/models/__init__.py | 46 - plugins/geenforce/models/manifest.py | 385 - plugins/geenforce/parity.py | 106 - plugins/geenforce/parityfixtures.json | 20 - plugins/geenforce/plugin.py | 299 - plugins/geenforce/seed_display_scope.py | 943 - plugins/geenforce/serializer.py | 142 - plugins/geenforce/service.py | 613 - plugins/guides/models/guide.py | 2 +- plugins/knowledgebase/api/routes.py | 25 +- .../machines/frontend/views/MachineForm.vue | 8 +- plugins/machines/models/machine.py | 2 +- plugins/machines/plugin.py | 13 + plugins/measuringtools/__init__.py | 5 - plugins/measuringtools/api/__init__.py | 5 - plugins/measuringtools/api/routes.py | 436 - plugins/measuringtools/frontend/routes.js | 59 - .../frontend/views/CalibrationReport.vue | 101 - .../frontend/views/MeasuringToolDetail.vue | 278 - .../frontend/views/MeasuringToolForm.vue | 295 - .../frontend/views/MeasuringToolTypesList.vue | 157 - .../frontend/views/MeasuringToolsList.vue | 195 - plugins/measuringtools/manifest.json | 14 - plugins/measuringtools/migrations/env.py | 16 - .../measuringtools/migrations/script.py.mako | 24 - .../versions/0001_measuringtools_baseline.py | 74 - plugins/measuringtools/models/__init__.py | 17 - .../measuringtools/models/measuringtool.py | 133 - plugins/measuringtools/plugin.py | 182 - plugins/network/frontend/routes.js | 12 + .../network/frontend/views/SubnetDetail.vue | 2 +- plugins/network/plugin.py | 13 + plugins/notifications/api/routes.py | 89 +- .../frontend/views/NotificationForm.vue | 81 +- .../versions/0006_add_fleet_alerts.py | 102 + plugins/notifications/models/__init__.py | 3 +- .../notifications/models/acknowledgement.py | 52 + plugins/notifications/models/notification.py | 57 +- plugins/notifications/plugin.py | 22 +- plugins/printedparts/README.md | 59 - plugins/printedparts/__init__.py | 5 - plugins/printedparts/api/__init__.py | 5 - plugins/printedparts/api/routes.py | 891 - plugins/printedparts/frontend-api-snippet.js | 35 - plugins/printedparts/frontend/routes.js | 58 - .../frontend/views/PartsKiosk.vue | 394 - .../frontend/views/PrintedItemDetail.vue | 379 - .../frontend/views/PrintedItemForm.vue | 175 - .../frontend/views/PrintedItemsList.vue | 169 - .../frontend/views/PrintedPartsLabels.vue | 214 - .../frontend/views/PrintedPartsSettings.vue | 268 - plugins/printedparts/manifest.json | 11 - plugins/printedparts/migrations/env.py | 14 - .../printedparts/migrations/script.py.mako | 24 - .../versions/0001_printedparts_baseline.py | 76 - .../versions/0002_printeditemfiles.py | 48 - .../migrations/versions/0003_gagelabtag.py | 54 - .../versions/0004_transaction_revision.py | 43 - plugins/printedparts/models/__init__.py | 11 - plugins/printedparts/models/printeditem.py | 152 - plugins/printedparts/plugin.py | 215 - plugins/printedparts/schemas/__init__.py | 6 - plugins/printedparts/services/__init__.py | 1 - plugins/printedparts/services/badges.py | 121 - plugins/printers/api/asset_routes.py | 19 +- .../client/Report-PrintersToShopDB.ps1 | 331 +- plugins/printers/frontend/routes.js | 8 +- .../frontend/views/ZabbixSettings.vue | 63 +- plugins/printers/plugin.py | 21 +- plugins/printers/services/supply_alerts.py | 2 +- plugins/slides/PLAN-slide-manager.md | 132 - .../tools/frontend/views/CodeGenerator.vue | 15 +- plugins/usb/README.md | 105 - plugins/usb/__init__.py | 5 - plugins/usb/api/__init__.py | 5 - plugins/usb/api/routes.py | 1227 - plugins/usb/api/selfhosted.py | 588 - plugins/usb/cli.py | 235 - plugins/usb/frontend/drivefilter.js | 29 - plugins/usb/frontend/drivefilter.spec.js | 62 - plugins/usb/frontend/routes.js | 54 - plugins/usb/frontend/views/USBDetail.vue | 288 - plugins/usb/frontend/views/USBForm.vue | 149 - plugins/usb/frontend/views/USBKiosk.vue | 510 - plugins/usb/frontend/views/USBLabelBatch.vue | 417 - plugins/usb/frontend/views/USBList.vue | 317 - plugins/usb/frontend/views/USBSettings.vue | 152 - plugins/usb/manifest.json | 11 - plugins/usb/migrations/env.py | 14 - plugins/usb/migrations/script.py.mako | 24 - .../migrations/versions/0001_usb_anchor.py | 28 - .../versions/0002_drop_checkout_machineid.py | 42 - .../versions/0003_checkout_scan_fields.py | 49 - .../versions/0004_checkin_identity.py | 59 - plugins/usb/models/__init__.py | 5 - plugins/usb/models/usb_device.py | 178 - plugins/usb/plugin.py | 218 - plugins/warranty/frontend/routes.js | 6 + .../frontend/views/DellWarrantySettings.vue | 63 +- .../frontend/views/WarrantiesList.vue | 2 +- .../frontend/views/WarrantyReport.vue | 2 +- pytest.ini | 16 +- scripts/backfill_app_fingerprints.py | 251 + scripts/backup/.gitattributes | 4 + scripts/backup/Dockerfile.backup | 25 + scripts/backup/README.md | 97 + scripts/backup/SIMULATE-RESTORE.README.md | 44 + .../__init__.py => backup/_probe_manifest.py} | 0 scripts/backup/backup.sh | 171 + scripts/backup/manifest.py | 132 + .../restic_0.17.3_linux_amd64.bz2 | Bin 0 -> 8944565 bytes scripts/backup/restore.sh | 133 + scripts/backup/simulate-restore-results.txt | 16 + scripts/backup/simulate-restore.sh | 235 + scripts/backup/status.sh | 100 + scripts/backup/test-restore-datarev.sh | 172 + scripts/backup/watch.sh | 61 + scripts/bootstrap-plugins.sh | 28 + scripts/build-site.sh | 18 +- scripts/check-gates-ran.sh | 65 +- scripts/check-naming-and-style.sh | 384 +- scripts/dev-bootstrap.ps1 | 2 +- scripts/docker-entrypoint.sh | 36 + scripts/export_catalog.py | 3 - scripts/gen_openapi.py | 1525 +- scripts/gen_project_map.py | 17 +- scripts/generate_sbom.py | 4 +- scripts/loadtest/Dockerfile.gevent | 9 + scripts/loadtest/burst_arrival.py | 219 + scripts/loadtest/force_pool.py | 22 + scripts/loadtest/loadtest_token.py | 62 + scripts/loadtest/mixed_workload.py | 218 + scripts/loadtest/poll_fleet.py | 141 + scripts/loadtest/pool_status.py | 42 + scripts/loadtest/sample_dbconns.py | 68 + scripts/loadtest/sample_resources.py | 80 + scripts/loadtest/ui_probe.py | 78 + scripts/migration/README.md | 5 +- scripts/resolve_plugin_closure.py | 10 +- scripts/site_imports/wjf/PILOT-DEPLOY.md | 219 - scripts/site_imports/wjf/README.md | 66 - scripts/site_imports/wjf/harness.py | 306 - scripts/site_imports/wjf/run.py | 1032 - scripts/uninstall-shopdbagent.ps1 | 37 +- shopdb/__init__.py | 88 +- shopdb/api/__init__.py | 118 +- shopdb/cli/__init__.py | 128 +- shopdb/config.py | 92 +- shopdb/core/api/__init__.py | 4 + shopdb/core/api/apitokens.py | 6 +- shopdb/core/api/applications.py | 466 +- shopdb/core/api/assets.py | 27 +- shopdb/core/api/auditlogs.py | 23 +- shopdb/core/api/collector.py | 145 +- shopdb/core/api/dashboard.py | 11 +- shopdb/core/api/docs.py | 153 +- shopdb/core/api/employees.py | 278 + shopdb/core/api/models.py | 27 + shopdb/core/api/plugins.py | 53 + shopdb/core/api/pluginui.py | 110 +- shopdb/core/api/scripts.py | 172 +- shopdb/core/api/search.py | 176 +- shopdb/core/api/settings.py | 232 +- shopdb/core/api/setup.py | 62 +- shopdb/core/api/shopdbbackups.py | 745 + .../core/api/staticdocs/redoc.standalone.js | 2 +- .../api/staticdocs/swagger-ui-bundle.min.js | 2 + .../api/staticdocs/swagger-ui.LICENSE.txt | 202 + shopdb/core/api/staticdocs/swagger-ui.css | 2 + shopdb/core/api/supportteams.py | 28 + shopdb/core/api/users.py | 158 +- shopdb/core/models/__init__.py | 15 +- shopdb/core/models/apitoken.py | 8 +- shopdb/core/models/application.py | 14 + shopdb/core/models/asset.py | 5 - shopdb/core/models/dashboarddefault.py | 13 +- shopdb/core/models/script_library.py | 50 + shopdb/core/models/user.py | 270 +- shopdb/core/services/csvimport.py | 2 +- shopdb/data/catalog.json | 69 - shopdb/plugins/__init__.py | 91 +- shopdb/plugins/alembic_template.py | 56 +- shopdb/plugins/base.py | 176 +- shopdb/plugins/cli.py | 19 +- shopdb/plugins/loader.py | 23 + shopdb/plugins/manifest_schema.json | 4 +- shopdb/utils/apitoken_auth.py | 5 +- shopdb/utils/authz.py | 36 +- shopdb/utils/badges.py | 18 +- shopdb/utils/cmmc_usb_db.py | 38 - shopdb/utils/import_mode.py | 3 +- shopdb/utils/ipallowlist.py | 64 + tests/test_api_inventory_parity.py | 172 +- tests/test_backfill_app_fingerprints.py | 117 + tests/test_bundle_lock.py | 231 - tests/test_core/test_api_docs_tryit.py | 216 + tests/test_core/test_apitokens.py | 56 + .../test_core/test_application_hard_delete.py | 139 + .../test_core/test_application_monitorable.py | 142 + .../test_application_required_policy.py | 110 + .../test_application_required_policy_sync.py | 109 + tests/test_core/test_application_revert.py | 196 + tests/test_core/test_application_uploads.py | 12 +- .../test_application_version_detection.py | 96 + .../test_asset_type_descriptor_enforcement.py | 79 + tests/test_core/test_authz.py | 82 +- tests/test_core/test_bad_query_args.py | 16 +- tests/test_core/test_collector_contract.py | 112 +- .../test_collector_legacy_sharedkey.py | 143 + tests/test_core/test_dashboard_widgets.py | 4 +- tests/test_core/test_dashboarddefaults.py | 11 +- tests/test_core/test_employees.py | 107 - tests/test_core/test_import_mode.py | 58 - .../test_core/test_list_search_identifiers.py | 3 +- tests/test_core/test_location_code.py | 2 +- .../test_migration_nest_app_packages.py | 156 + tests/test_core/test_permissions_catalog.py | 2 +- tests/test_core/test_plugin_settings_hubs.py | 160 + .../test_core/test_plugin_settings_routes.py | 47 + tests/test_core/test_pluginui.py | 76 +- tests/test_core/test_pluginui_panel_groups.py | 44 + tests/test_core/test_public_read_surface.py | 40 +- tests/test_core/test_script_captureoutput.py | 129 + tests/test_core/test_script_errormode.py | 138 + tests/test_core/test_script_library_gate.py | 160 + .../test_core/test_script_workingdirectory.py | 149 + tests/test_core/test_search_coverage.py | 106 +- tests/test_core/test_search_presentation.py | 29 +- tests/test_core/test_security_headers.py | 5 + tests/test_core/test_settings_branding.py | 8 +- .../test_settings_plugin_declared.py | 86 +- .../test_settings_switches_reachable.py | 228 + .../test_core/test_shopdbbackups_finalize.py | 242 + tests/test_core/test_user_scopes.py | 298 + tests/test_core/test_users_api.py | 82 + tests/test_csv_import.py | 6 +- tests/test_docs_index.py | 393 + tests/test_docs_publishable.py | 18 +- tests/test_docs_versions.py | 9 +- tests/test_installer_defaults.py | 106 - tests/test_installer_state.py | 106 - tests/test_naming_gate_migrations.py | 295 + tests/test_no_hidden_tests.py | 12 +- tests/test_openapi_examples.py | 637 + tests/test_openapi_spec.py | 139 +- tests/test_plugin_contract.py | 2 +- tests/test_plugin_importguard.py | 7 +- tests/test_plugin_migrations.py | 784 +- tests/test_plugin_prune_schema.py | 41 +- tests/test_plugin_settings_reachable.py | 8 +- tests/test_plugins/test_collector_deviceid.py | 74 +- .../test_collector_measuringtool_adoption.py | 232 - .../test_collector_partmarker_links.py | 2 +- .../test_computers_policy_allpcs.py | 87 + .../test_computers_policy_authoring_gate.py | 181 + tests/test_plugins/test_employee_photo.py | 212 - tests/test_plugins/test_employees_resolve.py | 68 - .../test_endpoints_agent_registration.py | 250 + .../test_endpoints_agent_upgrade_route.py | 329 + .../test_endpoints_agent_version_guard.py | 158 + .../test_endpoints_agentupload.py | 90 + .../test_endpoints_app_conflict.py | 165 + .../test_plugins/test_endpoints_app_delta.py | 377 + .../test_endpoints_bootstrap_install.py | 677 + .../test_endpoints_collect_log_denylist.py | 107 + .../test_endpoints_credential_admin_ui.py | 525 + .../test_endpoints_credentials.py | 935 + .../test_endpoints_credentials_migration.py | 140 + .../test_endpoints_dellupdates.py | 168 + .../test_endpoints_disk_health.py | 108 + .../test_endpoints_enroll_limits.py | 220 + .../test_endpoints_enrollment_denials.py | 352 + .../test_endpoints_enrollment_migration.py | 485 + .../test_endpoints_fleet_alert_scheduling.py | 114 + .../test_endpoints_fleet_alerts.py | 504 + .../test_endpoints_fleet_alias.py | 110 + tests/test_plugins/test_endpoints_health.py | 320 + .../test_endpoints_health_savepoint.py | 53 + .../test_plugins/test_endpoints_ip_history.py | 138 + .../test_plugins/test_endpoints_link_label.py | 49 + .../test_endpoints_mapped_drives.py | 186 + .../test_endpoints_mapped_drives_card.py | 118 + .../test_endpoints_operator_reports.py | 106 +- .../test_endpoints_panel_groups.py | 74 + tests/test_plugins/test_endpoints_plugin.py | 176 +- .../test_endpoints_policy_override.py | 923 + .../test_endpoints_script_runs.py | 200 + .../test_endpoints_server_certificate.py | 157 + .../test_endpoints_settings_declared.py | 162 + .../test_endpoints_shipped_monitor_policy.py | 141 - .../test_endpoints_siteagent_build.py | 353 + .../test_endpoints_split_monitor_policies.py | 224 + .../test_endpoints_storage_sections.py | 51 + .../test_geenforce_blob_parity.py | 108 - .../test_plugins/test_geenforce_compliance.py | 166 - tests/test_plugins/test_geenforce_crud.py | 278 - .../test_plugins/test_geenforce_dashboard.py | 123 - .../test_plugins/test_geenforce_ddl_parity.py | 143 - .../test_geenforce_display_seed.py | 299 - tests/test_plugins/test_geenforce_export.py | 123 - .../test_geenforce_httppayload.py | 91 - tests/test_plugins/test_geenforce_manifest.py | 195 - tests/test_plugins/test_geenforce_payload.py | 125 - .../test_geenforce_payload_hardening.py | 161 - .../test_geenforce_publish_gate.py | 318 - .../test_geenforce_report_contract.py | 137 - .../test_plugins/test_geenforce_reporting.py | 565 - .../test_geenforce_resource_binding.py | 128 - .../test_list_search_type_columns.py | 27 - tests/test_plugins/test_measuringtools.py | 646 - .../test_model_image_in_detail.py | 2 +- .../test_notifications_alertscope_storage.py | 93 + .../test_notifications_fleet_alert_fields.py | 101 + .../test_plugins/test_printedparts_ledger.py | 654 - .../test_plugins/test_printedparts_plugin.py | 30 - tests/test_plugins/test_shopfloor_feed.py | 24 +- tests/test_plugins/test_usb_import_pairing.py | 99 - tests/test_plugins/test_usb_kiosk.py | 439 - tests/test_plugins/test_usb_reports.py | 139 - tests/test_site_import_loader.py | 164 - tests/test_smoke.py | 2 - 675 files changed, 115759 insertions(+), 60239 deletions(-) create mode 100644 .dockerignore.prebuilt delete mode 100644 .github/workflows/scan-darkwing-portable.yml delete mode 100644 .github/workflows/scan-darkwing-pr.yml create mode 100644 Dockerfile.prebuilt create mode 100644 agent/ShopDbEndpointAgent.Core/AgentCredentials.cs create mode 100644 agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs create mode 100644 agent/ShopDbEndpointAgent.Core/AgentPipeClient.cs create mode 100644 agent/ShopDbEndpointAgent.Core/AgentPipeServer.cs create mode 100644 agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs create mode 100644 agent/ShopDbEndpointAgent.Core/EnrollmentService.cs create mode 100644 agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs create mode 100644 agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs create mode 100644 agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs create mode 100644 agent/ShopDbEndpointAgent.Core/ProtectedFileSystem.cs create mode 100644 agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs create mode 100644 agent/ShopDbEndpointAgent.Core/StartupJitter.cs create mode 100644 agent/ShopDbEndpointAgent.Core/StrictModePreamble.cs create mode 100644 agent/ShopDbEndpointAgent.Core/TcpTableReader.cs create mode 100644 agent/ShopDbEndpointAgent.Core/UserContextProbe.cs create mode 100644 agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs create mode 100644 agent/ShopDbEndpointAgent.Core/UserContextSpool.cs create mode 100644 agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs create mode 100644 agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs delete mode 100644 agent/installer/install-shopdbagent.ps1 create mode 100644 agent/test-fingerprint.ps1 create mode 100644 agent/utilities/shopdb-dellupdates.ps1 delete mode 100644 deploy/windows/installer/.gitignore delete mode 100644 deploy/windows/installer/README.md delete mode 100644 deploy/windows/installer/ShopDBFlask.iss delete mode 100644 deploy/windows/installer/build-installer.ps1 delete mode 100755 deploy/windows/installer/build-installer.sh delete mode 100644 deploy/windows/installer/bundle-lock.json delete mode 100644 deploy/windows/installer/bundle-lock.ps1 delete mode 100644 deploy/windows/installer/make-branding.py delete mode 100644 deploy/windows/installer/refresh-bundle-lock.ps1 delete mode 100644 deploy/windows/installer/shopdb-admin.ps1 delete mode 100644 deploy/windows/installer/shopdb-install.ps1 delete mode 100644 deploy/windows/installer/shopdb-preflight.ps1 delete mode 100644 deploy/windows/installer/shopdb.ico delete mode 100644 deploy/windows/installer/swatch-bad.bmp delete mode 100644 deploy/windows/installer/swatch-ok.bmp delete mode 100644 deploy/windows/installer/swatch-warn.bmp delete mode 100644 deploy/windows/installer/tests/test-install-state.ps1 delete mode 100644 deploy/windows/installer/verify_bundle_lock.py delete mode 100644 deploy/windows/installer/wizard-image.bmp delete mode 100644 deploy/windows/installer/wizard-image@125.bmp delete mode 100644 deploy/windows/installer/wizard-image@250.bmp delete mode 100644 deploy/windows/installer/wizard-small.bmp delete mode 100644 deploy/windows/installer/wizard-small@125.bmp delete mode 100644 deploy/windows/installer/wizard-small@250.bmp delete mode 100644 deploy/windows/shopdb-diagnose.py delete mode 100644 deploy/windows/web.config create mode 100644 docker-compose.prebuilt.yml create mode 100644 docs/ADOPTING-AT-ANOTHER-SITE.md create mode 100644 docs/API-REFERENCE.md create mode 100644 docs/ASSET-COMPOSITION.md create mode 100644 docs/BACKUP-KINDS.md create mode 100644 docs/BACKUP-RESTORE.md create mode 100644 docs/BACKUP-STALENESS.md create mode 100644 docs/COLLECTOR-AUTH.md create mode 100644 docs/COLLECTOR-CLIENT-SETUP.md create mode 100644 docs/COLLECTOR-INTEGRATION.md create mode 100644 docs/COLLECTOR-PAYLOADS.md create mode 100644 docs/CONFIG.md create mode 100644 docs/CONTRACT-STABILITY.md create mode 100644 docs/CSV-IMPORT.md create mode 100644 docs/DEPENDENCY-SECURITY.md create mode 100644 docs/DEPLOY-AIRGAP.md create mode 100644 docs/DEPLOY.md create mode 100644 docs/DEV-BUILD-TEST.md create mode 100644 docs/DEVELOPMENT-SETUP.md create mode 100644 docs/EVENTSAVER.md create mode 100644 docs/FLEET-ARCHITECTURE.md create mode 100644 docs/FLOOR-MAP.md create mode 100644 docs/GUIDES.md create mode 100644 docs/IMPORT-ADOPTION.md create mode 100644 docs/IMPORT-API.md create mode 100644 docs/IMPORT-LEGACY-ASP.md create mode 100644 docs/INDEX.md create mode 100644 docs/PLUGIN-EXTERNAL-REPO.md create mode 100644 docs/PLUGIN-GUIDE.md create mode 100644 docs/PLUGIN-HOOKS.md create mode 100644 docs/PLUGIN-QUICKSTART.md create mode 100644 docs/PLUGIN-SIGNING.md create mode 100644 docs/PLUGINS.md create mode 100644 docs/PRINTER-INSTALLER.md create mode 100644 docs/PROJECT-MAP.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/SECURE-SEND.md create mode 100644 docs/START-HERE.md create mode 100644 docs/UPGRADE.md create mode 100644 docs/adr/ADR-001-asset-as-platform-contract.md create mode 100644 docs/adr/ADR-002-plugin-versioning.md create mode 100644 docs/adr/ADR-003-plugin-distribution.md create mode 100644 docs/adr/ADR-004-deployment-topology.md create mode 100644 docs/adr/ADR-005-equipment-vs-measuringtools.md create mode 100644 docs/adr/ADR-006-collector-contract.md create mode 100644 docs/adr/ADR-007-product-versioning-and-releases.md create mode 100644 docs/adr/ADR-008-plugin-migration-ownership.md create mode 100644 docs/adr/ADR-009-frontend-plugin-gating.md create mode 100644 docs/adr/ADR-010-frontend-plugin-hooks.md create mode 100644 docs/adr/ADR-011-machines-rename.md create mode 100644 docs/adr/ADR-012-geenforce-manifest-ownership.md create mode 100644 docs/adr/ADR-013-plugin-catalog-and-lean-builds.md create mode 100644 docs/adr/ADR-014-schema-lean-per-site.md create mode 100644 docs/adr/ADR-015-site-specific-configuration.md create mode 100644 docs/adr/ADR-016-credential-delivery.md create mode 100644 docs/adr/ADR-017-buildings-and-levels.md create mode 100644 docs/adr/ADR-018-metrology-instruments-are-machines.md create mode 100644 docs/adr/ADR-019-per-pc-endpoint-credentials.md create mode 100644 docs/adr/README.md create mode 100644 docs/api-inventory.json create mode 100644 docs/deployment/DOCKER-PREBUILT-APPROACH.md create mode 100644 docs/deployment/PRODUCTION-READINESS.md create mode 100644 docs/llms.txt create mode 100644 docs/proposals/README.md create mode 100644 docs/proposals/dashboard-live-fleet.md create mode 100644 docs/proposals/device-identity.md create mode 100644 docs/proposals/printer-assignment.md delete mode 100644 frontend/src/components/EmployeeSearch.vue create mode 100644 frontend/src/components/NetworkAllowlistPicker.vue create mode 100644 frontend/src/components/emptyTablePanel.spec.js create mode 100644 frontend/src/components/panelGroups.spec.js create mode 100644 frontend/src/components/pinnedPanels.spec.js create mode 100644 frontend/src/components/tabSectionsTable.spec.js create mode 100644 frontend/src/utils/siteSettings.spec.js create mode 100644 frontend/src/views/settings/AssetConfigHub.vue create mode 100644 frontend/src/views/settings/AssetTypeHub.vue create mode 100644 frontend/src/views/settings/IntegrationsHub.vue create mode 100644 frontend/src/views/settings/IntegrationsLayout.vue create mode 100644 frontend/src/views/settings/LocationsHub.vue create mode 100644 frontend/src/views/settings/ModelCatalogHub.vue create mode 100644 frontend/src/views/settings/NetworkHub.vue create mode 100644 frontend/src/views/settings/SecurityHub.vue create mode 100644 frontend/src/views/settings/ShopdbBackupsList.vue create mode 100644 frontend/src/views/settings/SiteConfigHub.vue create mode 100644 frontend/src/views/settings/asset-types/SubtypesTab.vue create mode 100644 frontend/src/views/settings/asset-types/SuppliesTab.vue create mode 100644 frontend/src/views/settings/asset-types/TypesTabWrapper.vue create mode 100644 frontend/src/views/settings/types/NotificationTypesTab.vue delete mode 100644 migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md delete mode 100644 migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md delete mode 100644 migrations/PRODUCTION_MIGRATION_GUIDE.md create mode 100644 migrations/versions/7d42_split_endpoints_deploy.py create mode 100644 migrations/versions/7d43_userpermissions.py create mode 100644 migrations/versions/7d44_application_monitorable.py create mode 100644 migrations/versions/7d45_purge_inactive_apps.py create mode 100644 migrations/versions/7d46_nest_app_packages.py create mode 100644 migrations/versions/7d47_endpoints_operatorreports.py create mode 100644 migrations/versions/7d48_script_workingdirectory.py create mode 100644 migrations/versions/7d49_script_errormode.py create mode 100644 migrations/versions/7d50_script_captureoutput.py create mode 100644 migrations/versions/7d51_endpoint_logcollections.py create mode 100644 plugins/computers/client/Set-ShopDBDeviceId.ps1 create mode 100644 plugins/computers/frontend/views/configurationPolicyEditable.spec.js create mode 100644 plugins/computers/frontend/views/pcDetailCredential.spec.js create mode 100644 plugins/computers/frontend/views/pcDetailRunScripts.spec.js create mode 100644 plugins/computers/migrations/versions/0005_policyactionholds.py create mode 100644 plugins/computers/migrations/versions/0006_policyactionhold_indefinite.py create mode 100644 plugins/computers/services/apppolicy.py delete mode 100644 plugins/employees/README.md delete mode 100644 plugins/employees/__init__.py delete mode 100644 plugins/employees/api/__init__.py delete mode 100644 plugins/employees/api/routes.py delete mode 100644 plugins/employees/frontend/routes.js delete mode 100644 plugins/employees/frontend/views/EmployeeDetail.vue delete mode 100644 plugins/employees/frontend/views/EmployeeDirectory.vue delete mode 100644 plugins/employees/manifest.json delete mode 100644 plugins/employees/migrations/env.py delete mode 100644 plugins/employees/migrations/script.py.mako delete mode 100644 plugins/employees/migrations/versions/0001_employees_anchor.py delete mode 100644 plugins/employees/migrations/versions/0002_employee_photofilename.py delete mode 100644 plugins/employees/models/__init__.py delete mode 100644 plugins/employees/models/directory_employee.py delete mode 100644 plugins/employees/plugin.py delete mode 100644 plugins/endpoints/ADOPTION-TOOL-SPIKE.md delete mode 100644 plugins/endpoints/DELIVERY-TESTING-notes.md create mode 100644 plugins/endpoints/api/agentauth.py create mode 100644 plugins/endpoints/api/agentregistry.py create mode 100644 plugins/endpoints/api/agentupgradescript.py create mode 100644 plugins/endpoints/api/bootstrap.py create mode 100644 plugins/endpoints/api/enrollment.py create mode 100644 plugins/endpoints/api/ratelimit.py create mode 100644 plugins/endpoints/api/templates/upgrade-shopdbagent.ps1 create mode 100644 plugins/endpoints/client/Get-ShopDBServerPin.ps1 create mode 100644 plugins/endpoints/client/Install-ShopDBServerCertificate.ps1 create mode 100644 plugins/endpoints/client/Set-ShopDBServerPins.ps1 create mode 100644 plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md delete mode 100644 plugins/endpoints/frontend/views/DeliveryTestBench.vue create mode 100644 plugins/endpoints/frontend/views/EnrollmentSettings.vue create mode 100644 plugins/endpoints/frontend/views/ServerCertSettings.vue create mode 100644 plugins/endpoints/frontend/views/SystemScriptsSettings.vue create mode 100644 plugins/endpoints/frontend/views/agentBootstrapInstall.spec.js create mode 100644 plugins/endpoints/frontend/views/agentKeySettings.spec.js create mode 100644 plugins/endpoints/frontend/views/endpointsList.spec.js create mode 100644 plugins/endpoints/frontend/views/enrollmentSettings.spec.js create mode 100644 plugins/endpoints/frontend/views/serverCertSettings.spec.js create mode 100644 plugins/endpoints/frontend/views/systemScriptsSettings.spec.js create mode 100644 plugins/endpoints/migrations/versions/0012_endpoint_eventrecordid.py create mode 100644 plugins/endpoints/migrations/versions/0013_endpoint_inventory_columns.py create mode 100644 plugins/endpoints/migrations/versions/0014_endpoint_diskhealth.py create mode 100644 plugins/endpoints/migrations/versions/0015_endpoint_system_telemetry.py create mode 100644 plugins/endpoints/migrations/versions/0016_endpoint_dellupdates.py create mode 100644 plugins/endpoints/migrations/versions/0017_endpoint_health_rollup.py create mode 100644 plugins/endpoints/migrations/versions/0018_endpoint_locks.py create mode 100644 plugins/endpoints/migrations/versions/0019_split_shipped_monitor_policies.py create mode 100644 plugins/endpoints/migrations/versions/0020_app_delta_fingerprints.py create mode 100644 plugins/endpoints/migrations/versions/0021_client_certificates.py create mode 100644 plugins/endpoints/migrations/versions/0022_client_cert_revocation.py create mode 100644 plugins/endpoints/migrations/versions/0023_endpoint_credentials.py create mode 100644 plugins/endpoints/migrations/versions/0024_endpoint_bootstrap_tokens.py create mode 100644 plugins/endpoints/models/bootstrap.py create mode 100644 plugins/endpoints/models/credential.py create mode 100644 plugins/endpoints/models/health.py create mode 100644 plugins/endpoints/services/health.py delete mode 100644 plugins/geenforce/__init__.py delete mode 100644 plugins/geenforce/api/__init__.py delete mode 100644 plugins/geenforce/api/routes.py delete mode 100644 plugins/geenforce/client/Install-GEEnforce.ps1 delete mode 100644 plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 delete mode 100644 plugins/geenforce/client/Invoke-ShopdbShadow.ps1 delete mode 100644 plugins/geenforce/client/ShopdbEnforceClient.psm1 delete mode 100644 plugins/geenforce/filters.py delete mode 100644 plugins/geenforce/frontend/routes.js delete mode 100644 plugins/geenforce/frontend/views/EnforcementReports.vue delete mode 100644 plugins/geenforce/frontend/views/GeEnforceLayout.vue delete mode 100644 plugins/geenforce/frontend/views/GeEnforceSettings.vue delete mode 100644 plugins/geenforce/frontend/views/ManifestEditor.vue delete mode 100644 plugins/geenforce/frontend/views/entryForm.js delete mode 100644 plugins/geenforce/frontend/views/entryForm.spec.js delete mode 100644 plugins/geenforce/importer.py delete mode 100644 plugins/geenforce/manifest.json delete mode 100644 plugins/geenforce/migrations/env.py delete mode 100644 plugins/geenforce/migrations/script.py.mako delete mode 100644 plugins/geenforce/migrations/versions/0001_geenforce_baseline.py delete mode 100644 plugins/geenforce/migrations/versions/0002_manifest_blobs.py delete mode 100644 plugins/geenforce/migrations/versions/0003_report_subtype.py delete mode 100644 plugins/geenforce/migrations/versions/0004_scope_minlibversion.py delete mode 100644 plugins/geenforce/models/__init__.py delete mode 100644 plugins/geenforce/models/manifest.py delete mode 100644 plugins/geenforce/parity.py delete mode 100644 plugins/geenforce/parityfixtures.json delete mode 100644 plugins/geenforce/plugin.py delete mode 100644 plugins/geenforce/seed_display_scope.py delete mode 100644 plugins/geenforce/serializer.py delete mode 100644 plugins/geenforce/service.py delete mode 100644 plugins/measuringtools/__init__.py delete mode 100644 plugins/measuringtools/api/__init__.py delete mode 100644 plugins/measuringtools/api/routes.py delete mode 100644 plugins/measuringtools/frontend/routes.js delete mode 100644 plugins/measuringtools/frontend/views/CalibrationReport.vue delete mode 100644 plugins/measuringtools/frontend/views/MeasuringToolDetail.vue delete mode 100644 plugins/measuringtools/frontend/views/MeasuringToolForm.vue delete mode 100644 plugins/measuringtools/frontend/views/MeasuringToolTypesList.vue delete mode 100644 plugins/measuringtools/frontend/views/MeasuringToolsList.vue delete mode 100644 plugins/measuringtools/manifest.json delete mode 100644 plugins/measuringtools/migrations/env.py delete mode 100644 plugins/measuringtools/migrations/script.py.mako delete mode 100644 plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py delete mode 100644 plugins/measuringtools/models/__init__.py delete mode 100644 plugins/measuringtools/models/measuringtool.py delete mode 100644 plugins/measuringtools/plugin.py create mode 100644 plugins/notifications/migrations/versions/0006_add_fleet_alerts.py create mode 100644 plugins/notifications/models/acknowledgement.py delete mode 100644 plugins/printedparts/README.md delete mode 100644 plugins/printedparts/__init__.py delete mode 100644 plugins/printedparts/api/__init__.py delete mode 100644 plugins/printedparts/api/routes.py delete mode 100644 plugins/printedparts/frontend-api-snippet.js delete mode 100644 plugins/printedparts/frontend/routes.js delete mode 100644 plugins/printedparts/frontend/views/PartsKiosk.vue delete mode 100644 plugins/printedparts/frontend/views/PrintedItemDetail.vue delete mode 100644 plugins/printedparts/frontend/views/PrintedItemForm.vue delete mode 100644 plugins/printedparts/frontend/views/PrintedItemsList.vue delete mode 100644 plugins/printedparts/frontend/views/PrintedPartsLabels.vue delete mode 100644 plugins/printedparts/frontend/views/PrintedPartsSettings.vue delete mode 100644 plugins/printedparts/manifest.json delete mode 100644 plugins/printedparts/migrations/env.py delete mode 100644 plugins/printedparts/migrations/script.py.mako delete mode 100644 plugins/printedparts/migrations/versions/0001_printedparts_baseline.py delete mode 100644 plugins/printedparts/migrations/versions/0002_printeditemfiles.py delete mode 100644 plugins/printedparts/migrations/versions/0003_gagelabtag.py delete mode 100644 plugins/printedparts/migrations/versions/0004_transaction_revision.py delete mode 100644 plugins/printedparts/models/__init__.py delete mode 100644 plugins/printedparts/models/printeditem.py delete mode 100644 plugins/printedparts/plugin.py delete mode 100644 plugins/printedparts/schemas/__init__.py delete mode 100644 plugins/printedparts/services/__init__.py delete mode 100644 plugins/printedparts/services/badges.py delete mode 100644 plugins/slides/PLAN-slide-manager.md delete mode 100644 plugins/usb/README.md delete mode 100644 plugins/usb/__init__.py delete mode 100644 plugins/usb/api/__init__.py delete mode 100644 plugins/usb/api/routes.py delete mode 100644 plugins/usb/api/selfhosted.py delete mode 100644 plugins/usb/cli.py delete mode 100644 plugins/usb/frontend/drivefilter.js delete mode 100644 plugins/usb/frontend/drivefilter.spec.js delete mode 100644 plugins/usb/frontend/routes.js delete mode 100644 plugins/usb/frontend/views/USBDetail.vue delete mode 100644 plugins/usb/frontend/views/USBForm.vue delete mode 100644 plugins/usb/frontend/views/USBKiosk.vue delete mode 100644 plugins/usb/frontend/views/USBLabelBatch.vue delete mode 100644 plugins/usb/frontend/views/USBList.vue delete mode 100644 plugins/usb/frontend/views/USBSettings.vue delete mode 100644 plugins/usb/manifest.json delete mode 100644 plugins/usb/migrations/env.py delete mode 100644 plugins/usb/migrations/script.py.mako delete mode 100644 plugins/usb/migrations/versions/0001_usb_anchor.py delete mode 100644 plugins/usb/migrations/versions/0002_drop_checkout_machineid.py delete mode 100644 plugins/usb/migrations/versions/0003_checkout_scan_fields.py delete mode 100644 plugins/usb/migrations/versions/0004_checkin_identity.py delete mode 100644 plugins/usb/models/__init__.py delete mode 100644 plugins/usb/models/usb_device.py delete mode 100644 plugins/usb/plugin.py create mode 100644 scripts/backfill_app_fingerprints.py create mode 100644 scripts/backup/.gitattributes create mode 100644 scripts/backup/Dockerfile.backup create mode 100644 scripts/backup/README.md create mode 100644 scripts/backup/SIMULATE-RESTORE.README.md rename scripts/{site_imports/wjf/__init__.py => backup/_probe_manifest.py} (100%) create mode 100755 scripts/backup/backup.sh create mode 100644 scripts/backup/manifest.py create mode 100644 scripts/backup/offline-packages/restic_0.17.3_linux_amd64.bz2 create mode 100755 scripts/backup/restore.sh create mode 100644 scripts/backup/simulate-restore-results.txt create mode 100755 scripts/backup/simulate-restore.sh create mode 100755 scripts/backup/status.sh create mode 100755 scripts/backup/test-restore-datarev.sh create mode 100755 scripts/backup/watch.sh create mode 100644 scripts/bootstrap-plugins.sh create mode 100644 scripts/docker-entrypoint.sh create mode 100644 scripts/loadtest/Dockerfile.gevent create mode 100644 scripts/loadtest/burst_arrival.py create mode 100644 scripts/loadtest/force_pool.py create mode 100644 scripts/loadtest/loadtest_token.py create mode 100644 scripts/loadtest/mixed_workload.py create mode 100644 scripts/loadtest/poll_fleet.py create mode 100644 scripts/loadtest/pool_status.py create mode 100644 scripts/loadtest/sample_dbconns.py create mode 100644 scripts/loadtest/sample_resources.py create mode 100644 scripts/loadtest/ui_probe.py delete mode 100644 scripts/site_imports/wjf/PILOT-DEPLOY.md delete mode 100644 scripts/site_imports/wjf/README.md delete mode 100644 scripts/site_imports/wjf/harness.py delete mode 100644 scripts/site_imports/wjf/run.py create mode 100644 shopdb/core/api/employees.py create mode 100644 shopdb/core/api/shopdbbackups.py create mode 100644 shopdb/core/api/staticdocs/swagger-ui-bundle.min.js create mode 100644 shopdb/core/api/staticdocs/swagger-ui.LICENSE.txt create mode 100644 shopdb/core/api/staticdocs/swagger-ui.css delete mode 100644 shopdb/utils/cmmc_usb_db.py create mode 100644 shopdb/utils/ipallowlist.py create mode 100644 tests/test_backfill_app_fingerprints.py delete mode 100644 tests/test_bundle_lock.py create mode 100644 tests/test_core/test_api_docs_tryit.py create mode 100644 tests/test_core/test_application_hard_delete.py create mode 100644 tests/test_core/test_application_monitorable.py create mode 100644 tests/test_core/test_application_required_policy.py create mode 100644 tests/test_core/test_application_required_policy_sync.py create mode 100644 tests/test_core/test_application_revert.py create mode 100644 tests/test_core/test_application_version_detection.py create mode 100644 tests/test_core/test_asset_type_descriptor_enforcement.py create mode 100644 tests/test_core/test_collector_legacy_sharedkey.py delete mode 100644 tests/test_core/test_employees.py create mode 100644 tests/test_core/test_migration_nest_app_packages.py create mode 100644 tests/test_core/test_plugin_settings_hubs.py create mode 100644 tests/test_core/test_plugin_settings_routes.py create mode 100644 tests/test_core/test_pluginui_panel_groups.py create mode 100644 tests/test_core/test_script_captureoutput.py create mode 100644 tests/test_core/test_script_errormode.py create mode 100644 tests/test_core/test_script_library_gate.py create mode 100644 tests/test_core/test_script_workingdirectory.py create mode 100644 tests/test_core/test_settings_switches_reachable.py create mode 100644 tests/test_core/test_shopdbbackups_finalize.py create mode 100644 tests/test_core/test_user_scopes.py create mode 100644 tests/test_docs_index.py delete mode 100644 tests/test_installer_defaults.py delete mode 100644 tests/test_installer_state.py create mode 100644 tests/test_naming_gate_migrations.py create mode 100644 tests/test_openapi_examples.py delete mode 100644 tests/test_plugins/test_collector_measuringtool_adoption.py create mode 100644 tests/test_plugins/test_computers_policy_allpcs.py create mode 100644 tests/test_plugins/test_computers_policy_authoring_gate.py delete mode 100644 tests/test_plugins/test_employee_photo.py delete mode 100644 tests/test_plugins/test_employees_resolve.py create mode 100644 tests/test_plugins/test_endpoints_agent_registration.py create mode 100644 tests/test_plugins/test_endpoints_agent_upgrade_route.py create mode 100644 tests/test_plugins/test_endpoints_agent_version_guard.py create mode 100644 tests/test_plugins/test_endpoints_agentupload.py create mode 100644 tests/test_plugins/test_endpoints_app_conflict.py create mode 100644 tests/test_plugins/test_endpoints_app_delta.py create mode 100644 tests/test_plugins/test_endpoints_bootstrap_install.py create mode 100644 tests/test_plugins/test_endpoints_collect_log_denylist.py create mode 100644 tests/test_plugins/test_endpoints_credential_admin_ui.py create mode 100644 tests/test_plugins/test_endpoints_credentials.py create mode 100644 tests/test_plugins/test_endpoints_credentials_migration.py create mode 100644 tests/test_plugins/test_endpoints_dellupdates.py create mode 100644 tests/test_plugins/test_endpoints_disk_health.py create mode 100644 tests/test_plugins/test_endpoints_enroll_limits.py create mode 100644 tests/test_plugins/test_endpoints_enrollment_denials.py create mode 100644 tests/test_plugins/test_endpoints_enrollment_migration.py create mode 100644 tests/test_plugins/test_endpoints_fleet_alert_scheduling.py create mode 100644 tests/test_plugins/test_endpoints_fleet_alerts.py create mode 100644 tests/test_plugins/test_endpoints_fleet_alias.py create mode 100644 tests/test_plugins/test_endpoints_health.py create mode 100644 tests/test_plugins/test_endpoints_health_savepoint.py create mode 100644 tests/test_plugins/test_endpoints_ip_history.py create mode 100644 tests/test_plugins/test_endpoints_link_label.py create mode 100644 tests/test_plugins/test_endpoints_mapped_drives.py create mode 100644 tests/test_plugins/test_endpoints_mapped_drives_card.py create mode 100644 tests/test_plugins/test_endpoints_panel_groups.py create mode 100644 tests/test_plugins/test_endpoints_policy_override.py create mode 100644 tests/test_plugins/test_endpoints_script_runs.py create mode 100644 tests/test_plugins/test_endpoints_server_certificate.py create mode 100644 tests/test_plugins/test_endpoints_settings_declared.py delete mode 100644 tests/test_plugins/test_endpoints_shipped_monitor_policy.py create mode 100644 tests/test_plugins/test_endpoints_siteagent_build.py create mode 100644 tests/test_plugins/test_endpoints_split_monitor_policies.py create mode 100644 tests/test_plugins/test_endpoints_storage_sections.py delete mode 100644 tests/test_plugins/test_geenforce_blob_parity.py delete mode 100644 tests/test_plugins/test_geenforce_compliance.py delete mode 100644 tests/test_plugins/test_geenforce_crud.py delete mode 100644 tests/test_plugins/test_geenforce_dashboard.py delete mode 100644 tests/test_plugins/test_geenforce_ddl_parity.py delete mode 100644 tests/test_plugins/test_geenforce_display_seed.py delete mode 100644 tests/test_plugins/test_geenforce_export.py delete mode 100644 tests/test_plugins/test_geenforce_httppayload.py delete mode 100644 tests/test_plugins/test_geenforce_manifest.py delete mode 100644 tests/test_plugins/test_geenforce_payload.py delete mode 100644 tests/test_plugins/test_geenforce_payload_hardening.py delete mode 100644 tests/test_plugins/test_geenforce_publish_gate.py delete mode 100644 tests/test_plugins/test_geenforce_report_contract.py delete mode 100644 tests/test_plugins/test_geenforce_reporting.py delete mode 100644 tests/test_plugins/test_geenforce_resource_binding.py delete mode 100644 tests/test_plugins/test_measuringtools.py create mode 100644 tests/test_plugins/test_notifications_alertscope_storage.py create mode 100644 tests/test_plugins/test_notifications_fleet_alert_fields.py delete mode 100644 tests/test_plugins/test_printedparts_ledger.py delete mode 100644 tests/test_plugins/test_printedparts_plugin.py delete mode 100644 tests/test_plugins/test_usb_import_pairing.py delete mode 100644 tests/test_plugins/test_usb_kiosk.py delete mode 100644 tests/test_plugins/test_usb_reports.py delete mode 100644 tests/test_site_import_loader.py diff --git a/.dockerignore b/.dockerignore index 3ea9bfaa..8096bd11 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,3 @@ venv .gitignore *.log .env.local - -# Installer build artifacts (not part of the app image) -installer-arch-dev/build -installer-arch-dev/dist diff --git a/.dockerignore.prebuilt b/.dockerignore.prebuilt new file mode 100644 index 00000000..1e36ddd9 --- /dev/null +++ b/.dockerignore.prebuilt @@ -0,0 +1,22 @@ +# Dockerignore for pre-built frontend approach +# frontend/dist is INCLUDED (not ignored) for this build + +**/node_modules +frontend/src/.plugins-staged +frontend/src/router/routes.gen.js + +# Python caches +**/__pycache__ +**/*.pyc +.venv +venv + +# Local dev and VCS +.git +.gitignore +*.log +.env.local + +# Installer artifacts +installer-arch-dev/build +installer-arch-dev/dist diff --git a/.env.example b/.env.example index 891adb9a..dbbee8be 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,11 @@ # refuses to boot when SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, or # CORS_ORIGINS are missing or use the dev defaults. # -# See the DEPLOY wiki page for the full per-site deployment runbook. +# This template covers what a container deploy sets. Docker is the shipping +# path for a receiving site: docker-compose.yml builds from source, +# docker-compose.pull.yml runs a published image, and +# docker-compose.airgap.yml runs an image loaded from a tarball. See the +# DEPLOY wiki page for the full per-site runbook. # ---- Flask ---- FLASK_APP=wsgi.py @@ -37,11 +41,12 @@ DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8m CORS_ORIGINS=http://localhost:5173 # ---- Reverse proxy (optional, default 0 = no proxy) ---- -# Set to the number of proxies actually in front of this instance - 1 when IIS -# or nginx serves it under a subpath such as /shopdb. Without it the app sees -# the proxy's connection instead of the browser's (scheme http, the proxy's -# host and IP), so any absolute URL it builds is wrong at the browser. That is -# what made a redirect on the endpoints list unfollowable behind IIS. +# Set to the number of proxies actually in front of this instance - 1 when +# nginx or another front end terminates TLS for the container. Without it the +# app sees the proxy's connection instead of the browser's (scheme http, the +# proxy's host and IP), so any absolute URL it builds is wrong at the browser. +# That is what made a redirect on the endpoints list unfollowable behind a +# proxy. # # Leave it at 0 when nothing fronts the app. It makes X-Forwarded-* trusted, # and a client can send those headers itself - the login rate limit and the @@ -56,15 +61,22 @@ JWT_REFRESH_TOKEN_EXPIRES=2592000 LOG_LEVEL=INFO # ---- docker-compose only ---- -# These are read by docker-compose.yml; not used by the Flask app directly. +# These are read by the compose files; not used by the Flask app directly. MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD MYSQL_PORT=3306 API_PORT=5001 # Air-gapped deploy only (docker-compose.airgap.yml): the loaded image tag, # which MUST match what build-offline-bundle.ps1 -Version produced. Ignored by -# the connected build template (docker-compose.yml builds from source). +# the connected build template (docker-compose.yml builds from source) and by +# the pull template (which uses SHOPDB_IMAGE instead). IMAGE_TAG=0.7.0 +# Pull deploy only (docker-compose.pull.yml): the full image reference to pull. +# SHOPDB_IMAGE=ghcr.io//shopdb-flask:stable +# +# Non-empty lights a channel badge in the sidebar. Leave unset on a real site; +# the dev stacks set it to DEV. +# SHOPDB_ENV_LABEL= # ---- Zabbix integration (optional, for printer supply monitoring) ---- ZABBIX_URL= @@ -89,36 +101,25 @@ ZABBIX_TOKEN= # (flask plugin stamp-bundled) and pinning keys above. # PLUGIN_REQUIRE_SIGNED=false # -# Dev-only: directories whose UNSIGNED plugins are trusted, honored ONLY under -# DEBUG/TESTING (external-repo/symlink dev). Production ignores this. -# PLUGIN_DEV_TRUST_DIRS=/home/dev/my-plugin-repo -# # Read-only folder the app pulls plugin artifacts from (a SharePoint-synced or # copied shelf). The app reads this folder; it never speaks any network. # PLUGIN_SHELF_DIR=/srv/shopdb/plugin-shelf # ---- Employee directory database (optional, read-only) ---- -# Separate HR/employee lookup DB consumed by the notifications plugin and the -# public shopfloor kiosks. Leave unset if the feature is not used; there is no -# safe default for the password, so an unset password fails loud. +# Separate HR/employee lookup DB. Core reads it in two places: the employee +# domain of global search (shopdb/core/api/search.py) and audit-log name +# resolution (shopdb/core/api/auditlogs.py). Leave unset if the site has no +# such database; there is no safe default for the password, so an unset +# password fails loud rather than connecting as someone else. # EMPLOYEE_DB_HOST= # EMPLOYEE_DB_USER= # EMPLOYEE_DB_PASSWORD= -# EMPLOYEE_DB_NAME=wjf_employees - -# ---- cmmc_usb database (USB check-in/out) ---- -# Separate read-write MySQL DB used by the USB plugin to track device -# check-in/out, lockers, and the check-in/out log. Leave unset if the feature -# is not used; there is no safe default for the password, so an unset password -# fails loud. -# CMMC_USB_DB_HOST= -# CMMC_USB_DB_USER= -# CMMC_USB_DB_PASSWORD= -# CMMC_USB_DB_NAME=cmmc_usb +# EMPLOYEE_DB_NAME=employees # ---- Subpath deployment (optional) ---- -# Serve the app under a URL prefix instead of the server root, e.g. as an IIS -# Application at /ops under an existing site. The frontend must be rebuilt with -# the matching base: VITE_BASE_PATH=/ops/ npm run build. Leave unset when the -# app owns its own site/port (the default). See the INSTALL-WINDOWS-IIS wiki page. +# Serve the app under a URL prefix instead of the server root, e.g. behind an +# nginx location at /ops. wsgi.py reads this to mount the WSGI app under the +# prefix. The frontend must be rebuilt with the matching base: +# VITE_BASE_PATH=/ops/ npm run build. Leave unset when the app owns its own +# host/port, which is the container default. # MOUNT_PATH=/ops diff --git a/.gitattributes b/.gitattributes index 9a0a204a..e03d96cd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,9 +4,3 @@ *.sh text eol=lf scripts/check-naming-and-style.sh text eol=lf .githooks/pre-commit text eol=lf - -# Batch files must be CRLF. cmd.exe parses a lone LF inconsistently around -# labels and goto, so an LF-only .cmd can run its happy path and then jump -# nowhere on the error path - which is the path that matters in a promote -# script. -*.cmd text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfa99c3d..bb25e7b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,9 +88,31 @@ jobs: - run: npm run build lean-build: - # ADR-013 Phase 5: prove a per-site build carries only its chosen plugins. - # Builds a lean site (machines + printers) and asserts an omitted plugin's - # code is absent from the bundle - the delete-a-plugin guarantee in CI. + # ADR-013: prove a per-site build carries only its chosen plugins. + # + # The split below is SYNTHETIC, and deliberately so. Every plugin in this + # repository ships on this line, so there is no "omitted" set to borrow from + # a real site - and a job that iterated an empty omitted list would assert + # nothing while staying green, which is the exact failure the omission + # guarantee exists to catch. So the profile names a genuine SUBSET of the + # bundled plugins and the assertions prove the SUBSETTING MECHANISM: what + # the profile chooses is staged, and what it leaves out is not. + # + # Chosen (7): backups computers endpoints machines network notifications + # printers + # Omitted (5): guides knowledgebase slides tools warranty + # + # The omitted five are picked because nothing depends on them, so the + # closure stays exactly the chosen set. 'applications' is manifest-less core + # and ships in every build (ADR-013). endpoints hard-depends on computers, + # which is already chosen, so the closure adds nothing - if that changes, + # resolve_plugin_closure.py pulls the dependency in and the absence + # assertions below start failing, which is the signal to update this list + # rather than to loosen the check. + # + # Verified by planting a leak, per the standing rule that a green gate may + # be vacuous: adding 'guides' to the profile and rebuilding makes both the + # GuidesList assertion and the plugins/guides directory assertion fail. runs-on: arc-runner-set steps: - uses: actions/checkout@v5 @@ -100,21 +122,29 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json - run: cd frontend && npm ci - - name: Build lean site (machines + printers) + - name: Build lean site (seven-plugin subset) run: | - printf '{ "site": "ci-lean", "plugins": ["machines", "printers"] }' \ + printf '%s' '{ "site": "ci-lean", "plugins": ["backups", "computers", "endpoints", "machines", "network", "notifications", "printers"] }' \ > /tmp/lean-profile.json bash scripts/build-site.sh /tmp/lean-profile.json /tmp/leansite - name: Assert omitted plugin code is absent, chosen present run: | assets=/tmp/leansite/frontend-dist/assets - for code in PartsKiosk ManifestEditor USBLabelBatch KnowledgeBaseDetail; do + # One component per OMITTED plugin: guides, knowledgebase, slides, + # tools, warranty. Each name is proven to exist by the chosen-plugin + # loop on a full build, so these are known-good grep targets rather + # than strings that would pass by being misspelled. + for code in GuidesList KnowledgeBaseDetail SlideManager ToolsIndex \ + WarrantiesList; do if grep -rqoh "$code" "$assets"/*.js; then echo "FAIL: omitted-plugin code '$code' leaked into the lean bundle" exit 1 fi done - for code in MachineDetail PrinterDetail; do + # One component per CHOSEN plugin, so a plugin silently dropped from + # the bundle fails here instead of on the floor. + for code in BackupHistory PCsList EndpointsList MachineDetail \ + NetworkDevicesList NotificationsList PrinterDetail; do grep -rqoh "$code" "$assets"/*.js || { echo "FAIL: chosen-plugin code '$code' missing from the lean bundle" exit 1; } @@ -124,8 +154,17 @@ jobs: grep -rqoh "ApplicationsList" "$assets"/*.js || { echo "FAIL: core page 'ApplicationsList' missing from the lean bundle" exit 1; } - test -d /tmp/leansite/plugins/machines - test ! -d /tmp/leansite/plugins/printedparts + for plugin in backups computers endpoints machines network \ + notifications printers; do + test -d "/tmp/leansite/plugins/$plugin" || { + echo "FAIL: chosen plugin '$plugin' missing from the backend tree" + exit 1; } + done + for plugin in guides knowledgebase slides tools warranty; do + test ! -d "/tmp/leansite/plugins/$plugin" || { + echo "FAIL: omitted plugin '$plugin' staged into the backend tree" + exit 1; } + done echo "lean build verified: only chosen plugins present" # NOTE: the MySQL-8 migration/seed job (fresh `flask db upgrade` + every diff --git a/.github/workflows/scan-darkwing-portable.yml b/.github/workflows/scan-darkwing-portable.yml deleted file mode 100644 index 2c82d729..00000000 --- a/.github/workflows/scan-darkwing-portable.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Darkwing vulnerability scan, manual dispatch. -# -# Copied verbatim from the ge-aero/darkwing README so it stays byte-identical -# to the sanctioned integration. Do not edit this file to add triggers or -# gating - see scan-darkwing-pr.yml for that, which is ours to change. -# -# The file must sit on the default branch before a scan can run on ANY branch. -name: Scan (darkwing, portable) - -on: - workflow_dispatch: - inputs: - target_ref: - description: Ref, branch, or sha of this repo to scan - required: false - type: string - default: main - -permissions: - contents: read # checkout this repo via the ambient GITHUB_TOKEN - security-events: write # SARIF upload to the Security tab - actions: read # required by upload-sarif on private/internal repos - -jobs: - scan: - runs-on: darkwing-scan - steps: - # @v1 is a moving tag pointing to the latest release that is compatible - # with this workflow file. - - uses: ge-aero/darkwing@v1 - with: - target_ref: ${{ inputs.target_ref || 'main' }} diff --git a/.github/workflows/scan-darkwing-pr.yml b/.github/workflows/scan-darkwing-pr.yml deleted file mode 100644 index 12ed8d89..00000000 --- a/.github/workflows/scan-darkwing-pr.yml +++ /dev/null @@ -1,62 +0,0 @@ -# Darkwing scan on pull requests, so the "check against darkwing" is a check -# and not a thing somebody remembers to click. -# -# WHY THIS IS SEPARATE from scan-darkwing-portable.yml: that file is copied -# byte-for-byte from the ge-aero/darkwing README and is left alone so it keeps -# matching the sanctioned integration. Everything below is ours. -# -# THE TRAP THIS AVOIDS. The README workflow resolves its scan target as -# `inputs.target_ref || 'main'`. On a pull_request event there are no inputs, -# so that expression falls through to 'main' and the job scans the default -# branch instead of the code under review - a green Darkwing check that never -# looked at the change. Resolve the head SHA explicitly. -# -# Darkwing exits 0 whatever it finds (its own FAQ: "Does a finding fail my -# build? No."), so a passing job means the scan RAN, not that the code is -# clean. Findings land in Security -> Code scanning. Turning a finding into a -# hard failure means reading findings.sarif ourselves; the commented step at -# the bottom is the shape of that, deliberately left off until we have seen the -# false-positive rate on this repo. -name: Scan (darkwing, PR) - -on: - pull_request: - -permissions: - contents: read - security-events: write - actions: read - -# One scan per PR. A new push supersedes the old one rather than queueing a -# second Bedrock spend against a diff nobody is reviewing any more. -concurrency: - group: darkwing-pr-${{ github.ref }} - cancel-in-progress: true - -jobs: - scan: - runs-on: darkwing-scan - steps: - - uses: ge-aero/darkwing@v1 - with: - # The PR's head, NOT the default branch. See the note above. - target_ref: ${{ github.event.pull_request.head.sha }} - - # Gating, once the signal is trusted. Darkwing writes findings.sarif into - # its work dir and attaches it as the `findings` artifact; this reads the - # severities out and fails on error-level results only. - # - # - name: Fail on error-level findings - # run: | - # python - <<'PY' - # import json, pathlib, sys - # sarif = pathlib.Path('darkwing/findings.sarif') - # if not sarif.is_file(): - # sys.exit('no findings.sarif: the scan did not produce results') - # doc = json.loads(sarif.read_text()) - # bad = [r for run in doc.get('runs', []) - # for r in run.get('results', []) - # if r.get('level') == 'error'] - # if bad: - # sys.exit(f'{len(bad)} error-level finding(s); see the Security tab') - # PY diff --git a/.gitignore b/.gitignore index 31591d72..2c96b929 100644 --- a/.gitignore +++ b/.gitignore @@ -83,7 +83,6 @@ secrets.yml *_secret *_secrets credentials.json -scripts/site_imports/wjf/idmap.json # work-PC publication clone: bundle drop folder for the transfer pipeline _transfer/ diff --git a/Dockerfile b/Dockerfile index 7fd22a02..f8b7cf04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,19 @@ # shopdb-flask single-site container. # # One image, one site. Per ADR-004, each adopting facility runs its own -# stack with its own DB, secrets, and enabled-plugin list. This image -# bundles all 13 catalog plugins (computers, employees, geenforce, -# knowledgebase, machines, measuringtools, network, notifications, -# printedparts, printers, slides, usb, warranty); a site installs + enables -# the ones it wants with `flask plugin install ` (or, declaratively, -# `flask plugin apply-profile `). Per ADR-013 a future lean -# build stages only the chosen plugin directories into this image. +# stack with its own DB, secrets, and enabled-plugin list. This image bundles +# the whole catalog - backups, computers, endpoints, guides, knowledgebase, +# machines, network, notifications, printers, slides, tools, warranty - and a +# site installs + enables the ones it wants with `flask plugin install ` +# (or, declaratively, `flask plugin apply-profile `). +# +# WHY no count here: a hand-typed number goes stale the moment a plugin is +# added or dropped, and nothing in the test suite polices a count outside +# docs/. The generated PROJECT-MAP wiki page is the authority on what ships. +# +# Per ADR-013 a lean build stages only the chosen plugin directories into this +# image; scripts/build-site.sh plus scripts/resolve_plugin_closure.py are that +# mechanism. # # The frontend is built in a first stage and its dist output is copied into # the final image so Flask can serve the SPA (register_frontend_routes in diff --git a/Dockerfile.prebuilt b/Dockerfile.prebuilt new file mode 100644 index 00000000..2a53d0dc --- /dev/null +++ b/Dockerfile.prebuilt @@ -0,0 +1,47 @@ +# Dockerfile for pre-built frontend +# Use this when frontend/dist is already built on the host +# Avoids npm issues with corporate proxy + +FROM python:3.14-slim AS base + +WORKDIR /app + +# Install system dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + default-libmysqlclient-dev \ + pkg-config \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir gunicorn gevent + +# Copy application code +COPY shopdb/ ./shopdb/ +COPY plugins/ ./plugins/ +COPY migrations/ ./migrations/ +COPY scripts/ ./scripts/ +COPY wsgi.py ./ + +# Copy pre-built frontend +COPY frontend/dist ./frontend/dist + +# Make scripts executable +RUN chmod +x /app/scripts/docker-entrypoint.sh /app/scripts/bootstrap-plugins.sh + +# Create instance directory +RUN mkdir -p /app/instance + +# Expose port +EXPOSE 5001 + +# Set environment +ENV PYTHONUNBUFFERED=1 +ENV FLASK_APP=shopdb + +# Entrypoint runs migrations and starts gunicorn +ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 88d2d512..2183b498 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,13 @@ ShopDB tracks and manages: - **PCs** - Shopfloor computers, engineering workstations - **Printers** - Network printers with Zabbix supply integration - **Network devices** - Switches, routers, and the subnet browser -- **Measuring tools** - Gage-lab instruments with calibration tracking - **Applications** - Software deployed across the shop floor, with per-PC install tracking -- **Employees** - Directory, recognition and training notifications -- **Warranties** - Coverage records with Dell warranty lookups -- **USB devices** - CMMC check-in/out tracking -- **Knowledge Base** - Documentation and troubleshooting guides -- **GE-Enforce manifests** - Imaging/software manifest editing and fleet compliance +- **Endpoints** - RMM telemetry reported by the ShopDB agent: metrics, services, inventory, Windows events +- **Backups** - Per-asset configuration backups with revision history and diffs +- **Warranties** - Coverage records with vendor warranty lookups +- **Knowledge Base and Guides** - Reference articles and step-by-step procedures with working checklists +- **Notifications** - Shopfloor announcements and recognition feed +- **Slides** - Lobby display and shopfloor screensaver slideshows ## Tech Stack @@ -108,10 +108,11 @@ SQLite). Do not run dev or production against SQLite. ### Distribution -Source lives here. The Windows installer ships as a release asset rather than a -file in the repository - it is around 240 MB, well past what a repository takes -and well inside what a release asset does. There is no package or image -registry; a site installs from that `.exe` or builds from source. +Source lives here, and a site stands up ShopDB with Docker. Three compose +files cover the three situations: `docker-compose.yml` builds the image from +this checkout, `docker-compose.pull.yml` runs an image published to a +registry, and `docker-compose.airgap.yml` runs an image loaded from a tarball +at a site with no internet. ### Fast path (Docker) @@ -174,42 +175,19 @@ project wiki. To import a site's legacy data, use the HTTP import surface: an admin API token plus the IMPORT-API page in the project wiki drive the whole migration through documented endpoints (`X-Import-Mode` preserves original timestamps). -`scripts/site_imports/wjf/` is the reference site's loader, kept as a worked -example of the whole sequence. +`scripts/site_imports/` holds the shared import machinery - the resolver, the +idempotent journal, the approval packet builder and the WINC machine-adoption +writer that the core import routes call. ### Which deployment route | Target | Use | |---|---| -| **Windows Server + IIS** (how sister sites run) | **the INSTALL-WINDOWS page in the project wiki** - one installer `.exe`, offline, no manual IIS work. Day 2: the OPERATE-WINDOWS page in the project wiki | -| Linux / Docker, air-gapped | the DEPLOY-AIRGAP page in the project wiki | -| Linux / Docker, connected | the DEPLOY page in the project wiki | +| Docker, connected | the DEPLOY page in the project wiki (`docker-compose.yml` or `docker-compose.pull.yml`) | +| Docker, air-gapped | the DEPLOY-AIRGAP page in the project wiki (`docker-compose.airgap.yml`) | For every environment variable and Setting key see the CONFIG page in the project wiki. -### What the Windows installer requires - -The installer checks all of this before it changes anything and refuses rather -than half-installing. - -| | Minimum | Notes | -|---|---|---| -| Windows Server | **2019** (build 10.0.17763) | Ships **IIS 10.0**, which is therefore the earliest IIS supported. Server 2016 is also IIS 10.0 but falls below the build floor and is refused. | -| Windows client (test boxes) | **10 22H2** (build 10.0.19045) or 11 | **Pro or higher** - Home has no IIS at all. | -| Architecture | 64-bit | The payload is cp314 win_amd64. | -| IIS Web Server role | installed beforehand | `Install-WindowsFeature -Name Web-Server -IncludeManagementTools`. Needs no internet. | -| Free disk | 5 GB | Refused below this. 40 GB is comfortable once uploads and config backups accumulate. | -| Database | bundled MySQL 8.4 LTS, or your own | Bring credentials if you use an existing server. | - -The binding constraint is the operating system rather than IIS: the payload is -Python 3.14 plus HttpPlatformHandler and URL Rewrite, and the handler itself runs -on older IIS. An older host is untested rather than known-broken. - -If you must deploy onto something older, the INSTALL-WINDOWS-IIS page in the project wiki -is the manual procedure and has no OS gate - it even documents MySQL 5.6, which is -that era of machine. The trade is stated there: it produces a server the -installer will not subsequently upgrade. - ## Configuration Environment variables (`.env`): @@ -235,7 +213,7 @@ The REST API follows standard conventions: | DELETE | `/api/machines/:id` | Soft delete machine | Each asset plugin exposes the same CRUD pattern on its own prefix -(`/api/computers`, `/api/printers`, `/api/network`, `/api/measuringtools`), +(`/api/computers`, `/api/printers`, `/api/network`, `/api/machines`), and cross-cutting asset endpoints live under `/api/assets`. Query parameters for list endpoints: @@ -244,7 +222,7 @@ Query parameters for list endpoints: - `sort` - Sort field - `dir` - Sort direction (asc/desc) - `search` - Search term -- `assettype` - Filter by asset type (computer, printer, machine, networkdevice, measuringtool) +- `assettype` - Filter by asset type (computer, printer, machine, networkdevice) ## Where to start @@ -261,21 +239,21 @@ The image bundles these plugins; only the ones a site installs are loaded. The current count and each plugin's version and migration head are in the PROJECT-MAP page in the project wiki, which is generated: -- **backups** - Machine configuration backups (NTLARS/DNC, CMM, part marker, UDC) with revision history and diffs +- **backups** - Per-asset configuration backups (NTLARS/DNC, CMM, part marker, UDC) with revision history and diffs - **computers** - Shopfloor PCs and workstations, collector fleet ingest -- **employees** - Employee directory -- **geenforce** - GE-Enforce imaging/software manifests and fleet compliance +- **endpoints** - RMM telemetry from the ShopDB agent: metrics, services, inventory, Windows events +- **guides** - Step-by-step procedures with images, working checklists and printable output +- **knowledgebase** - Documentation and troubleshooting articles - **machines** - CNC, CMM, and other shop-floor machines -- **measuringtools** - Gage-lab instruments with calibration tracking -- **knowledgebase** - Documentation and troubleshooting guides - **network** - Network devices and subnets - **notifications** - Shopfloor notifications and recognition feed -- **printedparts** - 3D-printed part catalogue, kiosk issue tracking and stock alerts - **printers** - Extended printer management with Zabbix integration -- **slides** - TV/kiosk slideshows -- **tools** - Tech Tools: barcode and QR generation laid out for real label stock -- **usb** - CMMC USB check-in/out tracking -- **warranty** - Warranty records with Dell lookups +- **slides** - Lobby display and shopfloor screensaver slideshows +- **tools** - Tech Tools: barcode/QR label generation and one-view secure send +- **warranty** - Warranty records with derived coverage status + +Applications is not in that list because it is not a plugin: it is core, has no +manifest, and always ships. ## Legacy Migration diff --git a/agent/README.md b/agent/README.md index 0c43efe5..c54c7282 100644 --- a/agent/README.md +++ b/agent/README.md @@ -84,12 +84,23 @@ event channel and system WMI. ## Tray app (system-tray icon) The tray is a per-user process, not a service, so it starts at sign-in rather -than under `sc.exe`. `install-shopdbagent.ps1` handles it: it copies -`shopdb-endpoint-tray.exe` next to the agent, registers an HKCU logon autostart -(`Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`), and -launches it for the current user. Run the installer from the operator's own -session so the autostart lands in the right user hive. Pass `-NoTray` for a -headless, service-only install. +than under `sc.exe`. The generated `install.ps1` handles it: it copies +`shopdb-endpoint-tray.exe` next to the agent and registers a machine-wide logon +autostart (`HKLM\Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`, +64-bit view). The installer deliberately does NOT start the tray itself: it runs +elevated, so any process it starts inherits an admin token, and mapped drives are +per logon token - an elevated tray probes the wrong drive table. The Run key only +fires at logon, which would leave the tray dead until sign-out on a machine +someone is already using, so the installer also registers a transient +`ShopDB tray first launch` scheduled task under `S-1-5-32-545` at `RunLevel +Limited`, runs it once, and unregisters it. That task is the only mechanism that +can reach into an existing interactive session without carrying the installer's +token. Pass `-NoTray` for a headless, service-only install. + +User-context collection does not depend on the tray. The installer registers a +`ShopDB user-context probe` scheduled task against the Users group at +`RunLevel Limited`, which runs `shopdbagent.exe --collect-usercontext` in the +operator's own session at logon and every 30 minutes. Right-click the tray icon for: @@ -143,7 +154,7 @@ logic itself changes. ## Diagnostics -- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\agent-YYYYMMDD.log` +- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\YYYYMMDD-agent.log` (14-day retention). - Windows Event Log: agent runtime failures and unhandled exceptions are written to the `Application` log under source `ShopDB Endpoint Agent`. The source is diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs index 991d857e..9ce09fd5 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs @@ -13,10 +13,40 @@ public sealed class AgentConfig [JsonPropertyName("apikey")] public string ApiKey { get; set; } = ""; [JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900; + // Legacy pin: SHA-1 thumbprint of the server leaf certificate (hex, no + // separators). Honoured with serverpins only while the registry ServerPins + // value is absent; breaks on every cert renewal, so prefer an SPKI pin. + // See AgentHttpFactory. + [JsonPropertyName("servercertthumbprint")] public string? ServerCertThumbprint { get; set; } + + // Server public key pins: lowercase hex SHA-256 of the leaf certificate's + // SubjectPublicKeyInfo. Fallback only: HKLM\SOFTWARE\GE\ShopDB ServerPins + // (REG_MULTI_SZ), when it exists in either view, replaces these entirely, + // even when empty. Any pin in effect = the leaf key must match one; chain + // and name errors are then ignored. None = normal Windows chain validation. + [JsonPropertyName("serverpins")] public List ServerPins { get; set; } = new(); + // Jitter spreads a fleet's reports so they do not arrive in lockstep // (thundering herd). Actual delay = cadence +/- up to jitterseconds. [JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60; + // One-time random delay before FIRST contact, applied by both the telemetry + // and command loops at startup. JitterSeconds only spreads the gap BETWEEN + // runs, so without this every agent in the fleet still makes its first + // request the instant its service starts - and the service is installed + // start=auto, so a patch reboot starts them all together. + // + // Measured on a 700-endpoint burst (devnotes/gevent-sizing-simulation.md): + // simultaneous arrival drove TLS handshakes to 1482ms worst case and pinned + // CPU at 305% while the database sat idle at 3 active queries. Spreading the + // same arrivals over 30s cut worst-case handshake to 63.7ms. The server is + // not the constraint; simultaneity is. + // + // Costs up to this many seconds before a rebooted endpoint picks up a queued + // command. That window is on top of a reboot the machine was already + // unreachable for. 0 disables the delay. + [JsonPropertyName("startupjitterseconds")] public int StartupJitterSeconds { get; set; } = 30; + [JsonPropertyName("adoptonfirstrun")] public bool AdoptOnFirstRun { get; set; } = true; // Directory holding site-provided PowerShell "agent utilities" the runtime @@ -89,42 +119,52 @@ public sealed class AgentConfig return bare; } - // Per-name allowlist of pre-staged scripts for the generic run_script - // action. Exactly like LaunchPaths, the wire never carries a script body or - // path - only a script NAME the agent already holds here. A name absent - // from this map fails closed. There are no shipped defaults: a script is a - // site-specific artifact, so an empty map means run_script does nothing - // until a site stages scripts and lists them in appsettings.json. Keyed by - // script name (case-insensitive, .ps1 optional). - [JsonPropertyName("scriptpaths")] - public Dictionary ScriptPaths { get; set; } = new(); + // REMOVED DELIBERATELY - DO NOT REINTRODUCE: 'scriptpaths' / ScriptPaths. + // + // This used to be a per-name map of PRE-STAGED scripts on the endpoint + // (typically under C:\ProgramData\ShopDB\EndpointAgent\scripts), which + // run_script would look up by name and execute. It was superseded by + // server-hosted delivery and left behind as dead config: nothing read the + // map, so it was an execution path that appeared supported but was not. + // + // It is gone rather than merely unused because reviving it is a security + // regression, and dead code invites revival. That model had NO integrity + // check - no digest, no provenance - so whoever could write into the staging + // directory chose what the agent ran as SYSTEM. ProgramData is world- + // writable by default unless the tree is explicitly tightened, which made + // the gate "is this name in appsettings.json", not "is this content + // trusted". + // + // The supported path is run_script with a server-issued delivery descriptor: + // the body is fetched from ShopDB over the collector credential and verified + // against a server-pinned SHA-256 before it executes, so the content is + // authenticated and the action is attributable in the command log. If you + // need a script to run on an endpoint, publish it server-side and send it + // through that path. If you need to trigger something already installed on + // the box, use scheduledtask_run, which is far narrower. - // The effective script map: appsettings.json entries only (no defaults). - // Keys are normalized to bare names so a lookup tolerates 'reset' or - // 'reset.ps1'. - public IReadOnlyDictionary ResolveScriptPaths() - { - var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var pair in ScriptPaths) - { - if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value)) - continue; - resolved[NormalizeScript(pair.Key)] = pair.Value; - } - return resolved; - } - - private static string NormalizeScript(string name) - { - var bare = (name ?? "").Trim(); - if (bare.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) - bare = bare[..^4]; - return bare; - } + // Approved scheduled task paths for the scheduledtask_run action. The wire + // carries a free-text task path so the WebUI can expose it as a single + // field; ScheduledTaskPolicy validates it against this list and fails closed + // when it does not match. An entry matches that exact task path, or acts as + // a folder prefix for anything beneath it. + // + // Empty means "use the shipped Corporate Tools defaults" + // (ScheduledTaskPolicy.DefaultApprovedTaskPaths). Setting it REPLACES those + // defaults, so a site can narrow the set as well as widen it without an + // agent rebuild. Keep it tight: every entry here is something an operator + // holding endpoints.deploy can start, in whatever context the task itself + // registered. + [JsonPropertyName("approvedtaskpaths")] + public List ApprovedTaskPaths { get; set; } = new(); public bool IsUsable() - => !string.IsNullOrWhiteSpace(BaseUrl) - && !string.IsNullOrWhiteSpace(ApiKey) + => !string.IsNullOrWhiteSpace(BaseUrl) && HasSharedKey(); + + // True when a real shared collector key is provisioned (not the shipped + // placeholder). An enrolled agent runs without one. + public bool HasSharedKey() + => !string.IsNullOrWhiteSpace(ApiKey) && ApiKey != "REPLACE_WITH_COLLECTOR_KEY"; // Validates the BaseUrl scheme before any request is made. A plaintext diff --git a/agent/ShopDbEndpointAgent.Core/AgentCredentials.cs b/agent/ShopDbEndpointAgent.Core/AgentCredentials.cs new file mode 100644 index 00000000..92863141 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentCredentials.cs @@ -0,0 +1,269 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// This PC's own endpoint credential, issued once by POST /api/endpoints/enroll. +// Readable only by SYSTEM and Administrators (DPAPI LocalMachine inside a +// protected directory). The JSON key names are a contract: SYSTEM PowerShell +// (upgrade-shopdbagent.ps1, the reporters) reads the same file with +// [System.Security.Cryptography.ProtectedData]::Unprotect(bytes, $null, 'LocalMachine'). +public sealed class AgentCredential +{ + [JsonPropertyName("credential")] public string Credential { get; set; } = ""; + [JsonPropertyName("assetid")] public int AssetId { get; set; } + [JsonPropertyName("issuedat")] public string IssuedAt { get; set; } = ""; + [JsonPropertyName("baseurl")] public string BaseUrl { get; set; } = ""; + + // First 12 chars, the same prefix the server shows. Never log more. + [JsonIgnore] + public string Prefix => Credential.Length <= 12 ? Credential : Credential[..12]; +} + +// Why the server refused an agent request. Parsed from the error envelope. +public static class AgentAuthErrorCodes +{ + public const string CredentialInvalid = "CREDENTIAL_INVALID"; + public const string SharedKeyRefused = "SHARED_KEY_REFUSED"; + public const string AssetMismatch = "ASSET_MISMATCH"; +} + +// Current auth material for outbound requests. Swaps at runtime: enrollment +// sets a credential, a CREDENTIAL_INVALID clears it, neither needs a restart. +public interface ICredentialProvider +{ + // Null = not enrolled (or the stored credential belongs to another server). + AgentCredential? Current { get; } + + // Shared fleet collector key, null when not provisioned. + string? SharedKey { get; } + + // Raised after Current changes (set or cleared). + event EventHandler? Changed; + + // Raised when the enrollment service should run now. + event EventHandler? EnrollmentNeeded; + + // Called by the auth handler for every 401/403 carrying an auth code. + // sentCredential is the credential the failed request carried, or null + // when it went out on the shared key. + void ReportRejected(string code, string? sentCredential); +} + +public sealed class AgentCredentialProvider : ICredentialProvider +{ + private readonly object gate = new(); + private readonly CredentialStore? store; + private readonly ILogger logger; + private AgentCredential? current; + + public AgentCredentialProvider(AgentConfig config, CredentialStore? store, ILogger logger) + { + this.store = store; + this.logger = logger; + SharedKey = config.HasSharedKey() ? config.ApiKey : null; + + var loaded = store?.TryLoad(); + if (loaded is not null && !SameServer(loaded.BaseUrl, config.BaseUrl)) + { + // Never send a credential to a server that did not issue it. Kept on + // disk so pointing BaseUrl back restores it; a new enroll overwrites. + logger.LogWarning( + "Stored credential {Prefix} was issued by {Issuer}, not {BaseUrl}; not using it.", + loaded.Prefix, loaded.BaseUrl, config.BaseUrl); + loaded = null; + } + current = loaded; + if (current is not null) + logger.LogInformation("Using endpoint credential {Prefix} (asset {AssetId}).", + current.Prefix, current.AssetId); + } + + public AgentCredential? Current + { + get { lock (gate) return current; } + } + + public string? SharedKey { get; } + + public event EventHandler? Changed; + public event EventHandler? EnrollmentNeeded; + + // Persist then swap. Throws when the store cannot be written: a credential + // held only in memory is lost at restart and the server will not reissue. + public void Set(AgentCredential credential) + { + store?.Save(credential); + lock (gate) current = credential; + logger.LogInformation("Endpoint credential {Prefix} stored (asset {AssetId}).", + credential.Prefix, credential.AssetId); + Changed?.Invoke(this, EventArgs.Empty); + } + + public void ReportRejected(string code, string? sentCredential) + { + if (code == AgentAuthErrorCodes.CredentialInvalid && sentCredential is not null) + { + bool cleared = false; + lock (gate) + { + // Only the credential that was refused. A late response for an + // old credential must not wipe a freshly issued one. + if (current is not null && current.Credential == sentCredential) + { + logger.LogWarning( + "Server refused credential {Prefix} (revoked or unknown); deleting it and re-enrolling.", + current.Prefix); + current = null; + cleared = true; + } + } + if (cleared) + { + try + { + store?.Delete(); + } + catch (Exception error) + { + logger.LogError(error, "Could not delete the refused credential file."); + } + Changed?.Invoke(this, EventArgs.Empty); + EnrollmentNeeded?.Invoke(this, EventArgs.Empty); + } + } + else if (code == AgentAuthErrorCodes.SharedKeyRefused && sentCredential is null && Current is null) + { + logger.LogWarning("Server refused the shared key for this PC; requesting enrollment."); + EnrollmentNeeded?.Invoke(this, EventArgs.Empty); + } + else if (code == AgentAuthErrorCodes.AssetMismatch) + { + logger.LogError( + "Server reported ASSET_MISMATCH: this PC's credential belongs to a different asset than the request named."); + } + } + + private static bool SameServer(string issued, string configured) + => string.Equals(Normalize(issued), Normalize(configured), StringComparison.OrdinalIgnoreCase); + + private static string Normalize(string url) => (url ?? "").Trim().TrimEnd('/'); +} + +// credential.bin and enroll.json under %ProgramData%\ShopDB\EndpointAgent\credential. +public sealed class CredentialStore +{ + public const string DirectoryName = "credential"; + public const string CredentialFileName = "credential.bin"; + public const string EnrollFileName = "enroll.json"; + + private const int MaxFileBytes = 64 * 1024; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly ILogger logger; + + public CredentialStore(ILogger logger) + { + this.logger = logger; + } + + private static string StoreDirectory => ProtectedFileSystem.EnsureDirectory(DirectoryName); + + public AgentCredential? TryLoad() + { + try + { + var path = Path.Combine(StoreDirectory, CredentialFileName); + if (ProtectedFileSystem.TryGetAttributes(path) is null) + return null; + + var blob = ReadAllBytesNoFollow(path); + var json = ProtectedData.Unprotect(blob, null, DataProtectionScope.LocalMachine); + var credential = JsonSerializer.Deserialize(json, JsonOptions); + if (credential is null || !credential.Credential.StartsWith("sdbe_", StringComparison.Ordinal)) + { + logger.LogWarning("Stored credential file is not a usable credential; ignoring it."); + return null; + } + return credential; + } + catch (Exception error) + { + logger.LogError(error, "Could not read the stored endpoint credential; running unenrolled."); + return null; + } + } + + public void Save(AgentCredential credential) + { + var dir = StoreDirectory; + var json = JsonSerializer.SerializeToUtf8Bytes(credential); + var blob = ProtectedData.Protect(json, null, DataProtectionScope.LocalMachine); + var target = Path.Combine(dir, CredentialFileName); + var temp = Path.Combine(dir, CredentialFileName + ".new"); + using (var file = ProtectedFileSystem.CreateFile(temp)) + { + file.Write(blob); + file.Flush(flushToDisk: true); + } + // Moved file keeps its protected DACL. + File.Move(temp, target, overwrite: true); + } + + public void Delete() + => ProtectedFileSystem.DeleteFileNoFollow(Path.Combine(StoreDirectory, CredentialFileName)); + + // The enroll nonce is generated once and kept for the life of the PC, so + // an approved request can only be claimed by the agent that made it. + public string GetOrCreateEnrollNonce() + { + var path = Path.Combine(StoreDirectory, EnrollFileName); + if (ProtectedFileSystem.TryGetAttributes(path) is not null) + { + try + { + using var doc = JsonDocument.Parse(ReadAllBytesNoFollow(path)); + if (doc.RootElement.TryGetProperty("enrollnonce", out var value) + && value.GetString() is string nonce && nonce.Length >= 32) + return nonce; + logger.LogWarning("enroll.json has no usable nonce; generating a new one."); + } + catch (Exception error) when (error is JsonException or IOException or UnauthorizedAccessException) + { + logger.LogWarning(error, "enroll.json unreadable; generating a new nonce."); + } + } + + var fresh = Base64Url(RandomNumberGenerator.GetBytes(32)); + var body = JsonSerializer.SerializeToUtf8Bytes(new + { + enrollnonce = fresh, + createdat = DateTime.UtcNow.ToString("o"), + }); + using (var file = ProtectedFileSystem.CreateFile(path)) + { + file.Write(body); + file.Flush(flushToDisk: true); + } + return fresh; + } + + internal static string Base64Url(byte[] bytes) + => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static byte[] ReadAllBytesNoFollow(string path) + { + using var stream = ProtectedFileSystem.OpenReadNoFollow(path); + if (stream.Length > MaxFileBytes) + throw new InvalidDataException($"{path} is larger than expected"); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return buffer.ToArray(); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs b/agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs new file mode 100644 index 00000000..cccf3236 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs @@ -0,0 +1,387 @@ +using System.Net; +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace ShopDbEndpointAgent.Core; + +// What an HttpClient is for. Decides pooling, timeout and auth. +public enum AgentHttpChannel +{ + Collector, + Command, + FleetAlert, + Download, + Enroll, +} + +// The ONLY place the agent builds an HttpClient or a handler. Every channel gets +// the same server certificate check (ServerPinSet) and the same per-request auth +// (AgentAuthHandler), so a new channel cannot forget either. +public sealed class AgentHttpFactory +{ + private readonly AgentConfig config; + private readonly ICredentialProvider credentials; + private readonly ILogger logger; + private readonly ServerPinSet pins; + + public AgentHttpFactory(AgentConfig config, ICredentialProvider credentials, ILogger logger) + { + this.config = config; + this.credentials = credentials; + this.logger = logger; + pins = ServerPinSet.Load(config, logger); + } + + public ICredentialProvider Credentials => credentials; + + // Caller owns and disposes the client (it disposes the handler chain). + public HttpClient CreateClient(AgentHttpChannel channel, TimeSpan? timeout = null) + { + // Lifetime cap: pooled TLS sessions were validated against the pins of + // their handshake, so recycle them for a pin change to reach new ones. + var sockets = new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, cert, _, errors) + => pins.Validate(cert, errors, channel), + }, + }; + + switch (channel) + { + case AgentHttpChannel.Command: + // Long-poll: retire pooled TLS connections faster than the + // server keep-alive, or a reused dead socket yields "unexpected + // EOF or 0 bytes from the transport stream" (1.6.2). + sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1); + break; + case AgentHttpChannel.FleetAlert: + sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90); + break; + } + + var auth = new AgentAuthHandler(credentials, logger, + sharedKeyOnly: channel == AgentHttpChannel.Enroll) + { + InnerHandler = sockets, + }; + + return new HttpClient(auth, disposeHandler: true) + { + BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"), + Timeout = timeout ?? DefaultTimeout(channel), + }; + } + + private static TimeSpan DefaultTimeout(AgentHttpChannel channel) => channel switch + { + // Claim is held open server-side (~25s); clear that plus slack. + AgentHttpChannel.Command => TimeSpan.FromSeconds(60), + AgentHttpChannel.Download => TimeSpan.FromMinutes(10), + _ => TimeSpan.FromSeconds(30), + }; +} + +// Adds exactly one auth header per request, read at send time so a credential +// issued or revoked mid-run takes effect on the next request. Watches 401/403 +// replies for the auth error codes and reports them to the provider. +public sealed class AgentAuthHandler : DelegatingHandler +{ + public const string CredentialHeader = "X-Endpoint-Credential"; + public const string SharedKeyHeader = "X-API-Key"; + + private readonly ICredentialProvider credentials; + private readonly ILogger logger; + private readonly bool sharedKeyOnly; + + public AgentAuthHandler(ICredentialProvider credentials, ILogger logger, bool sharedKeyOnly = false) + { + this.credentials = credentials; + this.logger = logger; + this.sharedKeyOnly = sharedKeyOnly; + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Remove(CredentialHeader); + request.Headers.Remove(SharedKeyHeader); + + var credential = sharedKeyOnly ? null : credentials.Current?.Credential; + if (credential is not null) + request.Headers.TryAddWithoutValidation(CredentialHeader, credential); + else if (credentials.SharedKey is string sharedKey) + request.Headers.TryAddWithoutValidation(SharedKeyHeader, sharedKey); + + var response = await base.SendAsync(request, cancellationToken); + + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + try + { + // Buffered so the caller can still read the body. + await response.Content.LoadIntoBufferAsync(); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + var code = AgentServerError.TryParse(body)?.Code; + if (!string.IsNullOrEmpty(code)) + credentials.ReportRejected(code, credential); + } + catch (Exception error) when (error is not OperationCanceledException) + { + logger.LogDebug(error, "Could not read an auth failure body"); + } + } + + return response; + } +} + +// The server's error envelope: {"status":"error","data":{"error":{"code","message"}}}. +public sealed record AgentServerError(string Code, string Message) +{ + public static AgentServerError? TryParse(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + return null; + try + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return null; + if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object) + root = data; + if (!root.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.Object) + return null; + var code = error.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String + ? c.GetString() ?? "" : ""; + var message = error.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String + ? m.GetString() ?? "" : ""; + return new AgentServerError(code, message); + } + catch (JsonException) + { + return null; + } + } +} + +// Server certificate trust. Pins are hex SHA-256 of the leaf SubjectPublicKeyInfo. +// HKLM\SOFTWARE\GE\ShopDB ServerPins is AUTHORITATIVE whenever the value exists +// in either view, even empty (empty = no pins, Windows chain validation), so a +// removed or rotated-out pin really goes away. Only when the value is absent in +// both views do appsettings serverpins and the legacy SHA-1 servercertthumbprint +// apply. With any pin in effect only a matching leaf is accepted and chain/name +// errors are ignored (self-signed servers on the air gap). Without pins: +// SslPolicyErrors.None. Registry re-read at most every RefreshInterval on the +// handshake path, so a GE-Enforce fix or a rotation lands without a restart. +public sealed class ServerPinSet +{ + public const string RegistryValue = "ServerPins"; + public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(60); + + // Present = the value exists in at least one view. + private sealed record RegistryRead(bool Present, List Values) + { + public string Raw => Present ? "registry:" + string.Join("|", Values) : "absent"; + } + + private sealed record Snapshot(HashSet SpkiPins, string? LegacyThumbprint, bool FromRegistry, string RawRegistry) + { + public string Signature => (FromRegistry ? "registry:" : "appsettings:") + + string.Join(",", SpkiPins.OrderBy(p => p, StringComparer.Ordinal)) + + (LegacyThumbprint is null ? "" : "+legacy"); + public bool Pinned => SpkiPins.Count > 0 || LegacyThumbprint is not null; + public string Source => FromRegistry ? "registry" : "appsettings.json"; + } + + private readonly HashSet configPins; + private readonly string? legacyThumbprint; + private readonly ILogger logger; + private readonly Func readRegistry; + private readonly object refreshLock = new(); + private volatile Snapshot current; + private long nextRefreshTicks; + + private ServerPinSet(HashSet configPins, string? legacyThumbprint, ILogger logger, + Func readRegistry) + { + this.configPins = configPins; + this.legacyThumbprint = legacyThumbprint; + this.logger = logger; + this.readRegistry = readRegistry; + current = Build(readRegistry(false) ?? new RegistryRead(false, new List())); + nextRefreshTicks = Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds; + } + + public bool IsPinned => Current().Pinned; + + public static ServerPinSet Load(AgentConfig config, ILogger logger) + { + var pins = new HashSet(StringComparer.Ordinal); + foreach (var pin in config.ServerPins ?? new List()) + AddPin(pins, pin, "appsettings.json", logger); + + string? thumbprint = null; + if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint)) + thumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "").Trim().ToUpperInvariant(); + + var set = new ServerPinSet(pins, thumbprint, logger, refresh => ReadRegistryPins(logger, refresh)); + var snapshot = set.current; + logger.LogInformation( + "Server certificate trust from {Source}: {Pins} SPKI pin(s){Legacy}; {Mode}.", + snapshot.Source, snapshot.SpkiPins.Count, + snapshot.LegacyThumbprint is null ? "" : " + legacy thumbprint", + snapshot.Pinned ? "pinned" : "Windows chain validation"); + if (snapshot.FromRegistry && (pins.Count > 0 || thumbprint is not null)) + logger.LogInformation( + "Registry {Value} is set; ignoring appsettings.json serverpins/servercertthumbprint.", RegistryValue); + return set; + } + + // Cheap tick compare per handshake; registry read only when the window lapsed. + private Snapshot Current() + { + if (Environment.TickCount64 < Interlocked.Read(ref nextRefreshTicks)) + return current; + lock (refreshLock) + { + if (Environment.TickCount64 < nextRefreshTicks) + return current; + var read = readRegistry(true); + Interlocked.Exchange(ref nextRefreshTicks, + Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds); + var previous = current; + if (read is null || read.Raw == previous.RawRegistry) + return previous; + + var next = Build(read); + current = next; + if (next.Signature != previous.Signature) + logger.LogInformation( + "Server pins changed, source {Source}: {Count} SPKI pin(s) [{Pins}]{Legacy}; {Mode}.", + next.Source, + next.SpkiPins.Count, + string.Join(", ", next.SpkiPins.OrderBy(p => p, StringComparer.Ordinal).Select(p => p[..12])), + next.LegacyThumbprint is null ? "" : " + legacy thumbprint", + next.Pinned ? "pinned" : "Windows chain validation"); + return next; + } + } + + // Malformed-pin warnings only fire when the raw registry text changes. + private Snapshot Build(RegistryRead read) + { + if (!read.Present) + return new Snapshot(new HashSet(configPins, StringComparer.Ordinal), + legacyThumbprint, FromRegistry: false, read.Raw); + + var pins = new HashSet(StringComparer.Ordinal); + foreach (var pin in read.Values) + AddPin(pins, pin, "registry", logger); + return new Snapshot(pins, LegacyThumbprint: null, FromRegistry: true, read.Raw); + } + + public bool Validate(X509Certificate? certificate, SslPolicyErrors errors, AgentHttpChannel channel) + { + if (certificate is null) + { + logger.LogWarning("Server provided no certificate ({Channel})", channel); + return false; + } + + var snapshot = Current(); + if (!snapshot.Pinned) + { + if (errors == SslPolicyErrors.None) + return true; + logger.LogWarning( + "Server certificate rejected on {Channel} channel: {Errors} ({Subject})", + channel, errors, certificate.Subject); + return false; + } + + var owned = certificate as X509Certificate2; + var leaf = owned ?? new X509Certificate2(certificate); + try + { + var spki = SpkiSha256(leaf); + if (snapshot.SpkiPins.Contains(spki)) + return true; + if (snapshot.LegacyThumbprint is not null + && string.Equals(leaf.GetCertHashString(), snapshot.LegacyThumbprint, StringComparison.OrdinalIgnoreCase)) + return true; + + logger.LogError( + "Server certificate does not match any pin on {Channel} channel: spki {Spki} ({Subject})", + channel, spki, leaf.Subject); + return false; + } + finally + { + if (owned is null) + leaf.Dispose(); + } + } + + public static string SpkiSha256(X509Certificate2 certificate) + => Convert.ToHexString(SHA256.HashData(certificate.PublicKey.ExportSubjectPublicKeyInfo())) + .ToLowerInvariant(); + + private static void AddPin(HashSet pins, string? raw, string source, ILogger logger) + { + var pin = (raw ?? "").Replace(":", "").Replace(" ", "").Trim().ToLowerInvariant(); + if (pin.Length == 0) + return; + if (pin.Length != 64 || !pin.All(Uri.IsHexDigit)) + { + logger.LogWarning("Ignoring malformed server pin from {Source}: not 64 hex chars", source); + return; + } + pins.Add(pin); + } + + // Startup keeps whatever views read. A refresh returns null on any failed view + // so a transient denial cannot silently swap the pin source (and the server with it). + private static RegistryRead? ReadRegistryPins(ILogger logger, bool refresh) + { + var present = false; + var found = new List(); + foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) + { + try + { + using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view); + using var key = machine.OpenSubKey(AgentConfigProvider.RegistryPath); + var value = key?.GetValue(RegistryValue); + if (value is not null) + present = true; + switch (value) + { + case string[] many: + found.AddRange(many); + break; + case string one: + found.AddRange(one.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)); + break; + } + } + catch (Exception error) + { + if (refresh) + { + logger.LogDebug(error, "Registry re-read of {Value} in {View} view failed; keeping pins", RegistryValue, view); + return null; + } + logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view); + } + } + return new RegistryRead(present, found); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/AgentPipeClient.cs b/agent/ShopDbEndpointAgent.Core/AgentPipeClient.cs new file mode 100644 index 00000000..2b3c2bc1 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentPipeClient.cs @@ -0,0 +1,180 @@ +using System.IO.Pipes; +using System.Security.Principal; +using System.Text; +using System.Text.Json; + +namespace ShopDbEndpointAgent.Core; + +// Wire constants for the local agent pipe. One UTF-8 JSON object per line: +// request, response, close. +public static class AgentPipeProtocol +{ + public const string PipeName = "ShopDB.EndpointAgent.v1"; + public const int MaxRequestBytes = 64 * 1024; + public const int MaxResponseBytes = 4 * 1024 * 1024; + public static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(3); + public static readonly TimeSpan RequestReadTimeout = TimeSpan.FromSeconds(10); +} + +// Ok/Error as the agent answered. Data is the whole response object (ping +// fields such as agentversion/enrolled/assetid live there). Data is null when +// the agent could not be reached or answered garbage; Error then says why. +public sealed record PipeResponse(bool Ok, string? Error, JsonElement? Data); + +public sealed class AgentPipeException : Exception +{ + public AgentPipeException(string message) : base(message) + { + } + + public AgentPipeException(string message, Exception inner) : base(message, inner) + { + } +} + +// Client for the SYSTEM agent's local pipe. Used by the tray (which holds no +// secret) and by shopdbagent.exe --once/--report when not running as SYSTEM. +// Only GetAlertsAsync throws; the others report failure through PipeResponse. +public sealed class AgentPipeClient +{ + // Send Status waits on the user-context refresh plus the upload retries. + private static readonly TimeSpan LongOperationTimeout = TimeSpan.FromMinutes(3); + private static readonly TimeSpan ShortOperationTimeout = TimeSpan.FromSeconds(45); + + private static readonly JsonSerializerOptions AlertOptions = new() + { + Converters = { new TolerantFleetAlertListConverter() }, + }; + + public Task PingAsync(CancellationToken cancellationToken) + => SendSafeAsync(new { op = "ping" }, ShortOperationTimeout, cancellationToken); + + public Task SendStatusAsync(CancellationToken cancellationToken) + => SendSafeAsync(new { op = "sendstatus" }, LongOperationTimeout, cancellationToken); + + public Task SendReportAsync(string spoolPath, CancellationToken cancellationToken) + => SendSafeAsync(new { op = "sendreport", spoolpath = spoolPath }, LongOperationTimeout, + cancellationToken); + + public async Task> GetAlertsAsync(CancellationToken cancellationToken) + { + var response = await SendAsync(new { op = "alerts" }, ShortOperationTimeout, cancellationToken); + if (!response.Ok) + throw new AgentPipeException(response.Error ?? "agent refused the alerts request"); + + if (response.Data is not JsonElement data + || !data.TryGetProperty("alerts", out var alerts) + || alerts.ValueKind != JsonValueKind.Array) + throw new AgentPipeException("agent response has no alerts array"); + + try + { + return alerts.Deserialize>(AlertOptions) ?? new List(); + } + catch (JsonException error) + { + throw new AgentPipeException("agent alerts could not be parsed", error); + } + } + + public Task AckAlertAsync(int notificationId, CancellationToken cancellationToken) + => SendSafeAsync(new { op = "ackalert", notificationid = notificationId }, + ShortOperationTimeout, cancellationToken); + + private static async Task SendSafeAsync( + object request, TimeSpan timeout, CancellationToken cancellationToken) + { + try + { + return await SendAsync(request, timeout, cancellationToken); + } + catch (AgentPipeException error) + { + return new PipeResponse(false, error.Message, null); + } + } + + // Throws AgentPipeException on any transport or framing failure, and + // OperationCanceledException only when the caller cancelled. + private static async Task SendAsync( + object request, TimeSpan timeout, CancellationToken cancellationToken) + { + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + var token = timeoutSource.Token; + + try + { + // Identification: the agent may learn who we are, never act as us. + await using var pipe = new NamedPipeClientStream(".", AgentPipeProtocol.PipeName, + PipeDirection.InOut, PipeOptions.Asynchronous, TokenImpersonationLevel.Identification); + + try + { + await pipe.ConnectAsync((int)AgentPipeProtocol.ConnectTimeout.TotalMilliseconds, token); + } + catch (TimeoutException error) + { + throw new AgentPipeException("agent not reachable (is the ShopDB Endpoint Agent service running?)", error); + } + + var line = JsonSerializer.SerializeToUtf8Bytes(request); + await pipe.WriteAsync(line, token); + await pipe.WriteAsync(new[] { (byte)'\n' }, token); + await pipe.FlushAsync(token); + + var body = await ReadLineAsync(pipe, AgentPipeProtocol.MaxResponseBytes, token) + ?? throw new AgentPipeException("agent closed the pipe without answering"); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + throw new AgentPipeException("agent response is not a JSON object"); + + var ok = root.TryGetProperty("ok", out var okValue) && okValue.ValueKind == JsonValueKind.True; + var errorText = root.TryGetProperty("error", out var errorValue) + && errorValue.ValueKind == JsonValueKind.String + ? errorValue.GetString() : null; + return new PipeResponse(ok, ok ? null : errorText ?? "agent reported failure", root.Clone()); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException error) + { + throw new AgentPipeException("agent did not answer in time", error); + } + catch (AgentPipeException) + { + throw; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException + or InvalidDataException) + { + throw new AgentPipeException("agent pipe failed: " + error.Message, error); + } + } + + // One newline-terminated UTF-8 line, or null on EOF before any byte. + // Throws InvalidDataException past maxBytes. Shared with the server. + internal static async Task ReadLineAsync(Stream stream, int maxBytes, CancellationToken token) + { + var buffer = new MemoryStream(); + var chunk = new byte[4096]; + while (true) + { + var read = await stream.ReadAsync(chunk, token); + if (read == 0) + return buffer.Length == 0 ? null : Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length); + + var newline = Array.IndexOf(chunk, (byte)'\n', 0, read); + var take = newline >= 0 ? newline : read; + if (buffer.Length + take > maxBytes) + throw new InvalidDataException($"pipe message exceeds {maxBytes} bytes"); + buffer.Write(chunk, 0, take); + if (newline >= 0) + return Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length); + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/AgentPipeServer.cs b/agent/ShopDbEndpointAgent.Core/AgentPipeServer.cs new file mode 100644 index 00000000..f428028a --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentPipeServer.cs @@ -0,0 +1,562 @@ +using System.IO.Pipes; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace ShopDbEndpointAgent.Core; + +// \\.\pipe\ShopDB.EndpointAgent.v1: the tray's only way to reach the server. +// The tray holds no secret; it asks the SYSTEM agent to send status, upload a +// report, list alerts or acknowledge one, and the agent does it with its own +// credential. Protocol in AgentPipeProtocol / AgentPipeClient. +// +// ACL: SYSTEM and Administrators full control, INTERACTIVE read/write (no +// create-instance, so a user cannot add instances to our pipe). The first +// instance is created with FirstPipeInstance so a process that squatted the +// name before the service started is refused rather than joined. +public sealed class AgentPipeServer : BackgroundService +{ + public const int MaxInstances = 8; + + private const int MaxScreenshotBytes = 25 * 1024 * 1024; + private static readonly TimeSpan LongOperationTimeout = TimeSpan.FromMinutes(2); + private static readonly TimeSpan ShortOperationTimeout = TimeSpan.FromSeconds(40); + + private static readonly JsonSerializerOptions PayloadOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly AgentConfig config; + private readonly TelemetryWorker telemetry; + private readonly CollectorClient collector; + private readonly FleetAlertClient alerts; + private readonly ICredentialProvider credentials; + private readonly string agentVersion; + private readonly ILogger logger; + private readonly SemaphoreSlim statusGate = new(1, 1); + private readonly SemaphoreSlim reportGate = new(1, 1); + + public AgentPipeServer(AgentConfig config, TelemetryWorker telemetry, CollectorClient collector, + FleetAlertClient alerts, ICredentialProvider credentials, string agentVersion, + ILogger logger) + { + this.config = config; + this.telemetry = telemetry; + this.collector = collector; + this.alerts = alerts; + this.credentials = credentials; + this.agentVersion = agentVersion; + this.logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var slots = new SemaphoreSlim(MaxInstances, MaxInstances); + bool first = true; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await slots.WaitAsync(stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + + NamedPipeServerStream pipe; + try + { + pipe = CreateInstance(first); + } + catch (Exception error) + { + slots.Release(); + logger.LogError(error, + first + ? "Could not create the agent pipe; another process may own the name. Retrying in 30s." + : "Could not create an agent pipe instance. Retrying in 30s."); + if (!await SafeDelay(TimeSpan.FromSeconds(30), stoppingToken)) + break; + continue; + } + + if (first) + { + first = false; + logger.LogInformation("Agent pipe listening on {Pipe}", AgentPipeProtocol.PipeName); + RestrictAppSettingsAcl(); + } + + try + { + await pipe.WaitForConnectionAsync(stoppingToken); + } + catch (Exception error) + { + pipe.Dispose(); + slots.Release(); + if (stoppingToken.IsCancellationRequested) + break; + logger.LogWarning(error, "Agent pipe connection failed"); + if (!await SafeDelay(TimeSpan.FromSeconds(1), stoppingToken)) + break; + continue; + } + + _ = Task.Run(async () => + { + try + { + await HandleConnectionAsync(pipe, stoppingToken); + } + catch (Exception error) + { + logger.LogDebug(error, "Agent pipe request ended with an error"); + } + finally + { + pipe.Dispose(); + slots.Release(); + } + }, CancellationToken.None); + } + } + + private static NamedPipeServerStream CreateInstance(bool first) + { + var security = new PipeSecurity(); + security.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + PipeAccessRights.FullControl, AccessControlType.Allow)); + security.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), + PipeAccessRights.FullControl, AccessControlType.Allow)); + security.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.InteractiveSid, null), + PipeAccessRights.ReadWrite, AccessControlType.Allow)); + + var options = PipeOptions.Asynchronous; + if (first) + options |= PipeOptions.FirstPipeInstance; + + return NamedPipeServerStreamAcl.Create(AgentPipeProtocol.PipeName, PipeDirection.InOut, + MaxInstances, PipeTransmissionMode.Byte, options, 4096, 4096, security); + } + + private async Task HandleConnectionAsync(NamedPipeServerStream pipe, CancellationToken stoppingToken) + { + string? line; + using (var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken)) + { + readTimeout.CancelAfter(AgentPipeProtocol.RequestReadTimeout); + try + { + line = await AgentPipeClient.ReadLineAsync(pipe, AgentPipeProtocol.MaxRequestBytes, + readTimeout.Token); + } + catch (InvalidDataException) + { + await WriteAsync(pipe, Failure("request too large"), stoppingToken); + return; + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + logger.LogDebug("Agent pipe client sent no request within {Timeout}", + AgentPipeProtocol.RequestReadTimeout); + return; + } + } + + if (line is null) + return; + + // Impersonation needs a completed read first, which we just did. + var (callerName, callerSid) = IdentifyCaller(pipe); + + string op; + JsonElement request; + try + { + using var doc = JsonDocument.Parse(line); + request = doc.RootElement.Clone(); + op = request.ValueKind == JsonValueKind.Object + && request.TryGetProperty("op", out var opValue) && opValue.ValueKind == JsonValueKind.String + ? opValue.GetString() ?? "" + : ""; + } + catch (JsonException) + { + await WriteAsync(pipe, Failure("request is not valid JSON"), stoppingToken); + return; + } + + object response; + try + { + response = op switch + { + "ping" => Ping(), + "sendstatus" => await SendStatusAsync(callerName, stoppingToken), + "sendreport" => await SendReportAsync(request, callerName, callerSid, stoppingToken), + "alerts" => await ListAlertsAsync(stoppingToken), + "ackalert" => await AckAlertAsync(request, callerName, stoppingToken), + _ => Failure("unknown op"), + }; + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + response = Failure("the agent timed out talking to ShopDB"); + } + + await WriteAsync(pipe, response, stoppingToken); + } + + private (string? Name, SecurityIdentifier? Sid) IdentifyCaller(NamedPipeServerStream pipe) + { + string? name = null; + SecurityIdentifier? sid = null; + try + { + name = pipe.GetImpersonationUserName(); + } + catch (Exception error) + { + logger.LogDebug(error, "GetImpersonationUserName failed"); + } + try + { + pipe.RunAsClient(() => + { + using var identity = WindowsIdentity.GetCurrent(TokenAccessLevels.Query); + sid = identity.User; + }); + } + catch (Exception error) + { + logger.LogDebug(error, "Could not read the pipe caller's SID"); + } + return (name, sid); + } + + private object Ping() + { + var current = credentials.Current; + return new + { + ok = true, + agentversion = agentVersion, + enrolled = current is not null, + assetid = current?.AssetId, + }; + } + + private async Task SendStatusAsync(string? caller, CancellationToken stoppingToken) + { + if (!await statusGate.WaitAsync(0, stoppingToken)) + return Failure("a status send is already running"); + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + timeout.CancelAfter(LongOperationTimeout); + logger.LogInformation("Status send requested over the pipe by {Caller}", caller); + var result = await telemetry.RunOnceAsync(config.AdoptOnFirstRun, timeout.Token); + logger.LogInformation("Pipe status send complete: action={Action}", result.Action); + return Success(); + } + catch (CollectorException error) + { + logger.LogWarning("Pipe status send failed: HTTP {Status}", error.StatusCode); + return Failure(ServerFailure(error)); + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException + && !stoppingToken.IsCancellationRequested) + { + logger.LogWarning(error, "Pipe status send failed"); + return Failure("could not reach ShopDB"); + } + finally + { + statusGate.Release(); + } + } + + // Does what --report does, but only with files inside the CALLER's own + // profile: the pipe is open to every interactive user and this process is + // SYSTEM, so an unchecked path would upload any file SYSTEM can read. The + // sidecar is not deleted here (the tray removes it); deleting in a user- + // writable tree as SYSTEM is a redirection target. + private async Task SendReportAsync(JsonElement request, string? caller, + SecurityIdentifier? callerSid, CancellationToken stoppingToken) + { + if (callerSid is null) + return Failure("could not identify the calling user"); + + var spoolPath = request.TryGetProperty("spoolpath", out var pathValue) + && pathValue.ValueKind == JsonValueKind.String + ? pathValue.GetString() + : null; + if (string.IsNullOrWhiteSpace(spoolPath)) + return Failure("spoolpath is required"); + + var profile = ProfilePathFor(callerSid); + if (profile is null) + return Failure("no local profile for the calling user"); + + if (!await reportGate.WaitAsync(0, stoppingToken)) + return Failure("a report upload is already running"); + try + { + OperatorReportPayload? payload; + try + { + using var sidecar = OpenCallerFile(spoolPath, profile, AgentPipeProtocol.MaxRequestBytes); + payload = await JsonSerializer.DeserializeAsync( + sidecar, PayloadOptions, stoppingToken); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException + or JsonException or ArgumentException + or NotSupportedException) + { + logger.LogWarning("Refused report sidecar from {Caller}: {Reason}", caller, error.Message); + return Failure("report file refused: " + error.Message); + } + if (payload is null) + return Failure("report file is empty"); + + FileStream? screenshot = null; + try + { + if (!string.IsNullOrWhiteSpace(payload.ScreenshotPath)) + { + if (!payload.ScreenshotPath.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + return Failure("screenshot must be a .png file"); + try + { + screenshot = OpenCallerFile(payload.ScreenshotPath, profile, MaxScreenshotBytes); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException) + { + logger.LogWarning("Refused report screenshot from {Caller}: {Reason}", + caller, error.Message); + return Failure("screenshot refused: " + error.Message); + } + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + timeout.CancelAfter(LongOperationTimeout); + var uploaded = await collector.UploadOperatorReportAsync( + payload.Reason, payload.Details, screenshot, + payload.ScreenshotPath is null ? null : Path.GetFileName(payload.ScreenshotPath), + Environment.MachineName, timeout.Token); + logger.LogInformation("Operator report from {Caller} uploaded: id={ReportId}", + caller, uploaded?.OperatorReportId); + return Success(); + } + catch (CollectorException error) + { + logger.LogWarning("Operator report upload failed: HTTP {Status}", error.StatusCode); + return Failure(ServerFailure(error)); + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException + && !stoppingToken.IsCancellationRequested) + { + logger.LogWarning(error, "Operator report upload failed"); + return Failure("could not reach ShopDB"); + } + finally + { + screenshot?.Dispose(); + } + } + finally + { + reportGate.Release(); + } + } + + private async Task ListAlertsAsync(CancellationToken stoppingToken) + { + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + timeout.CancelAfter(ShortOperationTimeout); + var list = await alerts.FetchAlertsAsync(Environment.MachineName, timeout.Token); + return new { ok = true, alerts = list.ToArray() }; + } + catch (CollectorException error) + { + logger.LogDebug("Alert fetch for the tray failed: HTTP {Status}", error.StatusCode); + return Failure(ServerFailure(error)); + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException or JsonException + && !stoppingToken.IsCancellationRequested) + { + logger.LogDebug(error, "Alert fetch for the tray failed"); + return Failure("could not reach ShopDB"); + } + } + + private async Task AckAlertAsync(JsonElement request, string? caller, CancellationToken stoppingToken) + { + if (!request.TryGetProperty("notificationid", out var idValue) + || idValue.ValueKind != JsonValueKind.Number + || !idValue.TryGetInt32(out var notificationId) + || notificationId <= 0) + return Failure("notificationid must be a positive integer"); + + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + timeout.CancelAfter(ShortOperationTimeout); + // 0 = "the caller": the server resolves it from credential or hostname. + var assetId = credentials.Current?.AssetId ?? 0; + await alerts.AcknowledgeAlertAsync(notificationId, assetId, Environment.MachineName, timeout.Token); + logger.LogInformation("Alert {NotificationId} acknowledged by {Caller}", notificationId, caller); + return Success(); + } + catch (CollectorException error) + { + logger.LogWarning("Alert {NotificationId} acknowledge failed: HTTP {Status}", + notificationId, error.StatusCode); + return Failure(ServerFailure(error)); + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException + && !stoppingToken.IsCancellationRequested) + { + logger.LogWarning(error, "Alert {NotificationId} acknowledge failed", notificationId); + return Failure("could not reach ShopDB"); + } + } + + // Profile root for a SID from ProfileList. Null when absent. + private static string? ProfilePathFor(SecurityIdentifier sid) + { + try + { + using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using var key = machine.OpenSubKey( + @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\" + sid.Value); + if (key?.GetValue("ProfileImagePath") is not string raw || string.IsNullOrWhiteSpace(raw)) + return null; + return Path.GetFullPath(Environment.ExpandEnvironmentVariables(raw)).TrimEnd('\\'); + } + catch + { + return null; + } + } + + // Open a caller-named file only if it sits inside the caller's profile, no + // directory from the profile down is a reparse point, the file itself is + // not one, and the opened handle's final path is still inside the profile. + private static FileStream OpenCallerFile(string path, string profile, int maxBytes) + { + if (!Path.IsPathFullyQualified(path) || path.StartsWith(@"\\", StringComparison.Ordinal)) + throw new UnauthorizedAccessException("path must be a local absolute path"); + + var full = Path.GetFullPath(path); + var root = profile + @"\"; + if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + throw new UnauthorizedAccessException("path is outside the caller's profile"); + + var directory = Path.GetDirectoryName(full); + while (directory is not null && directory.Length >= profile.Length) + { + if (ProtectedFileSystem.IsReparsePoint(directory)) + throw new UnauthorizedAccessException("path crosses a reparse point"); + directory = Path.GetDirectoryName(directory); + } + + var stream = ProtectedFileSystem.OpenReadNoFollow(full); + try + { + var final = ProtectedFileSystem.FinalPath(stream.SafeFileHandle); + if (final is null || !final.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + throw new UnauthorizedAccessException("path resolves outside the caller's profile"); + if (stream.Length > maxBytes) + throw new IOException($"file is larger than {maxBytes} bytes"); + return stream; + } + catch + { + stream.Dispose(); + throw; + } + } + + // Only SYSTEM (the service) and Administrators read appsettings.json now: + // no user-context process needs it once the tray talks through this pipe. + private void RestrictAppSettingsAcl() + { + try + { + using var identity = WindowsIdentity.GetCurrent(); + if (!identity.IsSystem) + return; + + var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (ProtectedFileSystem.TryGetAttributes(path) is not FileAttributes attributes + || attributes.HasFlag(FileAttributes.ReparsePoint)) + return; + + var security = new FileSecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + FileSystemRights.FullControl, AccessControlType.Allow)); + security.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), + FileSystemRights.FullControl, AccessControlType.Allow)); + new FileInfo(path).SetAccessControl(security); + logger.LogInformation("appsettings.json restricted to SYSTEM and Administrators."); + } + catch (Exception error) + { + logger.LogWarning(error, "Could not restrict the appsettings.json ACL"); + } + } + + private static string ServerFailure(CollectorException error) + => string.IsNullOrEmpty(error.ServerCode) + ? $"ShopDB returned HTTP {error.StatusCode}" + : $"ShopDB returned HTTP {error.StatusCode} {error.ServerCode}"; + + private static object Success() => new { ok = true }; + + private static object Failure(string error) => new { ok = false, error }; + + private static async Task WriteAsync(Stream pipe, object response, CancellationToken token) + { + try + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(response); + await pipe.WriteAsync(bytes, token); + await pipe.WriteAsync(new[] { (byte)'\n' }, token); + await pipe.FlushAsync(token); + } + catch (IOException) + { + // Client went away. + } + } + + private static async Task SafeDelay(TimeSpan delay, CancellationToken token) + { + try + { + await Task.Delay(delay, token); + return true; + } + catch (OperationCanceledException) + { + return false; + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs index 6f119139..3ea688da 100644 --- a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs +++ b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs @@ -5,8 +5,9 @@ using Microsoft.Extensions.Logging; namespace ShopDbEndpointAgent.Core; -// Posts one EndpointReport to POST {baseurl}/api/collector/endpoints with the -// X-API-Key header (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping +// Posts one EndpointReport to POST {baseurl}/api/endpoints/collect (per-PC +// credential) or /api/collector/endpoints (shared key), auth added by +// AgentHttpFactory (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping // the ShopDB envelope { status, data, meta } to return the CollectorResponse. // // Resilience: transient failures (network errors, timeouts, 5xx, 429) are @@ -27,17 +28,15 @@ public sealed class CollectorClient : IDisposable private readonly int maxAttempts; private readonly Random jitter = new(); - public CollectorClient(AgentConfig config, ILogger logger, + private readonly ICredentialProvider credentials; + + public CollectorClient(AgentHttpFactory httpFactory, ILogger logger, int maxAttempts = 4) { this.logger = logger; this.maxAttempts = Math.Max(1, maxAttempts); - http = new HttpClient - { - BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"), - Timeout = TimeSpan.FromSeconds(30), - }; - http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey); + credentials = httpFactory.Credentials; + http = httpFactory.CreateClient(AgentHttpChannel.Collector); } public async Task SendAsync( @@ -76,8 +75,12 @@ public sealed class CollectorClient : IDisposable private async Task SendOnceAsync( EndpointReport report, CancellationToken token) { - using var response = await http.PostAsJsonAsync( - "api/collector/endpoints", report, JsonOptions, token); + // Enrolled agents post to the credential route; the shared key keeps + // the core collector route so this build still reports to an older server. + var route = credentials.Current is not null + ? "api/endpoints/collect" + : "api/collector/endpoints"; + using var response = await http.PostAsJsonAsync(route, report, JsonOptions, token); var body = await response.Content.ReadAsStringAsync(token); if (!response.IsSuccessStatusCode) @@ -91,7 +94,7 @@ public sealed class CollectorClient : IDisposable // Fetch the server-resolved watch list (policy-attached processes/services) // for this hostname, GET /api/endpoints/monitors/resolved. Shares the same - // X-API-Key credential as telemetry. Best-effort: returns null on any + // auth as telemetry. Best-effort: returns null on any // failure so the caller falls back to the last-known/local list rather than // blanking monitoring on a transient blip. A single attempt, no retry - the // next telemetry cycle retries naturally, and a stale-but-usable watch list @@ -134,49 +137,77 @@ public sealed class CollectorClient : IDisposable // Uploads one operator issue report (free-text reason/details plus an // optional PNG screenshot) to POST /api/endpoints/operatorreports as - // multipart/form-data, authorized by the same X-API-Key collector key as - // telemetry. The server resolves this hostname to its asset. A single + // multipart/form-data, authorized the same way as telemetry. The server + // resolves this hostname (or the credential) to its asset. A single // attempt: the tray shows the operator the outcome and can retry by hand, // and a stuck retry loop holding a screenshot in memory is worse than a // clear failure. Returns the parsed upload response (new row id) or throws. public async Task UploadOperatorReportAsync( string reason, string details, string? screenshotPath, string hostname, CancellationToken token) + { + FileStream? imageStream = null; + try + { + if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath)) + imageStream = File.OpenRead(screenshotPath); + return await UploadOperatorReportAsync(reason, details, imageStream, + screenshotPath is null ? null : Path.GetFileName(screenshotPath), + hostname, token); + } + finally + { + imageStream?.Dispose(); + } + } + + // Same upload from an already opened screenshot stream. The pipe path opens + // the file itself (no reparse follow, final path checked) and hands it here. + public async Task UploadOperatorReportAsync( + string reason, string details, Stream? screenshot, string? screenshotName, + string hostname, CancellationToken token) { using var form = new MultipartFormDataContent(); form.Add(new StringContent(reason ?? string.Empty), "reason"); form.Add(new StringContent(details ?? string.Empty), "details"); form.Add(new StringContent(hostname ?? string.Empty), "hostname"); - StreamContent? imageContent = null; - FileStream? imageStream = null; - try + if (screenshot is not null) { - if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath)) - { - imageStream = File.OpenRead(screenshotPath); - imageContent = new StreamContent(imageStream); - imageContent.Headers.ContentType = - new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); - form.Add(imageContent, "screenshot", - Path.GetFileName(screenshotPath)); - } - - using var response = await http.PostAsync( - "api/endpoints/operatorreports", form, token); - var body = await response.Content.ReadAsStringAsync(token); - if (!response.IsSuccessStatusCode) - throw new CollectorException((int)response.StatusCode, body); - - var envelope = JsonSerializer.Deserialize( - body, JsonOptions); - return envelope?.Data; - } - finally - { - imageContent?.Dispose(); - imageStream?.Dispose(); + // Not disposed with the form: the caller owns the stream. + var imageContent = new StreamContent(new NonClosingStream(screenshot)); + imageContent.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + form.Add(imageContent, "screenshot", screenshotName ?? "screenshot.png"); } + + using var response = await http.PostAsync( + "api/endpoints/operatorreports", form, token); + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + throw new CollectorException((int)response.StatusCode, body); + + var envelope = JsonSerializer.Deserialize( + body, JsonOptions); + return envelope?.Data; + } + + private sealed class NonClosingStream : Stream + { + private readonly Stream inner; + public NonClosingStream(Stream inner) => this.inner = inner; + public override bool CanRead => inner.CanRead; + public override bool CanSeek => inner.CanSeek; + public override bool CanWrite => false; + public override long Length => inner.Length; + public override long Position { get => inner.Position; set => inner.Position = value; } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => inner.ReadAsync(buffer, cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } public void Dispose() => http.Dispose(); @@ -193,9 +224,15 @@ public sealed class CollectorException : Exception StatusCode >= 400 && StatusCode < 500 && StatusCode != (int)HttpStatusCode.TooManyRequests; + public string Body { get; } + + // Error code from the server's JSON envelope (e.g. CREDENTIAL_INVALID), or null. + public string? ServerCode => AgentServerError.TryParse(Body)?.Code; + public CollectorException(int statusCode, string body) : base($"collector returned HTTP {statusCode}: {body}") { StatusCode = statusCode; + Body = body; } } diff --git a/agent/ShopDbEndpointAgent.Core/CommandClient.cs b/agent/ShopDbEndpointAgent.Core/CommandClient.cs index f1ad6d6e..c2f0c7bf 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandClient.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandClient.cs @@ -10,9 +10,8 @@ namespace ShopDbEndpointAgent.Core; // GET /api/endpoints/commands/next, which the server holds open until a command // is queued (HTTP 200) or the hold window elapses (HTTP 204). After running the // claimed command locally the agent posts the outcome to -// POST /api/endpoints/commands/{id}/result. Both share the endpoints collector -// key via X-API-Key, so the command channel rides the same kill switch as -// telemetry and adds no second secret. +// POST /api/endpoints/commands/{id}/result. Both use the same auth as telemetry +// (per-PC credential, else the shared collector key), added by AgentHttpFactory. public sealed class CommandClient : IDisposable { private static readonly JsonSerializerOptions JsonOptions = new() @@ -22,33 +21,13 @@ public sealed class CommandClient : IDisposable }; private readonly HttpClient http; - private readonly SocketsHttpHandler handler; private readonly ILogger logger; - public CommandClient(AgentConfig config, ILogger logger) + public CommandClient(AgentHttpFactory httpFactory, ILogger logger) { this.logger = logger; - // The command channel long-polls: the server holds each claim open for a - // hold window and then, between windows, the pooled TLS connection sits - // idle. Gunicorn's keep-alive closes an idle socket after a couple of - // seconds, so a connection reused after that yields "unexpected EOF or 0 - // bytes from the transport stream" on the next TLS handshake. Retire - // pooled connections faster than the server's keep-alive so a socket the - // server has already dropped is never handed back out; the lifetime cap - // is a hard backstop so no connection lingers indefinitely. - handler = new SocketsHttpHandler - { - PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1), - PooledConnectionLifetime = TimeSpan.FromMinutes(2), - }; - http = new HttpClient(handler, disposeHandler: true) - { - BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"), - // Longer than the collector client: the claim request is held open - // server-side (~25s), so the read timeout must clear that plus slack. - Timeout = TimeSpan.FromSeconds(60), - }; - http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey); + // Pooling tuned for the long-poll lives in AgentHttpFactory (Command). + http = httpFactory.CreateClient(AgentHttpChannel.Command); } // Claim the next pending command for this hostname, or null when the server diff --git a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs index 7db7a198..67b82f31 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs @@ -1,6 +1,10 @@ using System.Diagnostics; +using System.Globalization; +using System.Security.AccessControl; using System.Security.Cryptography; +using System.Security.Principal; using System.ServiceProcess; +using System.Text; using Microsoft.Extensions.Logging; namespace ShopDbEndpointAgent.Core; @@ -64,19 +68,28 @@ public sealed class CommandExecutor // before they are honored. Local launchPaths always win over a server path. private readonly LaunchPathPolicy launchPathPolicy; + // Validates the free-text task path carried by scheduledtask_run before the + // agent will start anything. Fails closed: an unapproved path never runs. + private readonly ScheduledTaskPolicy scheduledTaskPolicy; + // Held for update_app: the agent joins InstallPath to BaseUrl and downloads - // the pinned installer with its own collector credential (ApiKey), so the - // installer body never rides the command wire. + // the pinned installer with its own agent auth, so the installer body never + // rides the command wire. private readonly AgentConfig config; + // Builds the script/package download clients (pins + auth). + private readonly AgentHttpFactory httpFactory; + public CommandExecutor(ILogger logger, - AgentConfig config, TimeSpan? startTimeout = null) + AgentConfig config, AgentHttpFactory httpFactory, TimeSpan? startTimeout = null) { this.logger = logger; + this.httpFactory = httpFactory; this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30); this.config = config ?? new AgentConfig(); this.launchPaths = this.config.ResolveLaunchPaths(); this.launchPathPolicy = new LaunchPathPolicy(this.config.ApprovedLaunchRoots); + this.scheduledTaskPolicy = new ScheduledTaskPolicy(this.config.ApprovedTaskPaths); } public async Task ExecuteAsync( @@ -116,16 +129,32 @@ public sealed class CommandExecutor return RequireTarget(action, target) ?? await ProcessStart(target, token); + // Start an already-registered scheduled task by path. Deliberately + // narrow: the agent does not create, modify, or define tasks, it + // only starts one that already exists AND clears the approved-task + // policy. Prefer this over run_script whenever the work is already + // packaged as a task - it is a far smaller grant than shipping a + // script body, and the command log names exactly what ran. + case "scheduledtask_run": + return RequireTarget(action, target) + ?? await ScheduledTaskRun(target, token); + // Software delivery. run_script fetches a ShopDB-hosted script // pinned by SHA-256 and runs it in the script's configured context // (system or the interactive user); update_app fetches a ShopDB // installer pinned by SHA-256 and runs it silently. Both download // the body themselves with the collector credential and fail closed. case "run_script": + { + var auth = new DownloadAuth(); return RequireTarget(action, target) - ?? await RunScript(target, claim.Script, token); + ?? auth.Stamp(await RunScript(target, claim.Script, auth, token)); + } case "update_app": - return await UpdateApp(claim.App, token); + { + var auth = new DownloadAuth(); + return auth.Stamp(await UpdateApp(claim.App, auth, token)); + } // Fixed destructive action: reboot this endpoint immediately. Runs // as the SYSTEM agent, so shutdown does not need a user session. @@ -144,6 +173,19 @@ public sealed class CommandExecutor Message = "Fresh telemetry collection requested", }; + // Full TCP connection table, on demand only. Deliberately not part of + // the standard report: established connections churn every second, so + // sending them on cadence would bloat every payload and swamp the + // stable listener list. As a command the technician pays that cost + // once, at the moment they are actually diagnosing connectivity. + case "network_connections": + return NetworkConnections(); + + // Read a plaintext log off this machine on demand. Read-only and + // bounded: see CollectLog for why each refusal is there. + case "collect_log": + return RequireTarget(action, target) ?? CollectLog(target); + default: logger.LogWarning("Rejected unknown command action {Action}", action); return new CommandResult @@ -154,6 +196,415 @@ public sealed class CommandExecutor } } + // Snapshot of every TCP connection with its owning process. Capped because a + // busy machine can hold thousands of sockets and an unbounded result would + // be rejected by the server's payload limit, returning nothing at all - + // a truncated answer is far more useful than a failed one. + private const int MaxReportedConnections = 500; + + private CommandResult NetworkConnections() + { + try + { + var rows = TcpTableReader.GetTcpTable(); + var names = GetProcessNameSnapshot(); + var connections = new List(); + var truncated = false; + + foreach (var row in rows) + { + if (connections.Count >= MaxReportedConnections) + { + truncated = true; + break; + } + + names.TryGetValue(row.ProcessId, out var processName); + connections.Add(new Dictionary + { + ["localaddress"] = row.LocalAddress, + ["localport"] = row.LocalPort, + ["remoteaddress"] = row.RemoteAddress, + ["remoteport"] = row.RemotePort, + ["state"] = row.State.ToString().ToLowerInvariant(), + ["processid"] = row.ProcessId, + ["processname"] = processName, + }); + } + + logger.LogInformation( + "network_connections returned {Count} connections (truncated: {Truncated})", + connections.Count, truncated); + + return new CommandResult + { + Succeeded = true, + Message = $"Collected {connections.Count} TCP connections" + + (truncated ? " (truncated)" : ""), + Detail = new Dictionary + { + ["connections"] = connections, + ["totalcount"] = rows.Count, + ["truncated"] = truncated, + }, + }; + } + catch (Exception exception) + { + logger.LogWarning(exception, "network_connections failed"); + return new CommandResult + { + Succeeded = false, + Message = "Could not read the TCP connection table: " + exception.Message, + }; + } + } + + // ---- Log collection ---- + + // Extensions this agent will read. An allowlist, not a blocklist: the + // question is not "is this file dangerous" but "will this render as text", + // and only a closed set answers that. Mirrors LOG_PATH_EXTENSIONS on the + // server; that copy gives a fast error at enqueue, THIS copy is the one + // guarding the disk and must never be removed on the assumption the server + // already checked. + private static readonly HashSet AllowedLogExtensions = + new(StringComparer.OrdinalIgnoreCase) + { + // The extension the server also omits (see LOG_PATH_EXTENSIONS) is + // omitted here too. The two copies must agree, or the server + // accepts a path the agent then refuses and the operator gets a + // failure with no actionable cause. + ".log", ".txt", ".xml", ".json", ".csv", ".ini", ".conf", + ".yml", ".yaml", ".trace", ".out", ".err", ".md", + }; + + // Ceiling on what crosses the wire. Logs grow without bound; a multi-GB + // file would exhaust memory here and be refused by the server's payload + // limit anyway, returning nothing. Sending the last 2 MB is far more useful + // than failing, because the end of a log is where the recent failure is. + private const int MaxLogBytes = 2 * 1024 * 1024; + + // Read a plaintext log file and return its tail. + // + // Strictly read-only: opens for reading, never creates, writes or deletes. + // FileShare.ReadWrite|Delete is essential rather than lax - the interesting + // logs are the ones a running application currently holds open, and a + // stricter share mode would fail on exactly those with a sharing violation. + private CommandResult CollectLog(string target) + { + var path = (target ?? string.Empty).Trim(); + + try + { + // Re-validate independently of the server. The agent runs as SYSTEM + // and this path arrived over the network; trusting an upstream check + // would make the whole guard depend on a component this process + // cannot verify. + var rejection = ValidateLogPath(ref path); + if (rejection != null) + { + logger.LogWarning("collect_log refused {Path}: {Reason}", path, rejection); + return new CommandResult { Succeeded = false, Message = rejection }; + } + + var info = new FileInfo(path); + if (!info.Exists) + { + return new CommandResult + { + Succeeded = false, + Message = $"No file found at {path}", + }; + } + + if (ProtectedFileSystem.IsReparsePoint(path)) + { + logger.LogWarning("collect_log refused {Path}: reparse point", path); + return new CommandResult + { + Succeeded = false, + Message = $"{path} is a link or reparse point; only regular files can be collected", + }; + } + + long totalBytes; + bool truncated; + string content; + using (var stream = ProtectedFileSystem.OpenReadNoFollow(path)) + { + // Re-check where the handle really landed. A junction in the + // ancestry or a short 8.3 name would otherwise walk past the + // denylist, which only saw the typed path. + var finalPath = ProtectedFileSystem.FinalPath(stream.SafeFileHandle); + var finalRejection = finalPath is null + ? "Could not resolve the real path of the file" + : DeniedLogPathReason(finalPath); + if (finalRejection != null) + { + logger.LogWarning( + "collect_log refused {Path} (resolves to {FinalPath}): {Reason}", + path, finalPath, finalRejection); + return new CommandResult { Succeeded = false, Message = finalRejection }; + } + + totalBytes = stream.Length; + truncated = totalBytes > MaxLogBytes; + if (truncated) + { + // Seek to the tail. This can land mid-line, so the partial + // first line is dropped below rather than shown as a + // fragment the reader would misread as a real entry. + stream.Seek(-MaxLogBytes, SeekOrigin.End); + } + + using var reader = new StreamReader(stream, Encoding.UTF8, true); + content = reader.ReadToEnd(); + } + + if (truncated) + { + var firstBreak = content.IndexOf('\n'); + if (firstBreak >= 0 && firstBreak < content.Length - 1) + content = content[(firstBreak + 1)..]; + } + + // A file whose bytes are not text will have produced replacement + // characters above. Returning that would fill the operator's pane + // with noise, so it is refused with a reason instead. + if (LooksBinary(content)) + { + return new CommandResult + { + Succeeded = false, + Message = + $"{path} does not appear to be a plaintext file; " + + "only text logs can be collected", + }; + } + + var lineCount = CountLines(content); + + logger.LogInformation( + "collect_log read {Path} ({Bytes} bytes on disk, truncated: {Truncated})", + path, totalBytes, truncated); + + return new CommandResult + { + Succeeded = true, + Message = truncated + ? $"Collected the last {content.Length:N0} characters of {info.Name} " + + $"({totalBytes:N0} bytes on disk)" + : $"Collected {info.Name} ({totalBytes:N0} bytes)", + Detail = new Dictionary + { + // The RESOLVED path, which may differ from what was typed + // once environment variables are expanded. + ["path"] = info.FullName, + ["content"] = content, + ["filesizebytes"] = totalBytes, + ["linecount"] = lineCount, + ["truncated"] = truncated, + ["modifiedat"] = info.LastWriteTimeUtc.ToString( + "o", CultureInfo.InvariantCulture), + }, + }; + } + catch (UnauthorizedAccessException exception) + { + // Called out separately because the remedy is different from a + // generic failure: the file exists but the agent cannot read it. + logger.LogWarning(exception, "collect_log denied for {Path}", path); + return new CommandResult + { + Succeeded = false, + Message = $"Access denied reading {path}", + }; + } + catch (IOException exception) + { + logger.LogWarning(exception, "collect_log io failure for {Path}", path); + return new CommandResult + { + Succeeded = false, + Message = $"Could not read {path}: {exception.Message}", + }; + } + catch (Exception exception) + { + logger.LogWarning(exception, "collect_log failed for {Path}", path); + return new CommandResult + { + Succeeded = false, + Message = $"Could not collect {path}: {exception.Message}", + }; + } + } + + // Shape validation, run before anything is opened. Returns a reason to + // refuse, or null to proceed. Expands environment variables first so the + // checks apply to the path that will ACTUALLY be opened - validating the + // literal string and then expanding it would let %VAR% smuggle past every + // rule here. + private static string? ValidateLogPath(ref string path) + { + if (string.IsNullOrWhiteSpace(path)) + return "A log file path is required"; + + path = Environment.ExpandEnvironmentVariables(path).Trim(); + var normalized = path.Replace('/', '\\'); + + if (normalized.StartsWith(@"\\", StringComparison.Ordinal)) + return "UNC/network paths are not supported"; + + // Reject '..' before calling GetFullPath. Normalising first would + // resolve the traversal and hide it, so the check has to see the raw + // segments. + if (normalized.Split('\\').Any(part => part == "..")) + return "Path must not contain \"..\" segments"; + + if (normalized.Contains('*') || normalized.Contains('?')) + return "Wildcards are not supported; name a single log file"; + + if (!Path.IsPathRooted(normalized) || normalized.Length < 3 + || normalized[1] != ':') + { + return "Enter a full local path including the drive letter"; + } + + var extension = Path.GetExtension(normalized); + if (string.IsNullOrEmpty(extension) + || !AllowedLogExtensions.Contains(extension)) + { + return "Only plaintext log files can be collected " + + $"(allowed: {string.Join(", ", AllowedLogExtensions.OrderBy(e => e))})"; + } + + // A directory reached through a path that passes every check above is + // still not a file, and Directory.Exists is cheap insurance. + if (Directory.Exists(normalized)) + return "That path is a directory, not a log file"; + + path = Path.GetFullPath(normalized); + return DeniedLogPathReason(path); + } + + // Files SYSTEM can read that an operator must never be able to pull over the + // wire: deployment answer files (local admin passwords), the agent's own + // configuration and state, and key material. Checked against the normalized + // typed path and again against the handle's final path. + private static string? DeniedLogPathReason(string fullPath) + { + if (fullPath.StartsWith(@"\\", StringComparison.Ordinal)) + return "UNC/network paths are not supported"; + + var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var programData = Environment.GetFolderPath( + Environment.SpecialFolder.CommonApplicationData); + var programFiles = Environment.GetFolderPath( + Environment.SpecialFolder.ProgramFiles); + var deniedRoots = new[] + { + Path.Combine(windows, "Panther"), + Path.Combine(windows, "System32", "Sysprep"), + Path.Combine(programData, "ShopDB"), + Path.Combine(programFiles, "ShopDB", "EndpointAgent"), + AppContext.BaseDirectory, + }; + foreach (var root in deniedRoots) + { + if (string.IsNullOrWhiteSpace(root) || !Path.IsPathRooted(root)) + continue; + var normalizedRoot = Path.GetFullPath(root).TrimEnd('\\'); + if (fullPath.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase) + || fullPath.StartsWith(normalizedRoot + "\\", StringComparison.OrdinalIgnoreCase)) + { + return $"Collecting files under {normalizedRoot} is not permitted"; + } + } + + var fileName = Path.GetFileName(fullPath); + var extension = Path.GetExtension(fileName); + if ((fileName.StartsWith("unattend", StringComparison.OrdinalIgnoreCase) + && extension.Equals(".xml", StringComparison.OrdinalIgnoreCase)) + || fileName.Equals("autounattend.xml", StringComparison.OrdinalIgnoreCase) + || (fileName.StartsWith("appsettings", StringComparison.OrdinalIgnoreCase) + && extension.Equals(".json", StringComparison.OrdinalIgnoreCase)) + || extension.Equals(".pfx", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".p12", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".key", StringComparison.OrdinalIgnoreCase)) + { + return $"Collecting {fileName} is not permitted"; + } + + return null; + } + + // Heuristic: a NUL byte, or many replacement characters, means the decode + // produced garbage rather than text. Sampling the head is enough - a binary + // file betrays itself immediately, and scanning megabytes to be sure would + // cost more than it is worth. + private static bool LooksBinary(string content) + { + if (string.IsNullOrEmpty(content)) + return false; + + var sample = Math.Min(content.Length, 8000); + var replacements = 0; + for (var index = 0; index < sample; index++) + { + var character = content[index]; + if (character == '\0') + return true; + if (character == '\uFFFD') + replacements++; + } + + return replacements > sample / 100; + } + + private static int CountLines(string content) + { + if (string.IsNullOrEmpty(content)) + return 0; + + var lines = 1; + foreach (var character in content) + { + if (character == '\n') + lines++; + } + return lines; + } + + private Dictionary GetProcessNameSnapshot() + { + var names = new Dictionary(); + try + { + foreach (var process in Process.GetProcesses()) + { + try + { + names[process.Id] = process.ProcessName; + } + catch + { + // The process exited between enumeration and read. + } + finally + { + process.Dispose(); + } + } + } + catch (Exception exception) + { + logger.LogDebug(exception, "Process name snapshot failed"); + } + return names; + } + // Guard: a generic action with no target has nothing to act on. Returns a // failed result to short-circuit, or null when the target is present. private CommandResult? RequireTarget(string action, string target) @@ -550,6 +1001,139 @@ public sealed class CommandExecutor return found; } + // ---- Scheduled tasks ---- + + // Upper bound on how long a task start may block the command worker. This + // waits only for schtasks.exe to hand the request to the Task Scheduler + // service, NOT for the task's own work to finish - schtasks /run returns as + // soon as the task is launched. So success here means "the task was + // started", not "the task succeeded", and the timeout only needs to cover + // the handoff. + private static readonly TimeSpan ScheduledTaskStartTimeout = TimeSpan.FromSeconds(30); + + // Start an already-registered scheduled task by path, after the policy + // approves it. Uses schtasks.exe with ArgumentList (no shell, no string + // concatenation) so the task path is passed as a single argv entry and + // cannot break out into additional arguments. + // + // The task runs in whatever security context its own registered definition + // specifies - SYSTEM, an interactive user, whatever principal was set when + // the task was created. The agent does not and cannot change that, which is + // exactly why this action is narrower than run_script: the endpoint owns the + // definition of what happens, the server only asks for it to happen now. + private async Task ScheduledTaskRun(string target, CancellationToken token) + { + if (!scheduledTaskPolicy.TryValidate(target, out var taskPath, out var reason)) + { + logger.LogWarning( + "Rejected scheduledtask_run for {Target}: {Reason}", target, reason); + return new CommandResult + { + Succeeded = false, + Message = $"Scheduled task '{target}' rejected: {reason}", + }; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = "schtasks.exe", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("/run"); + startInfo.ArgumentList.Add("/tn"); + startInfo.ArgumentList.Add(taskPath); + + using var process = Process.Start(startInfo); + if (process is null) + { + return new CommandResult + { + Succeeded = false, + Message = $"Failed to start schtasks.exe for task {taskPath}", + }; + } + + var stdout = ""; + var stderr = ""; + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); + timeout.CancelAfter(ScheduledTaskStartTimeout); + try + { + await process.WaitForExitAsync(timeout.Token); + // Read only after exit. Reading to end first would block until + // the streams close - which IS process exit - so the timeout + // above could never fire. Safe here because schtasks emits a + // line or two, far below the pipe buffer that would deadlock a + // chattier child. + stdout = await process.StandardOutput.ReadToEndAsync(token); + stderr = await process.StandardError.ReadToEndAsync(token); + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + TryKill(process); + return new CommandResult + { + Succeeded = false, + Message = $"Timed out starting scheduled task {taskPath}", + }; + } + + var exit = process.ExitCode; + var ok = exit == 0; + // schtasks writes the useful diagnosis (task not found, access + // denied, disabled) to its output, so surface it rather than making + // the operator guess from a bare exit code. + var detail = (stderr.Trim().Length > 0 ? stderr.Trim() : stdout.Trim()); + if (ok) + { + logger.LogInformation("Started scheduled task {TaskPath}", taskPath); + } + else + { + logger.LogWarning( + "schtasks /run for {TaskPath} exited {Exit}: {Detail}", + taskPath, exit, detail); + } + + return new CommandResult + { + Succeeded = ok, + Message = ok + ? $"Started scheduled task {taskPath}" + : $"Scheduled task {taskPath} failed to start (exit {exit})" + + (detail.Length > 0 ? $": {detail}" : ""), + }; + } + catch (Exception error) + { + logger.LogWarning(error, "scheduledtask_run for {TaskPath} failed", taskPath); + return new CommandResult + { + Succeeded = false, + Message = $"Scheduled task {taskPath} failed: {error.Message}", + }; + } + } + + private void TryKill(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch (Exception error) + { + logger.LogDebug(error, "Could not kill timed-out process tree"); + } + } + // ---- Software delivery: run_script and update_app ---- // Run a ShopDB-hosted script pinned by the server's delivery descriptor. The @@ -559,8 +1143,28 @@ public sealed class CommandExecutor // 'system' through the agent's own service account, or 'user' in the active // console session so the script sees the interactive user's resources (for // example mapped network drives). Anything missing or mismatched fails closed. + // + // SECURITY CONTRACT - read before changing this method. This is arbitrary + // code execution on the endpoint, by design, and the agent is a SYSTEM + // service: a script whose descriptor does not pin the user context runs with + // full machine privilege. Three properties make that acceptable, and all + // three are load-bearing: + // + // 1. The body is never carried on the command wire. The agent fetches it + // itself from ShopDB using its own collector credential, so queueing a + // command cannot smuggle in code. + // 2. The body is verified against the server-pinned SHA-256 before it + // runs. A tampered or swapped body fails closed. + // 3. The action is gated on endpoints.deploy server-side and is meant to + // sit behind an explicit UI confirmation, the same as system_restart. + // + // Do not add a path that runs a script the agent found locally rather than + // fetched and verified - that was the old pre-staged 'scriptpaths' model and + // it was removed on purpose (see the tombstone in AgentConfig). If the goal + // is to trigger something already installed on the endpoint, use + // scheduledtask_run instead: it is a much smaller grant. private async Task RunScript( - string name, ScriptDeliveryClaim? script, CancellationToken token) + string name, ScriptDeliveryClaim? script, DownloadAuth auth, CancellationToken token) { if (script is null || string.IsNullOrWhiteSpace(script.InstallPath)) { @@ -574,30 +1178,46 @@ public sealed class CommandExecutor }; } - if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(config.ApiKey)) + if (string.IsNullOrWhiteSpace(config.BaseUrl)) { return new CommandResult { Succeeded = false, - Message = "run_script cannot download: agent has no base URL or collector key", + Message = "run_script cannot download: agent has no base URL", Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null), }; } var scriptUrl = CombineUrl(config.BaseUrl, script.InstallPath!); - var tempPath = Path.Combine(GetStagingDirectory(), - $"shopdb-script-{script.ScriptId}.ps1"); + var runInUser = string.Equals( + script.RunContext, "user", StringComparison.OrdinalIgnoreCase); + string? tempPath = null; try { - using var handler = new HttpClientHandler(); - using var client = new HttpClient(handler); - client.Timeout = TimeSpan.FromMinutes(5); - using var httpRequest = new HttpRequestMessage(HttpMethod.Get, scriptUrl); - httpRequest.Headers.Add("X-API-Key", config.ApiKey); + // Resolved before anything is staged: a user-context script and its + // transcript are granted to that one account and nobody else. + SecurityIdentifier? userSid = null; + if (runInUser) + { + userSid = InteractiveProcessLauncher.TryGetActiveUserSid(out var sidError); + if (userSid is null) + { + logger.LogWarning( + "run_script for {Name} could not resolve the interactive user: {Error}", + name, sidError); + return new CommandResult + { + Succeeded = false, + Message = $"Script {name} could not run in the user session: {sidError}", + Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null), + }; + } + } - using var response = await client.SendAsync( - httpRequest, HttpCompletionOption.ResponseHeadersRead, token); + using var client = httpFactory.CreateClient( + AgentHttpChannel.Download, TimeSpan.FromMinutes(5)); + using var response = await SendDownloadAsync(client, scriptUrl, "run_script", auth, token); if (!response.IsSuccessStatusCode) { return new CommandResult @@ -608,15 +1228,22 @@ public sealed class CommandExecutor }; } - await using (var source = await response.Content.ReadAsStreamAsync(token)) - await using (var file = File.Create(tempPath)) + // Held in memory, never on disk, until it is verified. A body on + // disk before the check is a body that can be swapped under it. + var body = await ReadBoundedAsync(response.Content, MaxScriptBytes, token); + if (body is null) { - await source.CopyToAsync(file, token); + return new CommandResult + { + Succeeded = false, + Message = $"run_script download refused: script exceeds {MaxScriptBytes / (1024 * 1024)} MB", + Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null), + }; } if (!string.IsNullOrWhiteSpace(script.Sha256)) { - var actual = HashFile(tempPath); + var actual = HashBytes(body); if (!actual.Equals(script.Sha256, StringComparison.OrdinalIgnoreCase)) { logger.LogWarning( @@ -626,16 +1253,48 @@ public sealed class CommandExecutor { Succeeded = false, Message = "run_script integrity check failed: script SHA-256 does not match", - Detail = ScriptDetail(name, script, tempPath, exitCode: null), + Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null), }; } } - var runInUser = string.Equals( - script.RunContext, "user", StringComparison.OrdinalIgnoreCase); - return runInUser - ? RunScriptInUserSession(name, script, tempPath) - : await RunScriptAsSystem(name, script, tempPath, token); + var staged = ApplyErrorMode(script, body, name); + + var stagingDirectory = runInUser + ? ProtectedFileSystem.EnsureDirectory(UserScriptDirectoryName, userSid) + : ProtectedFileSystem.EnsureDirectory(SystemStagingDirectoryName); + var runId = Guid.NewGuid().ToString("N"); + tempPath = Path.Combine(stagingDirectory, + $"shopdb-script-{script.ScriptId}-{runId}.ps1"); + await using (var file = ProtectedFileSystem.CreateFile( + tempPath, userSid, FileSystemRights.ReadAndExecute)) + { + await file.WriteAsync(staged, token); + } + + // The bytes about to execute, not the bytes that were delivered: + // after a strict-mode rewrite these differ, and both are pinned. + var expectedStaged = HashBytes(staged); + var actualStaged = HashFile(tempPath); + if (!actualStaged.Equals(expectedStaged, StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning( + "run_script staged file for {Name} changed on disk: expected {Expected} got {Actual}", + name, expectedStaged, actualStaged); + return new CommandResult + { + Succeeded = false, + Message = "run_script integrity check failed: staged script changed on disk", + Detail = ScriptDetail(name, script, tempPath, exitCode: null), + }; + } + + if (!runInUser) + return await RunScriptAsSystem(name, script, tempPath, token); + + var transcriptPath = TryCreateTranscript( + stagingDirectory, script.ScriptId, runId, userSid!, name); + return RunScriptInUserSession(name, script, tempPath, transcriptPath); } catch (OperationCanceledException) { @@ -655,8 +1314,8 @@ public sealed class CommandExecutor { try { - if (File.Exists(tempPath)) - File.Delete(tempPath); + if (tempPath is not null) + ProtectedFileSystem.DeleteFileNoFollow(tempPath); } catch (Exception cleanupError) { @@ -665,18 +1324,271 @@ public sealed class CommandExecutor } } + // Upper bound on a delivered script body. Scripts are text; anything near + // this is not a script, and the body is held in memory until verified. + private const int MaxScriptBytes = 8 * 1024 * 1024; + + // Upper bound on how much of a user-session transcript is read back. Far + // above what MaxCapturedOutputChars keeps, so the cap still reports truncation. + private const int MaxTranscriptBytes = 256 * 1024; + + // Subdirectories of %ProgramData%\ShopDB\EndpointAgent (Defender-excluded) + // created with a protected DACL. Standard users can create files anywhere + // else under ProgramData, so nothing SYSTEM executes or reads back is staged + // outside these. userscripts additionally lets the target operator traverse + // it, because a user-context run executes from there as that operator. + private const string SystemStagingDirectoryName = "staging"; + private const string UserScriptDirectoryName = "userscripts"; + + private static async Task ReadBoundedAsync( + HttpContent content, int maxBytes, CancellationToken token) + { + if (content.Headers.ContentLength is long declared && declared > maxBytes) + return null; + + await using var source = await content.ReadAsStreamAsync(token); + using var buffer = new MemoryStream(); + var chunk = new byte[81920]; + int read; + while ((read = await source.ReadAsync(chunk, token)) > 0) + { + if (buffer.Length + read > maxBytes) + return null; + buffer.Write(chunk, 0, read); + } + return buffer.ToArray(); + } + + // The transcript a user-session run redirects into. SYSTEM creates it + // inside the protected directory and grants the operator read/write on this + // one file, so the operator can fill it but cannot swap it for a link that + // would steer the later SYSTEM read and delete. Best-effort: a run without + // a transcript still runs, it just reports no output. + private string? TryCreateTranscript( + string stagingDirectory, int scriptId, string runId, + SecurityIdentifier userSid, string name) + { + var transcriptPath = Path.Combine(stagingDirectory, + $"shopdb-script-{scriptId}-{runId}-output.txt"); + try + { + using (ProtectedFileSystem.CreateFile(transcriptPath, userSid, + FileSystemRights.Read | FileSystemRights.Write)) + { + } + return transcriptPath; + } + catch (Exception error) + { + logger.LogWarning( + error, + "run_script {Name}: could not prepare the output transcript; " + + "the script will still run but its output will not be captured", name); + return null; + } + } + + // Read the transcript back and delete it. Deleted whether or not the read + // worked, so a script's output does not linger after it has been reported. + // Never follows a reparse point for either operation. + private string ReadAndClearTranscript(string? transcriptPath, string name) + { + if (transcriptPath is null) + return ""; + + var output = ""; + try + { + if (ProtectedFileSystem.IsReparsePoint(transcriptPath)) + logger.LogWarning( + "run_script {Name}: output transcript {Path} is a reparse point; not read", + name, transcriptPath); + else if (ProtectedFileSystem.TryGetAttributes(transcriptPath) is not null) + output = CombineScriptOutput( + ProtectedFileSystem.ReadTextHeadNoFollow(transcriptPath, MaxTranscriptBytes), + null); + } + catch (Exception error) + { + logger.LogWarning( + error, "run_script {Name}: could not read the output transcript", name); + } + try + { + ProtectedFileSystem.DeleteFileNoFollow(transcriptPath); + } + catch (Exception error) + { + logger.LogDebug( + error, "run_script {Name}: transcript cleanup failed", name); + } + return output; + } + + // Cap on captured script output. Two different limits are at work: the + // endpoint log should stay readable, and the server payload rides an HTTP + // POST into a MEDIUMTEXT column. A runaway loop printing megabytes must not + // turn a failed script into a failed REPORT - losing the verdict entirely is + // worse than losing the tail of the evidence. Head is kept rather than tail + // because the FIRST error is the one that explains the failure; under strict + // mode nothing after it ran anyway. + private const int MaxCapturedOutputChars = 8000; + + // Merge the two streams into one operator-readable blob. stderr is labelled + // rather than concatenated blind, because "which stream said this" is the + // difference between a warning a script prints and the error that killed it. + private static string CombineScriptOutput(string? stdout, string? stderr) + { + var parts = new List(2); + if (!string.IsNullOrWhiteSpace(stdout)) + parts.Add(stdout!.TrimEnd()); + if (!string.IsNullOrWhiteSpace(stderr)) + parts.Add("[stderr] " + stderr!.TrimEnd()); + var combined = string.Join(Environment.NewLine, parts); + if (combined.Length <= MaxCapturedOutputChars) + return combined; + // Say so explicitly. Silently truncated evidence reads as a script that + // stopped talking, which is a different diagnosis than one that was cut. + return combined.Substring(0, MaxCapturedOutputChars) + + Environment.NewLine + + $"[truncated after {MaxCapturedOutputChars} characters]"; + } + + // The endpoint log gets the output ALWAYS, success or failure. This is the + // copy that stays on the box: it never leaves the endpoint, so it costs no + // exposure, and it is the only record when a script reports success while + // doing nothing useful. The server copy is success-gated (see ShouldReturnOutput). + private void LogScriptOutput(string name, string output, int? exitCode) + { + if (string.IsNullOrWhiteSpace(output)) + { + logger.LogInformation( + "run_script {Name} produced no output (exit {Exit})", + name, exitCode?.ToString() ?? "unknown"); + return; + } + logger.LogInformation( + "run_script {Name} output (exit {Exit}):{NewLine}{Output}", + name, exitCode?.ToString() ?? "unknown", Environment.NewLine, output); + } + + // Inserted, followed by '; ', on the line of the first statement after any + // using / #Requires / comment help / attribute / param header (see + // StrictModePreamble). No newline is added, so line numbers PowerShell + // reports still match the body the author sees in the WebUI editor; only + // columns on that one line shift. + private const string StrictModePrologue = + "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'"; + + // Turn a script's non-terminating errors fatal when the library says strict. + // + // MUST run AFTER the SHA-256 check. The digest pins the bytes the SERVER + // holds; rewriting first would fail its own integrity check. Verify what was + // delivered, then modify how it is interpreted; the rewritten bytes are + // hashed again once staged. + // + // Best-effort by design: if the rewrite fails or the header cannot be read + // with confidence, the script runs unmodified under PowerShell's default + // rules. That is the behaviour it had before this existed, and the warning + // says the verdict is the weaker one. + private byte[] ApplyErrorMode(ScriptDeliveryClaim script, byte[] body, string name) + { + if (!string.Equals(script.ErrorMode, "strict", StringComparison.OrdinalIgnoreCase)) + return body; + + try + { + var rewritten = StrictModePreamble.TryApply(body, StrictModePrologue, out var reason); + if (rewritten is null) + { + logger.LogWarning( + "run_script {Name}: strict error mode NOT applied ({Reason}); running under " + + "PowerShell defaults, so a non-terminating error will NOT fail the command", + name, reason); + return body; + } + + logger.LogInformation( + "run_script {Name}: strict error mode applied; a non-terminating " + + "error will now stop the script and fail the command", name); + return rewritten; + } + catch (Exception error) + { + logger.LogWarning( + error, + "run_script {Name}: could not apply strict error mode; running under " + + "PowerShell defaults, so a non-terminating error will NOT fail the command", + name); + return body; + } + } + + // Where the script actually runs. Empty means the agent staging directory, + // which is right for a self-contained script and wrong for one that shells + // out to a corporate tool already installed on the endpoint (Sysinternals + // and similar, delivered by Intune) and invokes it by relative path. The + // server carries that folder per script. + // + // A NAMED DIRECTORY THAT IS ABSENT FAILS THE COMMAND. Falling back to + // staging would run SYSTEM PowerShell somewhere other than where the author + // said; the script would then fail further in with an error naming the + // missing tool rather than the missing folder, or half-succeed, which is + // worse. Fail closed and name the folder. + private static bool TryResolveWorkingDirectory( + ScriptDeliveryClaim? script, string scriptPath, + out string workingDirectory, out string error) + { + error = ""; + workingDirectory = Path.GetDirectoryName(scriptPath) ?? ""; + + var requested = script?.WorkingDirectory; + if (string.IsNullOrWhiteSpace(requested)) + return true; + + if (!Directory.Exists(requested)) + { + error = $"working directory not found on this endpoint: {requested}"; + return false; + } + + workingDirectory = requested!; + return true; + } + // System context: run through the agent's own service account and wait for // the exit code, the same as before. Fine for machine-scoped scripts that do // not need the interactive user's session resources. + // + // Both streams are drained WHILE the script runs. Reading only after exit + // deadlocks as soon as a script fills a pipe buffer (it blocks on write, we + // block on exit), and ReadToEnd would hold unbounded output in memory. The + // run is bounded by SystemScriptTimeout; on expiry the whole process tree is + // killed, since a SYSTEM PowerShell left running is worse than a failed run. private async Task RunScriptAsSystem( string name, ScriptDeliveryClaim script, string scriptPath, CancellationToken token) { + if (!TryResolveWorkingDirectory( + script, scriptPath, out var workingDirectory, out var directoryError)) + { + logger.LogWarning( + "run_script for {Name} not started: {Error}", name, directoryError); + return new CommandResult + { + Succeeded = false, + Message = $"Script {name} not run: {directoryError}", + Detail = ScriptDetail(name, script, scriptPath, exitCode: null), + }; + } + var startInfo = new ProcessStartInfo { FileName = "powershell.exe", UseShellExecute = false, CreateNoWindow = true, - WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? "", + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, }; startInfo.ArgumentList.Add("-NoProfile"); startInfo.ArgumentList.Add("-NonInteractive"); @@ -692,38 +1604,171 @@ public sealed class CommandExecutor { Succeeded = false, Message = $"Failed to start script {name}", - Detail = ScriptDetail(name, script, scriptPath, exitCode: null), + Detail = ScriptDetail(name, script, scriptPath, exitCode: null, workingDirectory), }; } - await process.WaitForExitAsync(token); + var stdout = new BoundedCapture(MaxCapturedOutputChars + 1); + var stderr = new BoundedCapture(MaxCapturedOutputChars + 1); + var pumps = Task.WhenAll( + stdout.PumpAsync(process.StandardOutput), + stderr.PumpAsync(process.StandardError)); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); + timeout.CancelAfter(SystemScriptTimeout); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + TryKill(process); + await DrainAsync(pumps); + if (token.IsCancellationRequested) + throw; + + var partial = CombineScriptOutput(stdout.ToString(), stderr.ToString()); + LogScriptOutput(name, partial, exitCode: null); + logger.LogWarning( + "run_script {Name} did not finish within {Timeout}; process tree killed", + name, SystemScriptTimeout); + return new CommandResult + { + Succeeded = false, + Message = $"Script {name} did not finish within " + + $"{SystemScriptTimeout.TotalMinutes:0} minutes; the script and its " + + "child processes were killed", + Detail = ScriptDetail(name, script, scriptPath, exitCode: null, + workingDirectory, partial), + }; + } + + // A grandchild that inherited the pipe can hold it open past exit, so + // the final drain is bounded too. + await DrainAsync(pumps); var exit = process.ExitCode; + var output = CombineScriptOutput(stdout.ToString(), stderr.ToString()); + LogScriptOutput(name, output, exit); return new CommandResult { Succeeded = exit == 0, Message = exit == 0 ? $"Script {name} completed (system context)" : $"Script {name} exited with code {exit}", - Detail = ScriptDetail(name, script, scriptPath, exit), + Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory, + ShouldReturnOutput(script, succeeded: exit == 0) ? output : null), }; } + // The descriptor carries no per-script timeout, so SYSTEM runs get the same + // budget as user-session runs. + private static readonly TimeSpan SystemScriptTimeout = TimeSpan.FromMinutes(5); + + private static readonly TimeSpan StreamDrainTimeout = TimeSpan.FromSeconds(10); + + private static async Task DrainAsync(Task pumps) + => await Task.WhenAny(pumps, Task.Delay(StreamDrainTimeout)); + + // Reads a redirected stream to EOF, keeping at most `limit` characters and + // discarding the rest so the pipe never fills and memory stays bounded. + private sealed class BoundedCapture + { + private readonly StringBuilder buffer = new(); + private readonly object gate = new(); + private readonly int limit; + + public BoundedCapture(int limit) => this.limit = limit; + + public Task PumpAsync(StreamReader reader) => Task.Run(async () => + { + var chunk = new char[4096]; + try + { + int read; + while ((read = await reader.ReadAsync(chunk, 0, chunk.Length)) > 0) + { + lock (gate) + { + var room = limit - buffer.Length; + if (room > 0) + buffer.Append(chunk, 0, Math.Min(room, read)); + } + } + } + catch (Exception) + { + // Pipe torn down by a kill; keep what was captured. + } + }); + + public override string ToString() + { + lock (gate) + return buffer.ToString(); + } + } + // User context: launch PowerShell in the ACTIVE CONSOLE session so the script // runs at the interactive user's integrity and sees that user's resources // (mapped network drives, HKCU, profile). The launcher waits for the process // to exit and returns its real exit code, so success here means the script // actually ran to a zero exit - not merely that it was launched. Fails closed // when no interactive user is signed in. + // Upper bound on how long a user-session script may block the command + // worker. Generous, because a staged maintenance script can legitimately run + // for minutes, but finite: before this existed the wait was Infinite and a + // hung script pinned the caller forever. + private static readonly TimeSpan UserScriptWaitTimeout = TimeSpan.FromMinutes(5); + + // Where a user-session script's output is captured to. + // + // The SYSTEM path can simply redirect the child's stdout/stderr handles. + // This path cannot: it goes through CreateProcessAsUser to cross a session + // boundary, and inheriting pipe handles across that boundary is exactly the + // part of the launcher that is delicate (session enumeration, token + // duplication). So PowerShell redirects to a file itself and the agent reads + // it back after exit. + // + // The file is created by SYSTEM in the protected userscripts directory and + // only that file is granted to the operator (see TryCreateTranscript). It + // used to live in the operator's own profile, where the operator could + // replace it with a junction or link and steer SYSTEM's read and delete. private CommandResult RunScriptInUserSession( - string name, ScriptDeliveryClaim script, string scriptPath) + string name, ScriptDeliveryClaim script, string scriptPath, string? transcriptPath) { var quotedPath = "\"" + scriptPath + "\""; + // Redirect inside the user session rather than from the service. The + // child is launched through CreateProcessAsUser, so the agent holds no + // handles to its streams; -Command wraps the same -File invocation and + // sends every stream to the transcript the operator was granted, which + // the agent reads back after exit. *> merges them because ordering + // matters more than telling them apart when reconstructing what a + // script did. + var redirect = transcriptPath is null + ? "" + : " *> " + ("\"" + transcriptPath + "\"").Replace("\"", "\\\""); var arguments = - "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + quotedPath; - var workingDirectory = Path.GetDirectoryName(scriptPath) ?? ""; + "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command " + + "\"& { & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy " + + "Bypass -File " + quotedPath.Replace("\"", "\\\"") + + redirect + "; exit $LASTEXITCODE }\""; + if (!TryResolveWorkingDirectory( + script, scriptPath, out var workingDirectory, out var directoryError)) + { + logger.LogWarning( + "run_script for {Name} not started: {Error}", name, directoryError); + ReadAndClearTranscript(transcriptPath, name); + return new CommandResult + { + Succeeded = false, + Message = $"Script {name} not run: {directoryError}", + Detail = ScriptDetail(name, script, scriptPath, exitCode: null), + }; + } var outcome = InteractiveProcessLauncher.LaunchInActiveSession( - "powershell.exe", arguments, workingDirectory, waitForExit: true); + "powershell.exe", arguments, workingDirectory, waitForExit: true, + waitTimeout: UserScriptWaitTimeout); if (!outcome.Launched) { logger.LogWarning( @@ -733,26 +1778,62 @@ public sealed class CommandExecutor { Succeeded = false, Message = $"Script {name} could not run in the user session: {outcome.Error}", - Detail = ScriptDetail(name, script, scriptPath, exitCode: null), + Detail = ScriptDetail(name, script, scriptPath, exitCode: null, workingDirectory, + ReadAndClearTranscript(transcriptPath, name)), }; } + if (outcome.TimedOut) + { + // The script is still running; we simply stopped waiting. Report it + // as unresolved rather than success, because we never saw an exit + // code and cannot claim it worked. + logger.LogWarning( + "run_script for {Name} still running after {Timeout}; stopped waiting. " + + "Partial output remains at {Transcript} while it runs", + name, UserScriptWaitTimeout, transcriptPath); + return new CommandResult + { + Succeeded = false, + Message = $"Script {name} did not finish within " + + $"{UserScriptWaitTimeout.TotalMinutes:0} minutes and is still running", + Detail = ScriptDetail(name, script, scriptPath, exitCode: null, workingDirectory), + }; + } + + // Launched and waited on, yet no exit code: nothing proves it worked. var exit = outcome.ExitCode; - var ok = exit is null || exit == 0; + var ok = exit == 0; + var output = ReadAndClearTranscript(transcriptPath, name); + LogScriptOutput(name, output, exit); return new CommandResult { Succeeded = ok, Message = exit is null - ? $"Script {name} launched in the interactive user session" + ? $"Script {name} failed in the interactive user session: no exit code reported" : ok ? $"Script {name} completed (user context)" : $"Script {name} exited with code {exit}", - Detail = ScriptDetail(name, script, scriptPath, exit), + Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory, + ShouldReturnOutput(script, succeeded: ok) ? output : null), }; } + // Whether this run's output should travel back to the server. + // + // A FAILED run always returns it: the whole point of the field is making a + // failure diagnosable from the UI rather than by remoting to the box. A + // SUCCESSFUL run returns it only when the script's author opted in, because + // output carries whatever the body printed - hostnames, share paths, a + // token echoed while debugging - and a routine green run is not worth that + // exposure by default. A claim from a server too old to send the field + // leaves CaptureOutput false, so the behaviour is unchanged. + private static bool ShouldReturnOutput(ScriptDeliveryClaim? script, bool succeeded) + => !succeeded || (script?.CaptureOutput ?? false); + private static Dictionary ScriptDetail( - string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode) + string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode, + string? workingDirectory = null, string? output = null) { var detail = new Dictionary { @@ -764,11 +1845,27 @@ public sealed class CommandExecutor detail["scriptid"] = script.ScriptId; if (!string.IsNullOrWhiteSpace(script.RunContext)) detail["runcontext"] = script.RunContext!; + if (!string.IsNullOrWhiteSpace(script.ErrorMode)) + detail["errormode"] = script.ErrorMode!; } if (scriptPath is not null) detail["path"] = scriptPath; + // The directory the script REALLY ran in, not the one that was asked + // for. An agent too old to know the field reports nothing here, which is + // how an operator tells "ignored" apart from "honoured" while agents of + // both vintages are in the field. + if (!string.IsNullOrWhiteSpace(workingDirectory)) + detail["workingdirectory"] = workingDirectory!; if (exitCode is not null) detail["exitcode"] = exitCode; + // Always present on failure; on success only when the script opted in + // via captureoutput (see ShouldReturnOutput). Script output can carry + // whatever the body happened to print - hostnames, paths, tokens an + // author echoed while debugging - so a successful run stays silent + // unless someone decided otherwise after reading the body. The endpoint + // log keeps the full copy either way. + if (!string.IsNullOrWhiteSpace(output)) + detail["output"] = output!; return detail; } @@ -778,7 +1875,8 @@ public sealed class CommandExecutor // credential, verifies the SHA-256 (when the server pinned one), then runs // an MSI via msiexec /qn or an EXE with the version's SilentArgs. Anything // missing or mismatched fails closed. - private async Task UpdateApp(AppDeliveryClaim? app, CancellationToken token) + private async Task UpdateApp( + AppDeliveryClaim? app, DownloadAuth auth, CancellationToken token) { if (app is null || string.IsNullOrWhiteSpace(app.InstallPath)) { @@ -791,12 +1889,12 @@ public sealed class CommandExecutor }; } - if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(config.ApiKey)) + if (string.IsNullOrWhiteSpace(config.BaseUrl)) { return new CommandResult { Succeeded = false, - Message = "update_app cannot download: agent has no base URL or collector key", + Message = "update_app cannot download: agent has no base URL", Detail = AppDetail(app, installerPath: null, exitCode: null), }; } @@ -804,19 +1902,19 @@ public sealed class CommandExecutor var packageUrl = CombineUrl(config.BaseUrl, app.InstallPath!); var ext = Path.GetExtension(app.InstallPath) ?? ""; var packageKey = app.AppVersionId?.ToString() ?? $"app{app.AppId}"; - var tempPath = Path.Combine(GetStagingDirectory(), - $"shopdb-app-{packageKey}{ext}"); + string? tempPath = null; try { - using var handler = new HttpClientHandler(); - using var client = new HttpClient(handler); - client.Timeout = TimeSpan.FromMinutes(10); - using var httpRequest = new HttpRequestMessage(HttpMethod.Get, packageUrl); - httpRequest.Headers.Add("X-API-Key", config.ApiKey); + // Protected directory and a random name, so no standard user can + // pre-place or swap the package between the hash check and msiexec. + tempPath = Path.Combine( + ProtectedFileSystem.EnsureDirectory(SystemStagingDirectoryName), + $"shopdb-app-{packageKey}-{Guid.NewGuid():N}{ext}"); - using var response = await client.SendAsync( - httpRequest, HttpCompletionOption.ResponseHeadersRead, token); + using var client = httpFactory.CreateClient( + AgentHttpChannel.Download, TimeSpan.FromMinutes(10)); + using var response = await SendDownloadAsync(client, packageUrl, "update_app", auth, token); if (!response.IsSuccessStatusCode) { return new CommandResult @@ -828,7 +1926,7 @@ public sealed class CommandExecutor } await using (var source = await response.Content.ReadAsStreamAsync(token)) - await using (var file = File.Create(tempPath)) + await using (var file = ProtectedFileSystem.CreateFile(tempPath)) { await source.CopyToAsync(file, token); } @@ -903,8 +2001,8 @@ public sealed class CommandExecutor { try { - if (File.Exists(tempPath)) - File.Delete(tempPath); + if (tempPath is not null) + ProtectedFileSystem.DeleteFileNoFollow(tempPath); } catch (Exception cleanupError) { @@ -913,6 +2011,64 @@ public sealed class CommandExecutor } } + // What a download had to do about a revoked credential, stamped onto the + // command result (message suffix + detail.authfallback) however it ends. + private sealed class DownloadAuth + { + public string? Note { get; set; } + + public CommandResult Stamp(CommandResult result) + { + if (Note is null) + return result; + result.Message = $"{result.Message} ({Note})"; + result.Detail ??= new Dictionary(); + result.Detail["authfallback"] = Note; + return result; + } + } + + // GET for run_script/update_app bodies. A command claimed before a revoke + // downloads with the revoked credential and gets 401 CREDENTIAL_INVALID; the + // auth handler has already deleted it and woken enrollment. Resend ONCE, + // which picks up the shared key (or a credential enrolled meanwhile). + private async Task SendDownloadAsync( + HttpClient client, string url, string action, DownloadAuth auth, CancellationToken token) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + var response = await client.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, token); + if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized + || !request.Headers.Contains(AgentAuthHandler.CredentialHeader)) + return response; + + // Buffered by the auth handler on 401. + var body = await response.Content.ReadAsStringAsync(token); + if (AgentServerError.TryParse(body)?.Code != AgentAuthErrorCodes.CredentialInvalid) + return response; + + var credentials = httpFactory.Credentials; + if (credentials.Current is null && credentials.SharedKey is null) + { + auth.Note = "endpoint credential was revoked and no shared key is configured to retry the download; " + + "the PC must re-enroll"; + logger.LogWarning("{Action} download refused the revoked endpoint credential; no shared key to retry with", + action); + return response; + } + + response.Dispose(); + using var retry = new HttpRequestMessage(HttpMethod.Get, url); + var second = await client.SendAsync( + retry, HttpCompletionOption.ResponseHeadersRead, token); + var via = retry.Headers.Contains(AgentAuthHandler.SharedKeyHeader) + ? "the shared key" : "the newly enrolled credential"; + auth.Note = $"endpoint credential was revoked; download retried once with {via}: HTTP {(int)second.StatusCode}"; + logger.LogWarning("{Action} download refused the revoked endpoint credential; retried with {Via}: HTTP {Status}", + action, via, (int)second.StatusCode); + return second; + } + // MSI installs run through msiexec /i /qn (any SilentArgs appended); // an EXE runs directly with its version's SilentArgs (homegrown EXEs vary by // developer, so the switches are per-version server data, not guessed here). @@ -930,28 +2086,17 @@ public sealed class CommandExecutor return (packagePath, extra); } - // Fixed, known staging folder we control and that is Defender-excluded in - // the environment. LocalSystem's Path.GetTempPath() resolves to - // C:\Windows\SystemTemp on Win11/Server2022+, which we do not use; stage - // downloads here instead so run_script and update_app land in a predictable - // spot. Created on demand. - private static string GetStagingDirectory() - { - var programData = Environment.GetFolderPath( - Environment.SpecialFolder.CommonApplicationData); - var staging = Path.Combine(programData, "ShopDB", "EndpointAgent"); - Directory.CreateDirectory(staging); - return staging; - } - private static string HashFile(string path) { - using var stream = File.OpenRead(path); + using var stream = ProtectedFileSystem.OpenReadNoFollow(path); using var sha = SHA256.Create(); var hash = sha.ComputeHash(stream); return Convert.ToHexString(hash).ToLowerInvariant(); } + private static string HashBytes(byte[] bytes) + => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + // Join the agent base URL to a ShopDB-relative package path, tolerating a // trailing slash on the base and a leading slash on the path. private static string CombineUrl(string baseUrl, string path) diff --git a/agent/ShopDbEndpointAgent.Core/CommandWorker.cs b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs index 2d71232d..b4165a90 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandWorker.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs @@ -41,15 +41,20 @@ public sealed class CommandWorker : BackgroundService private readonly CommandExecutor executor; private readonly ILogger logger; private readonly CollectSignal? collectSignal; + private readonly AgentConfig? config; private readonly string hostname; + private readonly EnrollmentService? enrollment; public CommandWorker(CommandClient client, CommandExecutor executor, - ILogger logger, CollectSignal? collectSignal = null) + ILogger logger, CollectSignal? collectSignal = null, + AgentConfig? config = null, EnrollmentService? enrollment = null) { + this.enrollment = enrollment; this.client = client; this.executor = executor; this.logger = logger; this.collectSignal = collectSignal; + this.config = config; hostname = Environment.MachineName; } @@ -57,6 +62,30 @@ public sealed class CommandWorker : BackgroundService { logger.LogInformation("Command channel started for host {Host}", hostname); + // Spread first contact across the fleet. Every agent otherwise polls the + // instant its service starts, and the service is start=auto, so a patch + // reboot puts the whole fleet on the wire together. See StartupJitter. + var startupDelay = StartupJitter.Next(config?.StartupJitterSeconds ?? 0, logger); + if (startupDelay > TimeSpan.Zero) + { + logger.LogInformation( + "Delaying first command poll by {Delay}ms (startup jitter)", + (int)startupDelay.TotalMilliseconds); + await SafeDelay(startupDelay, stoppingToken); + } + + if (enrollment is not null) + { + try + { + await enrollment.WaitForFirstAttemptAsync(TimeSpan.FromSeconds(20), stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + while (!stoppingToken.IsCancellationRequested) { var pollStarted = System.Diagnostics.Stopwatch.StartNew(); diff --git a/agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs b/agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs new file mode 100644 index 00000000..1c4f8e90 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs @@ -0,0 +1,51 @@ +using System.Runtime.InteropServices; + +namespace ShopDbEndpointAgent.Core; + +// Hides the console window this process was given. +// +// shopdbagent.exe is a console executable, so anything that starts it in an +// interactive session gets a visible black window. That was invisible while the +// tray owned the probe - it launched with CreateNoWindow - but the user-context +// scheduled task runs the exe directly in the operator's session, where the +// window is very much visible and pops up on every collection cycle. +// +// The window is hidden rather than the exe rebuilt as a WinExe: the agent is +// also a service host and a command-line tool, and both of those want a real +// console for --once, --report and service debugging. +public static class ConsoleWindow +{ + private const int SW_HIDE = 0; + + [DllImport("kernel32.dll")] + private static extern nint GetConsoleWindow(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(nint window, int command); + + // Best-effort: a process with no console (already detached, or redirected by + // a caller) simply has nothing to hide, and failing to hide a window must + // never stop the collection that window belongs to. + public static void Hide() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + var window = GetConsoleWindow(); + if (window != nint.Zero) + { + ShowWindow(window, SW_HIDE); + } + } + catch + { + // Nothing useful to do or say: there is no log sink yet at the point + // this runs, and a visible window is cosmetic next to losing data. + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index d1c18f0f..ee7ace3f 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -44,11 +44,57 @@ public sealed class EndpointReport // collection: both Uninstall registry hives, {displayname, displayversion, // publisher}. Server logs it as an unpersisted key until the SBOM store lands. [JsonPropertyName("applications")] public object[]? Applications { get; set; } + + // True when Windows is holding a reboot. A machine that has been waiting to + // reboot for weeks explains stalled patching and "the fix did not take" + // reports, and on a shop floor PC nobody reboots voluntarily. + [JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; } + + // Which signal(s) drove pendingreboot, so the server can tell a box staged + // mid-servicing (pendingfilerename) from one with a stale servicing key. + // Empty array = checked, nothing pending. Omitted = the check failed. + [JsonPropertyName("rebootreasons")] public string[]? RebootReasons { get; set; } + + // hiberbootenabled (fast startup configured) and lastboottype (what the + // last boot actually did). Uptime is not trustworthy when these disagree. + [JsonPropertyName("bootinfo")] public object? BootInfo { get; set; } + + // Defender health. Persisted but deliberately not surfaced: known false + // positives in this environment. + [JsonPropertyName("defender")] public object? Defender { get; set; } + + // Physical drive health from two sources: MSFT_PhysicalDisk (the only one + // that reports NVMe) and Win32_DiskDrive.Status (ATA/SATA and fallback). + // Each entry carries a precomputed predictfailure so an alert is a boolean + // test rather than a string match against an enum. Null predictfailure + // means the drive did not answer - unknown, not healthy. + [JsonPropertyName("diskhealth")] public object[]? DiskHealth { get; set; } + + // Session-scoped data gathered by a probe running as the signed-in user: + // mapped drives and user-owned listeners. Null when nobody is signed in or + // the probe did not produce a result this cycle - absent means "not + // observed", which is deliberately distinct from an empty list. + [JsonPropertyName("usercontext")] public object? UserContext { get; set; } + + // Pending Dell Command | Update results, produced by the shopdb-dellupdates + // agent utility rather than collected in C#: the data comes from dcu-cli's + // own scan output, so the utility that parses it belongs next to the task + // that writes it. Null means no scan has ever run (or the utility is not + // installed), which is deliberately distinct from a scan that found nothing + // pending - the first is "unknown", the second is "known clean". + [JsonPropertyName("dellupdates")] public object? DellUpdates { get; set; } } public sealed class EndpointEventRecord { [JsonPropertyName("eventid")] public int EventId { get; set; } + + // The Windows event record id: monotonic and unique within its channel on a + // given machine. It is both the agent's harvest watermark and the server's + // dedupe key, so the same record can never be stored twice. Null only when + // the source record did not expose one. + [JsonPropertyName("eventrecordid")] public long? EventRecordId { get; set; } + [JsonPropertyName("eventchannel")] public string? EventChannel { get; set; } [JsonPropertyName("eventsource")] public string? EventSource { get; set; } [JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; } @@ -136,6 +182,28 @@ public sealed class AppDeliveryClaim // downloads with its collector credential; Sha256 pins the exact bytes; // RunContext is 'system' (the agent's own service account) or 'user' (the // interactive logged-on session), decided per script in the ShopDB library. +// WorkingDirectory is the optional folder to run the script IN. Empty means the +// agent's staging directory, which is what every script did before the field +// existed. It is there so a script can sit beside a corporate tool already +// installed on the endpoint and invoke it by relative path; it confers no +// rights, since the body is already arbitrary PowerShell that could change its +// own directory. +// ErrorMode is 'default' or 'strict'. Default is what PowerShell does unaided: +// a non-terminating error is written and the script continues, so powershell.exe +// still exits 0 and the command reports SUCCEEDED even when the body did none of +// its work. Strict makes the agent prepend $ErrorActionPreference = 'Stop' so the +// first error terminates and the exit code means what an operator reads it to +// mean. Per script, never global: strict changes execution semantics, and a body +// written to tolerate a failing cleanup or to probe with a cmdlet that errors on +// absence would start aborting halfway. An absent or unrecognised value is +// treated as default, so an old server that never sends the field is safe. +// CaptureOutput decides whether a SUCCESSFUL run returns its output to the +// server. Failures always return it, because a failure has to be diagnosable +// without walking to the machine. Success is opt-in per script: output carries +// whatever the body printed, and that is not worth persisting server-side for a +// routine green run. The endpoint log keeps the full copy regardless, so this +// only governs what crosses the wire. Absent means false, which is the same +// behaviour as before the field existed. public sealed class ScriptDeliveryClaim { [JsonPropertyName("scriptid")] public int ScriptId { get; set; } @@ -143,6 +211,9 @@ public sealed class ScriptDeliveryClaim [JsonPropertyName("installpath")] public string? InstallPath { get; set; } [JsonPropertyName("sha256")] public string? Sha256 { get; set; } [JsonPropertyName("runcontext")] public string? RunContext { get; set; } + [JsonPropertyName("workingdirectory")] public string? WorkingDirectory { get; set; } + [JsonPropertyName("errormode")] public string? ErrorMode { get; set; } + [JsonPropertyName("captureoutput")] public bool CaptureOutput { get; set; } } // Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }. diff --git a/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs b/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs index 57b44d7f..143ce78d 100644 --- a/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs +++ b/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs @@ -27,7 +27,15 @@ public static class CuratedEventCatalog new(55, "System", "NTFS file system corruption"), new(7011, "System", "Service Control Manager timeout"), new(10016, "System", "DCOM permission error"), + // Windows Error Reporting on the Application channel: 1000 is the + // faulting-application record (app, version, faulting module, exception + // code), 1001 is the WER bucket record Windows pairs with it, and 1002 + // is an unresponsive app rather than a crash. The structured detail for + // all three arrives in the record's EventData, which the collector lifts + // into `fields`. + new(1000, "Application", "Application error / faulting module"), new(1001, "Application", "Application error / bugcheck report"), + new(1002, "Application", "Application hang"), }; // The distinct channels the collector must read. diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs index 2486a66e..f1d6e8f2 100644 --- a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -1,5 +1,8 @@ using System.Diagnostics; +using System.Diagnostics.Eventing.Reader; using System.Management; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Win32; @@ -19,16 +22,28 @@ public sealed class EndpointCollector : ICollector // keep the payload bounded on a chatty Security log. private const int MaxEventsPerChannel = 25; + // The spool file is read every cycle. Reading it is a small file read, and + // the tray - not this cadence - decides how often the data is refreshed, so + // throttling the read here would only delay already-collected data reaching + // the server without saving any real work. + private readonly ILogger logger; private readonly AgentConfig config; private readonly IAgentUtilityRunner? utilities; + private readonly EventWatermarkStore? watermarks; + private readonly UserContextSpool? userContextSpool; + + private object? lastUserContext; public EndpointCollector(ILogger logger, AgentConfig config, - IAgentUtilityRunner? utilities = null) + IAgentUtilityRunner? utilities = null, EventWatermarkStore? watermarks = null, + UserContextSpool? userContextSpool = null) { this.logger = logger; this.config = config; this.utilities = utilities; + this.watermarks = watermarks; + this.userContextSpool = userContextSpool; } public EndpointReport Collect(string agentVersion, bool adopt) @@ -49,23 +64,232 @@ public sealed class EndpointCollector : ICollector MachineNumber = GetMachineNumber(), LastBootTime = GetLastBootTime(), Services = GetMonitoredTargets(), + Ports = GetListeningPorts(), Interfaces = GetInterfaces(), Events = GetCuratedEvents(), Disks = GetDisks(), + DiskHealth = GetDiskHealth(), Bios = GetBios(), Memory = GetMemory(), Processor = GetProcessor(), OsInfo = GetOsInfo(), Domain = GetDomain(), Applications = GetApplications(), + PendingReboot = GetPendingReboot(), + RebootReasons = GetRebootReasons(), + BootInfo = GetBootInfo(), + Defender = GetDefender(), + UserContext = GetUserContext(), }; if (report.LastBootTime is DateTimeOffset boot) report.UptimeSeconds = (long)(DateTimeOffset.UtcNow - boot).TotalSeconds; + report.CpuPercent = GetCpuPercent(); + report.MemoryPercent = GetMemoryPercent(); + report.IdleSeconds = GetIdleSecondsFromUserContext(); + return report; } + // Instantaneous CPU load from the _Total performance instance. + // + // Uses the WMI formatted-data class rather than PerformanceCounter because a + // PerformanceCounter reads 0 on its first sample and needs a second sample a + // second later to mean anything. Sleeping a second inside the collect path + // would delay the heartbeat on every tick for one number. The formatted-data + // class is already averaged by the OS, so a single read is usable. + private double? GetCpuPercent() + { + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT PercentProcessorTime FROM " + + "Win32_PerfFormattedData_PerfOS_Processor WHERE Name='_Total'"); + foreach (var mo in searcher.Get()) + { + var value = ToLong(mo["PercentProcessorTime"]); + if (value is long percent) + return Math.Clamp(percent, 0, 100); + } + } + catch (Exception error) + { + logger.LogDebug(error, "CPU percent query failed"); + } + return null; + } + + // Percentage of physical memory in use. Reported alongside the absolute + // byte counts in the memory object because the percentage is what an alert + // threshold keys off, and deriving it server-side would break whenever the + // byte fields are absent. + private double? GetMemoryPercent() + { + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT TotalVisibleMemorySize, FreePhysicalMemory " + + "FROM Win32_OperatingSystem"); + foreach (var mo in searcher.Get()) + { + var totalKb = ToLong(mo["TotalVisibleMemorySize"]); + var freeKb = ToLong(mo["FreePhysicalMemory"]); + if (totalKb is long total && total > 0 && freeKb is long free) + { + var used = (double)(total - free) / total * 100.0; + return Math.Round(Math.Clamp(used, 0, 100), 1); + } + } + } + catch (Exception error) + { + logger.LogDebug(error, "Memory percent query failed"); + } + return null; + } + + // Idle time is measured by the user-context probe, because GetLastInputInfo + // is per session and this service runs in session 0. Null when no probe + // result is available, which correctly reads as "not observed" rather than + // "the machine has been idle for zero seconds". + private long? GetIdleSecondsFromUserContext() + { + if (lastUserContext is not Dictionary context) + return null; + if (!context.TryGetValue("idleseconds", out var raw) || raw is null) + return null; + + try + { + if (raw is JsonElement element) + { + return element.ValueKind == JsonValueKind.Number + && element.TryGetInt64(out var parsed) ? parsed : null; + } + return Convert.ToInt64(raw); + } + catch (Exception error) + { + logger.LogDebug(error, "Could not read idleseconds from the user context"); + return null; + } + } + + // Listening TCP ports with the owning process name. + // + // Only listeners, never established connections: listeners answer "what + // service is this machine offering" (the DNC port, the MTConnect adapter), + // which is stable and small. Established connections churn constantly and + // would make every report differ from the last for no diagnostic gain, so + // the full table is exposed as an on-demand command instead. + private const int MaxListeners = 100; + + private object[] GetListeningPorts() + { + try + { + var listeners = TcpTableReader.GetListeners(); + if (listeners.Count == 0) + return Array.Empty(); + + var names = GetProcessNames(); + var result = new List(); + + foreach (var listener in listeners) + { + if (result.Count >= MaxListeners) + { + logger.LogDebug( + "Listener list truncated at {Limit} entries.", MaxListeners); + break; + } + + names.TryGetValue(listener.ProcessId, out var processName); + result.Add(new + { + localaddress = listener.LocalAddress, + localport = listener.LocalPort, + processid = listener.ProcessId, + processname = processName, + protocol = "tcp", + }); + } + + return result.ToArray(); + } + catch (Exception error) + { + logger.LogDebug(error, "Listening port enumeration failed"); + return Array.Empty(); + } + } + + // Snapshot the process table once and index by id. Resolving each listener + // individually would be an O(n) call per row and can race with process exit. + private Dictionary GetProcessNames() + { + var names = new Dictionary(); + try + { + foreach (var process in Process.GetProcesses()) + { + try + { + names[process.Id] = process.ProcessName; + } + catch + { + // The process exited between enumeration and read. + } + finally + { + process.Dispose(); + } + } + } + catch (Exception error) + { + logger.LogDebug(error, "Process name snapshot failed"); + } + return names; + } + + // Session-scoped data, read from the spool file the tray writes. The service + // only reads: it cannot collect this itself, because mapped drives and idle + // time do not exist in session 0. Between reads the previous result is + // repeated, because a mapped drive stays mapped and dropping the key would + // read on the server as "the drive went away". The spool's own age limit is + // what eventually retires a result, so a logged-off machine goes quiet + // instead of reporting the last operator forever. + private object? GetUserContext() + { + if (userContextSpool is null) + return null; + + var resultPath = userContextSpool.TryGetResultPath(out var reason); + if (resultPath is null) + { + logger.LogDebug("Skipping the user-context read: {Reason}", reason); + return lastUserContext; + } + + // Peek rather than consume: the probe overwrites the spool on every run, + // so the newest result is always the one on disk. The file deliberately + // survives a service restart, which the previous delete-on-read did not - + // lastUserContext is in-memory only, so a restart used to leave the agent + // with no drives to report until the tray's next probe up to 30 minutes + // later. lastUserContext still repeats the previous value between reads, + // because the tray collects on a slower cadence than telemetry and + // dropping the key in between would read on the server as "the drives + // went away". + var collected = userContextSpool.ConsumeResult(resultPath); + if (collected is not null) + lastUserContext = collected; + + return lastUserContext; + } + // The interactive tray app ships and updates independently of this service, // so the agent reports the tray's file version as its own field. Resolve the // tray exe beside the agent first (the normal install layout), then the @@ -296,6 +520,7 @@ public sealed class EndpointCollector : ICollector private object[] GetInterfaces() { var result = new List(); + var linkstate = GetLinkState(); try { using var searcher = new ManagementObjectSearcher( @@ -321,6 +546,10 @@ public sealed class EndpointCollector : ICollector var gateway = gateways is null || gateways.Length == 0 ? null : gateways[0]; + // Paired by MAC from MSFT_NetAdapter. Absent when that class is + // unavailable, which must leave the rest of the entry intact. + linkstate.TryGetValue(NormalizeMac(mac) ?? "", out var link); + result.Add(new { alias = mo["Description"]?.ToString()?.Trim() ?? "", @@ -332,6 +561,10 @@ public sealed class EndpointCollector : ICollector gateway = gateway ?? "", subnet = subnet ?? "", dhcp = mo["DHCPEnabled"] as bool? ?? false, + // Null rather than 0/false when link state was not + // readable: an unknown duplex must not read as half. + linkspeedbps = link.Speed, + fullduplex = link.FullDuplex, }); } } @@ -451,40 +684,33 @@ public sealed class EndpointCollector : ICollector } } - // Walk each curated channel newest-first, keep only the curated IDs for - // that channel, cap per channel. Reads Security/System/Application - the - // old scaffold read System only and filtered by the wrong IDs. + // Walk each curated channel newest-first and report ONLY records newer than + // the stored watermark, so a record crosses the wire exactly once. + // + // Two things changed here versus the original scaffold. First, filtering by + // event id now happens inside the event log service through an XPath query + // instead of walking every entry in the channel from C#; on a busy Security + // log that is the difference between touching a handful of records and + // hundreds of thousands. Second, EventLogReader (not the legacy EventLog + // API) exposes RecordId, which is what makes exactly-once possible. + // + // First contact on a channel has no watermark, so it sends a bounded tail + // (MaxEventsPerChannel) for initial context rather than the whole backlog. + // The same cap also bounds a burst: if more than the cap arrived since the + // last cycle, the NEWEST are taken and the watermark still jumps to the + // newest, so the payload can never balloon. private EndpointEventRecord[] GetCuratedEvents() { var records = new List(); foreach (var channel in CuratedEventCatalog.Channels) { var wanted = CuratedEventCatalog.IdsForChannel(channel); + if (wanted.Count == 0) + continue; + try { - using var log = new EventLog(channel); - int kept = 0; - for (int i = log.Entries.Count - 1; - i >= 0 && kept < MaxEventsPerChannel; i--) - { - EventLogEntry entry; - try { entry = log.Entries[i]; } catch { continue; } - - int id = (int)(entry.InstanceId & 0xFFFF); - if (!wanted.Contains(id)) - continue; - - records.Add(new EndpointEventRecord - { - EventId = id, - EventChannel = channel, - EventSource = entry.Source, - EventTime = new DateTimeOffset( - entry.TimeGenerated.ToUniversalTime(), TimeSpan.Zero), - EventMessage = Truncate(entry.Message, 1000), - }); - kept++; - } + records.AddRange(ReadChannel(channel, wanted)); } catch (Exception error) { @@ -493,9 +719,172 @@ public sealed class EndpointCollector : ICollector logger.LogDebug(error, "Event channel {Channel} unreadable", channel); } } + + watermarks?.Save(); return records.ToArray(); } + private IEnumerable ReadChannel( + string channel, IReadOnlySet wanted) + { + long watermark = watermarks?.GetWatermark(channel) ?? 0; + bool seeding = watermarks is null || !watermarks.HasWatermark(channel); + + var query = new EventLogQuery(channel, PathType.LogName, BuildIdFilter(wanted)) + { + // Newest-first so the cap keeps the most recent records and we can + // stop as soon as we reach the watermark. + ReverseDirection = true, + }; + + var found = new List(); + long newestSeen = 0; + + using var reader = new EventLogReader(query); + while (found.Count < MaxEventsPerChannel) + { + EventRecord? entry; + try { entry = reader.ReadEvent(); } + catch (Exception error) + { + logger.LogDebug(error, "Event read failed on {Channel}", channel); + break; + } + + if (entry is null) + break; + + using (entry) + { + long recordId = entry.RecordId ?? 0; + if (recordId > newestSeen) + newestSeen = recordId; + + // Reverse order means everything from here down is older than + // the watermark and has already been reported. + if (!seeding && recordId != 0 && recordId <= watermark) + break; + + found.Add(ToEventRecord(entry, channel, recordId)); + } + } + + // A cleared log restarts record ids low; reseed rather than go silent. + if (!seeding && newestSeen > 0 + && watermarks is not null && watermarks.ResetIfStale(channel, newestSeen)) + { + return found; + } + + if (newestSeen > 0) + watermarks?.Advance(channel, newestSeen); + + return found; + } + + private EndpointEventRecord ToEventRecord( + EventRecord entry, string channel, long recordId) + { + string? message = null; + try { message = entry.FormatDescription(); } + catch (Exception error) + { + // A provider whose message DLL is missing cannot render text; the + // id, time, and source are still worth reporting. + logger.LogDebug(error, "Event message not renderable on {Channel}", channel); + } + + return new EndpointEventRecord + { + EventId = entry.Id, + EventRecordId = recordId == 0 ? null : recordId, + EventChannel = channel, + EventSource = entry.ProviderName, + EventTime = entry.TimeCreated is DateTime created + ? new DateTimeOffset(created.ToUniversalTime(), TimeSpan.Zero) + : null, + EventMessage = Truncate(message, 1000), + Fields = GetEventFields(entry, channel), + }; + } + + // Bounds on the structured field extraction. The rendered message is often + // a paragraph of prose while the useful specifics (which account, which + // logon type, which faulting module) live in EventData, so it is worth + // carrying - but a single record can declare a lot of elements and some + // providers emit large blobs, so both the count and each value are capped. + private const int MaxEventFields = 30; + private const int MaxEventFieldLength = 512; + + // Pull the provider's structured payload out of the event XML into the + // existing `fields` dictionary, which the server already stores as + // payloadjson. Most providers use v; + // some use with an arbitrary provider-defined subtree, so both + // are walked. Unnamed elements keep their positional index as the + // key, because for several catalog IDs position IS the meaning. + // + // Never throws: a provider with malformed or unreadable XML degrades to no + // fields rather than losing the whole record. + private Dictionary? GetEventFields(EventRecord entry, string channel) + { + try + { + var xml = entry.ToXml(); + if (string.IsNullOrWhiteSpace(xml)) + return null; + + var document = System.Xml.Linq.XDocument.Parse(xml); + var root = document.Root; + if (root is null) + return null; + + var container = + root.Elements().FirstOrDefault(e => e.Name.LocalName == "EventData") + ?? root.Elements().FirstOrDefault(e => e.Name.LocalName == "UserData"); + if (container is null) + return null; + + var fields = new Dictionary(); + int unnamed = 0; + foreach (var element in container.Descendants()) + { + if (fields.Count >= MaxEventFields) + break; + if (element.HasElements) + continue; + + var value = element.Value; + if (string.IsNullOrWhiteSpace(value)) + continue; + + var name = element.Attribute("Name")?.Value; + if (string.IsNullOrWhiteSpace(name)) + { + name = element.Name.LocalName == "Data" + ? "data" + unnamed++ + : element.Name.LocalName; + } + + fields[name!] = Truncate(value, MaxEventFieldLength) ?? string.Empty; + } + + return fields.Count > 0 ? fields : null; + } + catch (Exception error) + { + logger.LogDebug(error, "Event XML not parseable on {Channel}", channel); + return null; + } + } + + // An XPath the event log service evaluates itself, e.g. + // *[System[(EventID=4624 or EventID=4625)]] + private static string BuildIdFilter(IReadOnlySet wanted) + { + var clauses = string.Join(" or ", wanted.Select(id => "EventID=" + id)); + return "*[System[(" + clauses + ")]]"; + } + // Installed-software SBOM. Mirrors the Assess-Orchestrator applications // collection exactly: enumerate both Uninstall registry hives (64-bit and // WOW6432Node), keep only entries with a DisplayName, and emit @@ -547,11 +936,15 @@ public sealed class EndpointCollector : ICollector if (!seen.Add(displayName + "\u0000" + displayVersion)) continue; + var fingerprint = ComputeApplicationFingerprint( + displayName, displayVersion, publisher); + result.Add(new { displayname = displayName, displayversion = displayVersion, publisher, + fingerprint, }); } catch (Exception error) @@ -566,6 +959,256 @@ public sealed class EndpointCollector : ICollector } } + // Compute SHA256 fingerprint for application inventory delta optimization. + // Format: SHA256(displayname|displayversion|publisher) as lowercase hex string. + // Matches backend algorithm in plugins/endpoints/plugin.py and backfill script. + // Empty strings are preserved (not converted to null) to match backend behavior. + private static string ComputeApplicationFingerprint( + string displayName, string displayVersion, string publisher) + { + var normalized = $"{displayName}|{displayVersion}|{publisher}"; + var bytes = Encoding.UTF8.GetBytes(normalized); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + // Whether Windows is waiting on a reboot. + // + // There is no single API for this, so we check the three signals Windows + // itself uses: the CBS servicing flag, the Windows Update auto-update flag, + // and a queued file-rename operation. Any one of them is sufficient. Checked + // by key EXISTENCE rather than value, which is how these flags work - the + // key is created when a reboot is needed and deleted once it happens. + // Link speed and duplex, keyed by MAC so the caller can pair them onto the + // matching GetInterfaces() entry. Deliberately a separate query: the + // IPEnabled config class above does not carry link state, and a NIC that + // negotiated 100Mb half-duplex on a gigabit switch port is a cable/port + // fault that otherwise presents as "the machine is slow sometimes". + private Dictionary GetLinkState() + { + var byMac = new Dictionary( + StringComparer.OrdinalIgnoreCase); + + // MSFT_NetAdapter carries duplex; Win32_NetworkAdapter does not. It + // lives in the same Storage-style optional namespace family, so a + // failure here must not take the interface list with it. + try + { + using var searcher = new ManagementObjectSearcher( + @"root\StandardCimv2", + "SELECT PermanentAddress, Speed, FullDuplex, MediaConnectState " + + "FROM MSFT_NetAdapter"); + foreach (var mo in searcher.Get()) + { + var mac = NormalizeMac(mo["PermanentAddress"]?.ToString()); + if (mac is null) + continue; + byMac[mac] = (ToLong(mo["Speed"]), mo["FullDuplex"] as bool?); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Net adapter link state query failed"); + } + + return byMac; + } + + // WMI reports MACs in several shapes (00:11:22, 001122AABBCC). Strip to hex + // so the two sources can be matched at all. + private static string? NormalizeMac(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + var hex = new string(Array.FindAll(raw.ToCharArray(), Uri.IsHexDigit)); + return hex.Length == 12 ? hex : null; + } + + // Whether the last start was a true cold boot or a fast-startup resume. + // + // This matters because uptime LIES under fast startup: the box reports days + // of uptime after what the operator experienced as a shutdown, and a + // "reboot" that was really a hybrid resume does not clear the driver or + // kernel state that a support call is usually trying to clear. + // + // Two distinct facts, deliberately not collapsed: + // hiberbootenabled - fast startup is CONFIGURED (registry, always readable) + // lastboottype - what the last boot ACTUALLY did (Kernel-Boot event 27) + // The registry alone cannot tell you what the last boot did, and the event + // may have aged out of the log, so neither substitutes for the other. + private object? GetBootInfo() + { + bool? hiberboot = null; + try + { + using var power = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\Session Manager\Power"); + if (power?.GetValue("HiberbootEnabled") is int raw) + hiberboot = raw != 0; + } + catch (Exception error) + { + logger.LogDebug(error, "Hiberboot registry read failed"); + } + + string? lastboottype = null; + try + { + // Kernel-Boot 27 BootType: 0 cold, 1 hybrid (fast startup), + // 2 resume from hibernate. + var query = new EventLogQuery( + "System", PathType.LogName, + "*[System[Provider[@Name='Microsoft-Windows-Kernel-Boot'] " + + "and (EventID=27)]]") + { ReverseDirection = true }; + using var reader = new EventLogReader(query); + using var record = reader.ReadEvent(); + if (record?.Properties.Count > 0) + { + lastboottype = ToInt(record.Properties[0].Value) switch + { + 0 => "Cold", + 1 => "Fast startup", + 2 => "Resume", + _ => null, + }; + } + } + catch (Exception error) + { + logger.LogDebug(error, "Boot type event read failed"); + } + + if (hiberboot is null && lastboottype is null) + return null; + + return new + { + hiberbootenabled = hiberboot, + lastboottype = lastboottype ?? "Unknown", + }; + } + + // Defender status. Collected and persisted but NOT surfaced: this + // environment is known to produce false positives, so wiring it to a card + // or an alert now would train operators to ignore the card. Stored so the + // signal is already there when someone is ready to interpret it. + private object? GetDefender() + { + try + { + using var searcher = new ManagementObjectSearcher( + @"root\Microsoft\Windows\Defender", + "SELECT AMServiceEnabled, RealTimeProtectionEnabled, " + + "AntivirusSignatureAge, AntispywareSignatureAge, " + + "QuickScanAge FROM MSFT_MpComputerStatus"); + foreach (var mo in searcher.Get()) + { + return new + { + serviceenabled = mo["AMServiceEnabled"] as bool?, + realtimeenabled = mo["RealTimeProtectionEnabled"] as bool?, + // Ages in days. Null when Defender did not report rather + // than 0, which would read as "updated today". + signatureagedays = ToLong(mo["AntivirusSignatureAge"]), + antispywareagedays = ToLong(mo["AntispywareSignatureAge"]), + quickscanagedays = ToLong(mo["QuickScanAge"]), + }; + } + } + catch (Exception error) + { + // A third-party AV box or a stripped image has no Defender WMI + // provider at all. That is normal here, not a fault. + logger.LogDebug(error, "Defender status query failed"); + } + return null; + } + + // Which signal(s) drove pendingreboot=true, in report order. + // + // pendingreboot is a single bool OR-ed from three sources, so it cannot say + // WHY. That distinction is the point: PendingFileRenameOperations is the + // stronger signal - files are staged to be replaced on next boot, so the + // box is mid-servicing - whereas a lone CBS or Windows Update key can sit + // set for weeks on a machine nobody has rebooted. Same boolean, very + // different urgency. + // + // Empty array = checked, nothing pending. Null = the check failed, matching + // pendingreboot's own null-is-unknown rule. + private string[]? GetRebootReasons() + { + try + { + var reasons = new List(); + + if (LocalMachineKeyExists( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending")) + { + reasons.Add("componentbasedservicing"); + } + + if (LocalMachineKeyExists( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired")) + { + reasons.Add("windowsupdate"); + } + + using var sessionManager = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\Session Manager"); + if (sessionManager?.GetValue("PendingFileRenameOperations") is string[] pending + && pending.Length > 0) + { + reasons.Add("pendingfilerename"); + } + + return reasons.ToArray(); + } + catch (Exception error) + { + logger.LogDebug(error, "Reboot reason check failed"); + return null; + } + } + + private bool? GetPendingReboot() + { + try + { + const string cbs = + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"; + const string windowsUpdate = + @"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"; + + if (LocalMachineKeyExists(cbs) || LocalMachineKeyExists(windowsUpdate)) + return true; + + using var sessionManager = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\Session Manager"); + if (sessionManager?.GetValue("PendingFileRenameOperations") is string[] pending + && pending.Length > 0) + { + return true; + } + + return false; + } + catch (Exception error) + { + // Null, not false: an unreadable registry means we do not know, and + // reporting a confident "no reboot pending" would be worse than + // reporting nothing. + logger.LogDebug(error, "Pending reboot check failed"); + return null; + } + } + + private static bool LocalMachineKeyExists(string path) + { + using var key = Registry.LocalMachine.OpenSubKey(path); + return key is not null; + } + // Fixed local disks with capacity/free so the server can flag a PC that is // running out of space. Win32_LogicalDisk DriveType 3 = local fixed disk; // network/removable drives are skipped. Bytes stay as longs; the server can @@ -591,6 +1234,14 @@ public sealed class EndpointCollector : ICollector freebytes = free, usedbytes = size is long total && free is long available ? total - available : (long?)null, + // Precomputed so an alert threshold is a direct comparison. + // A 40GB free reading means nothing without the capacity it + // came from, and the server would have to guard against a + // null or zero size on every evaluation to derive it. + freepercent = size is long capacity && capacity > 0 + && free is long remaining + ? Math.Round((double)remaining / capacity * 100.0, 1) + : (double?)null, }); } } @@ -601,6 +1252,129 @@ public sealed class EndpointCollector : ICollector return result.ToArray(); } + // Physical drive health, so a failing disk is caught before it takes the + // machine and whatever was local on it. + // + // Two sources, because neither covers the fleet alone: + // + // MSFT_PhysicalDisk (Storage namespace) is the only one that reports NVMe. + // The classic SMART class, MSStorageDriver_FailurePredictStatus, is ATA/SATA + // only and returns NOTHING on an NVMe box - verified on a two-NVMe machine + // here, where it yielded zero instances while the drives were present and + // healthy. Collecting only that class would have reported an empty disk + // health list on every modern endpoint and looked like "no problems". + // + // Win32_DiskDrive.Status is the old reliable string ("OK"/"Pred Fail") and + // covers anything the Storage namespace does not, including machines where + // that namespace is missing or blocked. + // + // Keyed by DeviceId so the server can pair the two views per drive the same + // way mapped drives are paired. + private object[] GetDiskHealth() + { + var result = new List(); + + // MSFT_PhysicalDisk returns raw enum INTEGERS through + // ManagementObjectSearcher, not the friendly strings Get-PhysicalDisk + // prints. Verified on a live box: Health=0 Op=2 for a healthy NVMe. + // Mapping them here keeps the wire self-describing; shipping bare + // integers would make the server guess at an undocumented enum. + try + { + using var searcher = new ManagementObjectSearcher( + @"root\Microsoft\Windows\Storage", + "SELECT DeviceId, FriendlyName, MediaType, BusType, " + + "HealthStatus, OperationalStatus FROM MSFT_PhysicalDisk"); + foreach (var mo in searcher.Get()) + { + var health = ToInt(mo["HealthStatus"]); + result.Add(new + { + deviceid = mo["DeviceId"]?.ToString()?.Trim() ?? "", + model = mo["FriendlyName"]?.ToString()?.Trim() ?? "", + mediatype = PhysicalDiskMediaType(ToInt(mo["MediaType"])), + bustype = PhysicalDiskBusType(ToInt(mo["BusType"])), + healthstatus = PhysicalDiskHealth(health), + // The actionable bit, precomputed so an alert is a direct + // boolean test rather than a string comparison against an + // enum the server would have to keep in sync with this file. + // Null when health is unreadable: unknown is not healthy, + // but it is not a confident failure either. + predictfailure = health is null ? (bool?)null : health != 0, + source = "physicaldisk", + }); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Physical disk health query failed"); + } + + // Win32_DiskDrive as the ATA/SATA and fallback view. Status is a plain + // string here, no enum mapping needed. + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT DeviceID, Model, Status FROM Win32_DiskDrive"); + foreach (var mo in searcher.Get()) + { + var status = mo["Status"]?.ToString()?.Trim() ?? ""; + result.Add(new + { + deviceid = mo["DeviceID"]?.ToString()?.Trim() ?? "", + model = mo["Model"]?.ToString()?.Trim() ?? "", + mediatype = "", + bustype = "", + healthstatus = status, + // "OK" is the only healthy value this class reports; + // "Pred Fail" is the SMART trip. An empty status means the + // drive did not answer, which is unknown rather than failed. + predictfailure = status.Length == 0 + ? (bool?)null + : !status.Equals("OK", StringComparison.OrdinalIgnoreCase), + source = "diskdrive", + }); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Disk drive status query failed"); + } + + return result.ToArray(); + } + + // MSFT_PhysicalDisk HealthStatus: 0 Healthy, 1 Warning, 2 Unhealthy. + private static string PhysicalDiskHealth(int? value) => value switch + { + 0 => "Healthy", + 1 => "Warning", + 2 => "Unhealthy", + _ => "Unknown", + }; + + // MSFT_PhysicalDisk MediaType: 3 HDD, 4 SSD, 5 SCM. + private static string PhysicalDiskMediaType(int? value) => value switch + { + 3 => "HDD", + 4 => "SSD", + 5 => "SCM", + _ => "Unknown", + }; + + // MSFT_PhysicalDisk BusType. Only the values that actually appear on an + // endpoint are named; anything else stays numeric rather than guessed at. + private static string PhysicalDiskBusType(int? value) => value switch + { + 3 => "ATA", + 7 => "USB", + 8 => "RAID", + 9 => "iSCSI", + 11 => "SATA", + 17 => "NVMe", + _ => value is null ? "Unknown" : value.Value.ToString(), + }; + // Firmware identity from Win32_BIOS: vendor, version string, and release // date. Lets the server track BIOS/UEFI levels for a fleet update campaign. private object? GetBios() @@ -737,6 +1511,14 @@ public sealed class EndpointCollector : ICollector catch { return null; } } + private static int? ToInt(object? value) + { + if (value is null) + return null; + try { return Convert.ToInt32(value); } + catch { return null; } + } + // Win32 CIM datetimes (yyyymmddHHMMSS.ffffff+zzz) render as an ISO-8601 UTC // string; anything unparseable passes through untouched so nothing is lost. private string? ToBiosDate(string? raw) diff --git a/agent/ShopDbEndpointAgent.Core/EnrollmentService.cs b/agent/ShopDbEndpointAgent.Core/EnrollmentService.cs new file mode 100644 index 00000000..4c466d2e --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/EnrollmentService.cs @@ -0,0 +1,357 @@ +using System.Management; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace ShopDbEndpointAgent.Core; + +// Gets this PC its own endpoint credential (POST /api/endpoints/enroll) and +// keeps trying until it has one. Workers run on the shared key meanwhile, so an +// unenrolled or pending PC keeps reporting. +// +// 201 enrolled store credential, swap provider, idle until woken +// 202 pending PendingSchedule: 1 min, then 5 min, then 15 min +// 404 server predates enrollment: idle, retry every 6 h +// other back off 1, 5, 15 min (cap); a 429 or 5xx lands here, so a +// rate limit drops out of the fast pending phase for that try +// +// Woken early by the provider on CREDENTIAL_INVALID (credential deleted) and on +// SHARED_KEY_REFUSED while unenrolled, floored at one attempt a minute. +public sealed class EnrollmentService : BackgroundService +{ + public const string DeviceIdValue = "DeviceId"; + + // Retry while pending, by how long this request has been pending. An admin + // usually approves within a couple of minutes and a tech is standing there, + // so ask often at first. Jitter is added, never subtracted, so MinWakeInterval + // cannot clip it back into lockstep across a room of PCs. + private static readonly (TimeSpan Waited, TimeSpan Retry, TimeSpan Jitter)[] PendingSchedule = + { + (TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(8)), + (TimeSpan.FromMinutes(30), TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(20)), + (TimeSpan.MaxValue, TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(2)), + }; + private static readonly TimeSpan UnsupportedRetry = TimeSpan.FromHours(6); + private static readonly TimeSpan[] ErrorBackoff = + { + TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(15), + }; + private static readonly TimeSpan MinWakeInterval = TimeSpan.FromMinutes(1); + + private readonly AgentConfig config; + private readonly AgentHttpFactory httpFactory; + private readonly AgentCredentialProvider credentials; + private readonly CredentialStore store; + private readonly string agentVersion; + private readonly ILogger logger; + private readonly SemaphoreSlim wake = new(0, 1); + private readonly TaskCompletionSource firstAttempt = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Random jitter = new(); + + public EnrollmentService(AgentConfig config, AgentHttpFactory httpFactory, + AgentCredentialProvider credentials, CredentialStore store, string agentVersion, + ILogger logger) + { + this.config = config; + this.httpFactory = httpFactory; + this.credentials = credentials; + this.store = store; + this.agentVersion = agentVersion; + this.logger = logger; + credentials.EnrollmentNeeded += (_, _) => Wake(); + } + + // Workers await this (bounded) before first contact so a PC that can enroll + // right away does its first report on its own credential. + public async Task WaitForFirstAttemptAsync(TimeSpan limit, CancellationToken token) + { + try + { + await firstAttempt.Task.WaitAsync(limit, token); + } + catch (TimeoutException) + { + } + } + + public void Wake() + { + try + { + if (wake.CurrentCount == 0) + wake.Release(); + } + catch (SemaphoreFullException) + { + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + int failures = 0; + var lastAttempt = DateTime.MinValue; + // Start of the current pending run; reset on a new request id so a + // reject-and-ask-again (or a revoke) gets the fast phase over again. + DateTime? pendingSince = null; + int? pendingRequest = null; + using var client = httpFactory.CreateClient(AgentHttpChannel.Enroll); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + if (credentials.Current is not null) + { + pendingSince = null; + pendingRequest = null; + firstAttempt.TrySetResult(); + await wake.WaitAsync(stoppingToken); + continue; + } + + var sinceLast = DateTime.UtcNow - lastAttempt; + if (sinceLast < MinWakeInterval) + await Task.Delay(MinWakeInterval - sinceLast, stoppingToken); + + lastAttempt = DateTime.UtcNow; + var (outcome, requestId) = await TryEnrollAsync(client, stoppingToken); + firstAttempt.TrySetResult(); + + TimeSpan delay; + switch (outcome) + { + case EnrollOutcome.Enrolled: + failures = 0; + pendingSince = null; + pendingRequest = null; + continue; + case EnrollOutcome.Pending: + failures = 0; + if (pendingSince is null + || (requestId is not null && requestId != pendingRequest)) + { + pendingSince = DateTime.UtcNow; + pendingRequest = requestId; + } + delay = PendingDelay(DateTime.UtcNow - pendingSince.Value); + logger.LogDebug("Next enrollment attempt in {Delay}.", delay); + break; + case EnrollOutcome.Unsupported: + failures = 0; + pendingSince = null; + pendingRequest = null; + delay = UnsupportedRetry; + break; + default: + delay = ErrorBackoff[Math.Min(failures, ErrorBackoff.Length - 1)]; + failures++; + break; + } + + // A wake cuts the wait short (credential refused, shared key refused). + await wake.WaitAsync(delay, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + finally + { + firstAttempt.TrySetResult(); + } + } + + private enum EnrollOutcome { Enrolled, Pending, Unsupported, Failed } + + private TimeSpan PendingDelay(TimeSpan waited) + { + foreach (var step in PendingSchedule) + { + if (waited < step.Waited) + return step.Retry + + TimeSpan.FromSeconds(jitter.NextDouble() * step.Jitter.TotalSeconds); + } + return PendingSchedule[^1].Retry; + } + + private async Task<(EnrollOutcome Outcome, int? RequestId)> TryEnrollAsync( + HttpClient client, CancellationToken token) + { + var hostname = Environment.MachineName; + var serial = ReadBiosSerial(); + if (string.IsNullOrWhiteSpace(serial)) + { + logger.LogError("Enrollment skipped: no BIOS serial number from WMI Win32_BIOS."); + return (EnrollOutcome.Failed, null); + } + + string nonce; + try + { + nonce = store.GetOrCreateEnrollNonce(); + } + catch (Exception error) + { + logger.LogError(error, "Enrollment skipped: could not read or create enroll.json."); + return (EnrollOutcome.Failed, null); + } + + var request = new + { + hostname, + serialnumber = serial, + deviceid = ReadDeviceId(), + enrollnonce = nonce, + agentversion = agentVersion, + }; + + try + { + using var response = await client.PostAsJsonAsync("api/endpoints/enroll", request, token); + var body = await response.Content.ReadAsStringAsync(token); + + if (response.StatusCode == HttpStatusCode.NotFound + && AgentServerError.TryParse(body)?.Code is null or "" or "NOT_FOUND") + { + logger.LogInformation( + "Server has no enrollment endpoint (HTTP 404); staying on the shared key, retry in {Delay}.", + UnsupportedRetry); + return (EnrollOutcome.Unsupported, null); + } + + if (response.StatusCode == HttpStatusCode.Created) + { + var result = ParseResult(body); + var secret = result.GetString("credential"); + if (secret is null || !secret.StartsWith("sdbe_", StringComparison.Ordinal)) + { + logger.LogError("Enrollment returned 201 without a usable credential."); + return (EnrollOutcome.Failed, null); + } + + var credential = new AgentCredential + { + Credential = secret, + AssetId = result.GetInt("assetid") ?? 0, + IssuedAt = DateTime.UtcNow.ToString("o"), + BaseUrl = config.BaseUrl.Trim().TrimEnd('/'), + }; + try + { + credentials.Set(credential); + } + catch (Exception error) + { + // Shown once by the server; an admin must revoke and let it re-enroll. + logger.LogError(error, + "Enrolled as asset {AssetId} but could not store credential {Prefix}. " + + "Revoke it in ShopDB so this PC can enroll again.", + credential.AssetId, credential.Prefix); + return (EnrollOutcome.Failed, null); + } + return (EnrollOutcome.Enrolled, null); + } + + if (response.StatusCode == HttpStatusCode.Accepted) + { + var result = ParseResult(body); + var requestId = result.GetInt("requestid"); + logger.LogInformation( + "Enrollment pending (request {RequestId}): {Reason}.", + requestId, result.GetString("reason") ?? "no reason given"); + return (EnrollOutcome.Pending, requestId); + } + + var refusal = AgentServerError.TryParse(body); + if (refusal?.Code == "ENROLL_NONCE_MISMATCH") + { + logger.LogError( + "Enrollment refused: the approved request belongs to a different enroll nonce. " + + "An admin must reject it so this PC can request again."); + // No request id: keeps the current pending run and its schedule. + return (EnrollOutcome.Pending, null); + } + + logger.LogWarning("Enrollment failed: HTTP {Status} {Code} {Message}", + (int)response.StatusCode, refusal?.Code, refusal?.Message); + return (EnrollOutcome.Failed, null); + } + catch (Exception error) when ( + error is HttpRequestException or TaskCanceledException or JsonException + && !token.IsCancellationRequested) + { + logger.LogWarning("Enrollment request failed: {Message}", error.Message); + return (EnrollOutcome.Failed, null); + } + } + + // Accepts the bare body or the success envelope's data object. + private static ResultReader ParseResult(string body) + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object) + root = data; + return new ResultReader(root.Clone()); + } + + private readonly struct ResultReader + { + private readonly JsonElement root; + public ResultReader(JsonElement root) => this.root = root; + + public string? GetString(string name) + => root.ValueKind == JsonValueKind.Object + && root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() : null; + + public int? GetInt(string name) + => root.ValueKind == JsonValueKind.Object + && root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number + && value.TryGetInt32(out var number) + ? number : null; + } + + private string? ReadBiosSerial() + { + try + { + using var searcher = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS"); + foreach (var item in searcher.Get()) + { + var value = item["SerialNumber"]?.ToString()?.Trim(); + if (!string.IsNullOrWhiteSpace(value)) + return value.Length > 128 ? value[..128] : value; + } + } + catch (Exception error) + { + logger.LogWarning(error, "WMI Win32_BIOS query failed"); + } + return null; + } + + private string? ReadDeviceId() + { + foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) + { + try + { + using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view); + using var key = machine.OpenSubKey(AgentConfigProvider.RegistryPath); + if (key?.GetValue(DeviceIdValue) is string value && !string.IsNullOrWhiteSpace(value)) + return value.Trim(); + } + catch (Exception error) + { + logger.LogDebug(error, "Registry read of {Value} in {View} view failed", DeviceIdValue, view); + } + } + return null; + } +} diff --git a/agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs b/agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs new file mode 100644 index 00000000..ba4cfdd4 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Remembers the newest Windows event record id already reported for each +// curated channel, so a collect cycle harvests ONLY records the server has not +// seen. +// +// Why this exists: the collector used to re-read the newest N records of every +// channel on every cycle, and the server ingest appends event rows without a +// dedupe check. A single crash therefore landed in the database once per +// cadence tick for as long as it stayed inside that window - up to 96 identical +// rows a day per event. Carrying a watermark makes the steady state "only what +// is new", which is also what makes the full event XML affordable to send. +// +// The watermark is persisted under %ProgramData% (not in memory) because a +// service restart would otherwise re-report the whole window again, which is +// exactly the crash-loop case where duplicate events are most likely. +// +// Record ids are per channel and monotonic on a given machine. A cleared or +// rebuilt event log restarts them at 1; that is handled by treating a stored +// watermark HIGHER than the newest available record as stale and resetting it, +// otherwise the channel would go permanently silent. +public sealed class EventWatermarkStore +{ + private const string StoreFileName = "eventwatermark.json"; + + // %ProgramData%\ShopDB\EndpointAgent - the same root the rolling agent log + // lives under, so all agent-owned state sits in one place. + public static string DefaultStateDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "ShopDB", "EndpointAgent"); + + private readonly string storePath; + private readonly ILogger logger; + private readonly Dictionary watermarks; + + public EventWatermarkStore(ILogger logger, + string? stateDirectory = null) + { + this.logger = logger; + var directory = string.IsNullOrWhiteSpace(stateDirectory) + ? DefaultStateDirectory + : stateDirectory; + storePath = Path.Combine(directory, StoreFileName); + watermarks = Load(); + } + + // The last record id reported for this channel, or 0 when the channel has + // never been harvested. 0 means "first contact": the caller sends a bounded + // tail for initial context rather than the entire backlog. + public long GetWatermark(string channel) + => watermarks.TryGetValue(channel, out var value) ? value : 0; + + public bool HasWatermark(string channel) + => watermarks.ContainsKey(channel); + + // Advance the channel forward only. Never moves backwards, so an + // out-of-order read cannot cause the same record to be reported twice. + public void Advance(string channel, long recordId) + { + if (recordId <= 0) + return; + if (watermarks.TryGetValue(channel, out var current) && current >= recordId) + return; + watermarks[channel] = recordId; + } + + // A cleared or rebuilt log restarts record ids near 1. When the newest + // record available is below the stored watermark the store is stale, so + // drop it and let the channel re-seed instead of going silent forever. + public bool ResetIfStale(string channel, long newestAvailableRecordId) + { + if (newestAvailableRecordId <= 0) + return false; + if (!watermarks.TryGetValue(channel, out var current) || current <= newestAvailableRecordId) + return false; + + logger.LogInformation( + "Event channel {Channel} record id went backwards ({Stored} -> {Newest}); log was cleared, reseeding", + channel, current, newestAvailableRecordId); + watermarks.Remove(channel); + return true; + } + + // Best-effort persist. A failure here costs at most a repeated report of + // records already sent, so it must never abort a collection. + public void Save() + { + try + { + var directory = Path.GetDirectoryName(storePath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + // Write-then-rename so a crash mid-write cannot leave a truncated + // file that would reseed every channel on the next start. + var temporaryPath = storePath + ".tmp"; + File.WriteAllText(temporaryPath, + JsonSerializer.Serialize(watermarks)); + File.Move(temporaryPath, storePath, overwrite: true); + } + catch (Exception error) + { + logger.LogDebug(error, "Could not persist event watermarks to {Path}", storePath); + } + } + + private Dictionary Load() + { + try + { + if (!File.Exists(storePath)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var loaded = JsonSerializer.Deserialize>( + File.ReadAllText(storePath)); + return loaded is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(loaded, StringComparer.OrdinalIgnoreCase); + } + catch (Exception error) + { + // A corrupt store reseeds every channel, which costs one bounded + // tail per channel - acceptable versus failing the collection. + logger.LogWarning(error, + "Event watermark store unreadable at {Path}; reseeding channels", storePath); + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs index fa374105..7ff794ed 100644 --- a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs +++ b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs @@ -14,32 +14,74 @@ public sealed class FileLoggerProvider : ILoggerProvider private readonly string directory; private readonly LogLevel minLevel; private readonly int retentionDays; + private readonly string filePrefix; private readonly object gate = new(); private readonly ConcurrentDictionary loggers = new(); private string currentDateStamp = ""; private StreamWriter? writer; + // Why the sink can be unusable, or null when it opened normally. The + // constructor cannot throw (see below), but a caller that has a second, + // known-writable place to complain to needs to be able to ask. + public string? SinkProblem { get; private set; } + public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information, - int retentionDays = 14) + int retentionDays = 14, string filePrefix = "agent") { this.directory = directory; this.minLevel = minLevel; this.retentionDays = retentionDays; + this.filePrefix = filePrefix; // Best-effort: creating the log directory can fail on a locked-down host // or when a non-privileged user targets a SYSTEM-owned path. Never let // that abort construction; Write() degrades to a no-op if the sink is // unusable. + // + // The reason is KEPT rather than discarded. A silently dead sink is what + // made the user-context probe undiagnosable: running as the operator it + // could not write into the SYSTEM-owned ProgramData log directory, so a + // probe that failed and a probe that never ran produced identical + // silence. Callers that have a writable fallback can now surface this. try { Directory.CreateDirectory(directory); } - catch (Exception error) when (error is IOException or UnauthorizedAccessException) { } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + SinkProblem = $"{directory}: {error.Message}"; + } } // The default log directory when config leaves LogPath empty: // %ProgramData%\ShopDB\EndpointAgent\logs + // + // SERVICE-ONLY. This path is SYSTEM-owned and a signed-in operator cannot + // write to it, so anything running in the user's session must use + // UserProfileLogDirectory() instead. public static string DefaultDirectory() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "ShopDB", "EndpointAgent", "logs"); + // The shop-floor operator account. Every shop-floor endpoint uses this same + // local account name, so the path is fixed rather than resolved through the + // interactive token: the token lookup is the part that fails on a hardened + // endpoint, and when it fails the writer and the reader silently disagree + // about where the spool lives. If a site ever uses a different account only + // user-context is lost - telemetry, commands and inventory are unaffected. + public const string ShopFloorUserName = "ShopFloor"; + + public static string ShopFloorProfileDirectory() + => Path.Combine(DrivesRoot, "Users", ShopFloorUserName); + + private static string DrivesRoot => + Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.Windows)) + ?? @"C:\"; + + // Where the TRAY and the user-context probe log: + // C:\Users\ShopFloor\ShopDB\Logs + // The operator owns this directory, so it is writable from the user session + // where ProgramData is not. + public static string UserProfileLogDirectory() + => Path.Combine(ShopFloorProfileDirectory(), "ShopDB", "Logs"); + public ILogger CreateLogger(string categoryName) => loggers.GetOrAdd(categoryName, name => new FileLogger(this, name)); @@ -85,7 +127,14 @@ public sealed class FileLoggerProvider : ILoggerProvider writer?.Flush(); writer?.Dispose(); currentDateStamp = stamp; - var path = Path.Combine(directory, $"agent-{stamp}.log"); + // One convention for every log this codebase writes: + // "-.log", so agent, tray and installer logs sort + // chronologically when they share a directory and can be collected with + // a single date-leading glob. The service previously used the opposite + // order, which meant a support request for "the logs from the 5th" had to + // know which component wrote which shape. + var name = $"{stamp}-{filePrefix}.log"; + var path = Path.Combine(directory, name); writer = new StreamWriter(new FileStream( path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) { @@ -99,7 +148,12 @@ public sealed class FileLoggerProvider : ILoggerProvider try { var cutoff = DateTime.Now.AddDays(-retentionDays); - foreach (var file in Directory.EnumerateFiles(directory, "agent-*.log")) + // Both shapes: retention has to keep covering the "-" + // files written before the convention was unified, or an upgraded + // endpoint would keep its pre-upgrade logs forever. + string[] patterns = [$"*-{filePrefix}.log", $"{filePrefix}-*.log"]; + foreach (var pattern in patterns) + foreach (var file in Directory.EnumerateFiles(directory, pattern)) { if (File.GetLastWriteTime(file) < cutoff) File.Delete(file); diff --git a/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs b/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs new file mode 100644 index 00000000..10672c0e --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs @@ -0,0 +1,167 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Polls the fleet alert channel to fetch active notifications for this endpoint. +// The server may publish site-wide alerts (maintenance windows, policy changes) +// or endpoint-specific warnings (outdated agent version, failed telemetry push). +// The agent polls GET /api/endpoints/alerts?hostname=X every 60 seconds when idle +// and deserializes the JSON response into FleetAlert DTOs. Same auth as +// telemetry and commands (AgentHttpFactory). In 1.20 the service calls this and +// serves the tray over the local pipe (AgentPipeServer). +public sealed class FleetAlertClient : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient http; + private readonly ILogger logger; + + public FleetAlertClient(AgentHttpFactory httpFactory, ILogger logger) + { + this.logger = logger; + http = httpFactory.CreateClient(AgentHttpChannel.FleetAlert); + } + + // Tray compatibility until tray 1.7.0 moves to AgentPipeClient: shared key + // only, no credential. Remove with the last tray that calls it. + public FleetAlertClient(AgentConfig config, ILogger logger) + : this(new AgentHttpFactory(config, new SharedKeyOnlyProvider(config), logger), logger) + { + } + + private sealed class SharedKeyOnlyProvider : ICredentialProvider + { + public SharedKeyOnlyProvider(AgentConfig config) + => SharedKey = config.HasSharedKey() ? config.ApiKey : null; + public AgentCredential? Current => null; + public string? SharedKey { get; } + public event EventHandler? Changed { add { } remove { } } + public event EventHandler? EnrollmentNeeded { add { } remove { } } + public void ReportRejected(string code, string? sentCredential) { } + } + + public async Task> FetchAlertsAsync( + string hostname, CancellationToken token) + { + var url = "api/endpoints/alerts?hostname=" + + Uri.EscapeDataString(hostname); + using var response = await http.GetAsync(url, token); + + if (response.StatusCode == HttpStatusCode.NoContent) + return new List(); + + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + throw new CollectorException((int)response.StatusCode, body); + + var envelope = JsonSerializer.Deserialize( + body, JsonOptions); + if (envelope?.Data is FleetAlertList parsed) + foreach (var skipped in parsed.Skipped) + logger.LogWarning("Skipped malformed fleet alert: {Reason}", skipped); + return envelope?.Data ?? new List(); + } + + public async Task AcknowledgeAlertAsync( + int notificationId, int assetId, string hostname, CancellationToken token) + { + var url = $"api/endpoints/alerts/{notificationId}/acknowledge"; + var payload = new + { + assetid = assetId, + hostname + }; + var json = JsonSerializer.Serialize(payload, JsonOptions); + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + using var response = await http.PostAsync(url, content, token); + var body = await response.Content.ReadAsStringAsync(token); + + if (!response.IsSuccessStatusCode) + throw new CollectorException((int)response.StatusCode, body); + } + + public void Dispose() => http.Dispose(); +} + +// Server-sent strings may be JSON null; setters coerce to "" so consumers +// never see null. Timeout null or <= 0 means no timeout. +public sealed class FleetAlert +{ + private string typeName = ""; + private string typeColor = ""; + private string title = ""; + private string message = ""; + + [JsonPropertyName("notificationid")] public int NotificationId { get; set; } + [JsonPropertyName("type")] public string TypeName { get => typeName; set => typeName = value ?? ""; } + [JsonPropertyName("typecolor")] public string TypeColor { get => typeColor; set => typeColor = value ?? ""; } + [JsonPropertyName("title")] public string Title { get => title; set => title = value ?? ""; } + [JsonPropertyName("message")] public string Message { get => message; set => message = value ?? ""; } + [JsonPropertyName("timeout")] public int? Timeout { get; set; } + + [JsonIgnore] public int TimeoutMinutes => Timeout is > 0 ? Timeout.Value : 0; +} + +public sealed class FleetAlertEnvelope +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + + [JsonPropertyName("data")] + [JsonConverter(typeof(TolerantFleetAlertListConverter))] + public List? Data { get; set; } +} + +// List of alerts that parsed, plus a note per element that did not. The +// caller logs Skipped; one malformed alert must not drop the rest. +public sealed class FleetAlertList : List +{ + public List Skipped { get; } = new(); +} + +// Deserializes the data array one element at a time. An element that fails +// to bind, or has no usable notificationid, is recorded and skipped. +public sealed class TolerantFleetAlertListConverter : JsonConverter> +{ + public override List? Read( + ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException("Fleet alert data is not an array."); + + var list = new FleetAlertList(); + var index = 0; + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + using var element = JsonDocument.ParseValue(ref reader); + try + { + var alert = element.RootElement.Deserialize(options); + if (alert is null || alert.NotificationId <= 0) + list.Skipped.Add($"element {index}: missing notificationid"); + else + list.Add(alert); + } + catch (Exception exception) when (exception is JsonException or InvalidOperationException) + { + list.Skipped.Add($"element {index}: {exception.Message}"); + } + index++; + } + return list; + } + + public override void Write( + Utf8JsonWriter writer, List value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.ToArray(), options); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs b/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs new file mode 100644 index 00000000..33dcc7e9 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs @@ -0,0 +1,190 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Local JSON file store for tracking acknowledged fleet alerts. +// Stores notification IDs that have been acknowledged so they don't +// reappear in future polls. Lives under %LOCALAPPDATA% because the tray runs +// as the standard operator, who cannot write to %ProgramData%. Read-modify-write +// is serialized across processes by a named mutex. +public sealed class FleetAlertStore +{ + private const int MaxIds = 1000; + private const string MutexName = @"Local\ShopDB-FleetAlertStore"; + private static readonly TimeSpan MutexWait = TimeSpan.FromSeconds(5); + + private static readonly string DataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ShopDB", + "FleetAlerts"); + + private static readonly string StoreFile = Path.Combine(DataDir, "acknowledged-alerts.json"); + + // Pre-1.6.11 location. Standard users could not write it, but an elevated + // run may have left IDs there; merged once. + private static readonly string LegacyStoreFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "ShopDB", + "FleetAlerts", + "acknowledged-alerts.json"); + + private readonly object fileLock = new(); + private readonly ILogger logger; + private HashSet acknowledged = new(); + + public FleetAlertStore(ILogger logger) + { + this.logger = logger; + Load(); + } + + public bool IsAcknowledged(int notificationId) + { + lock (fileLock) + { + return acknowledged.Contains(notificationId); + } + } + + public void MarkAcknowledged(int notificationId) + { + lock (fileLock) + { + WithFileMutex(() => + { + // Merge what another instance may have written since our load. + var onDisk = ReadFile(StoreFile); + if (onDisk?.AcknowledgedIds is not null) + acknowledged.UnionWith(onDisk.AcknowledgedIds); + + acknowledged.Add(notificationId); + Prune(); + Save(onDisk?.MigratedLegacy ?? true); + }); + } + } + + private void Load() + { + lock (fileLock) + { + WithFileMutex(() => + { + var stored = ReadFile(StoreFile); + acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet(); + + if (stored?.MigratedLegacy == true) + return; + + var legacy = ReadFile(LegacyStoreFile); + var merged = legacy?.AcknowledgedIds?.Count(id => acknowledged.Add(id)) ?? 0; + if (merged > 0) + { + logger.LogInformation( + "Merged {Count} acknowledged alert ID(s) from legacy store {Path}.", + merged, LegacyStoreFile); + Prune(); + } + if (merged > 0 || stored is not null) + Save(migratedLegacy: true); + }); + } + } + + private StoredData? ReadFile(string path) + { + try + { + if (!File.Exists(path)) + return null; + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not read acknowledged alerts store {Path}.", path); + return null; + } + } + + private void Save(bool migratedLegacy) + { + try + { + Directory.CreateDirectory(DataDir); + + var data = new StoredData + { + AcknowledgedIds = acknowledged.ToList(), + LastUpdated = DateTime.UtcNow, + MigratedLegacy = migratedLegacy, + }; + + var json = JsonSerializer.Serialize(data, new JsonSerializerOptions + { + WriteIndented = true + }); + + var temp = StoreFile + ".tmp"; + File.WriteAllText(temp, json); + File.Move(temp, StoreFile, overwrite: true); + } + catch (Exception ex) + { + logger.LogError(ex, + "Could not save acknowledged alerts store {Path}; acknowledged alerts may reappear.", + StoreFile); + } + } + + // Runs action under the cross-process mutex. If the mutex cannot be had, + // logs and runs anyway: a rare clobber beats never recording an ack. + private void WithFileMutex(Action action) + { + Mutex? mutex = null; + var owned = false; + try + { + mutex = new Mutex(false, MutexName); + try + { + owned = mutex.WaitOne(MutexWait); + } + catch (AbandonedMutexException) + { + owned = true; + } + if (!owned) + logger.LogWarning("Timed out waiting for fleet alert store mutex; proceeding without it."); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not open fleet alert store mutex; proceeding without it."); + } + + try + { + action(); + } + finally + { + if (owned) + mutex!.ReleaseMutex(); + mutex?.Dispose(); + } + } + + private void Prune() + { + // Keep only newest IDs to prevent unbounded growth + if (acknowledged.Count > MaxIds) + acknowledged = acknowledged.OrderByDescending(id => id).Take(MaxIds).ToHashSet(); + } + + private sealed class StoredData + { + public List AcknowledgedIds { get; set; } = new(); + public DateTime LastUpdated { get; set; } + public bool MigratedLegacy { get; set; } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs index 326bdc07..ac66f4a5 100644 --- a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs +++ b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs @@ -1,5 +1,7 @@ using System.ComponentModel; using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; namespace ShopDbEndpointAgent.Core; @@ -18,7 +20,11 @@ namespace ShopDbEndpointAgent.Core; // Only process_start uses this. Stop/status/service actions stay as SYSTEM. public static class InteractiveProcessLauncher { - public readonly record struct LaunchOutcome(bool Launched, string? Error, int? ExitCode = null); + // TimedOut=true means the process launched fine but was still running when + // the caller's wait budget expired. It is deliberately NOT killed, so the + // work still completes; the caller just stops blocking on it. + public readonly record struct LaunchOutcome( + bool Launched, string? Error, int? ExitCode = null, bool TimedOut = false); private const int TokenDuplicate = 0x0002; private const int TokenQuery = 0x0008; @@ -34,6 +40,10 @@ public static class InteractiveProcessLauncher private const uint Infinite = 0xFFFFFFFF; + // WaitForSingleObject returns this when the budget expires with the handle + // still unsignalled. + private const uint WaitTimeout = 0x00000102; + // Returns Launched=true when CreateProcessAsUser succeeds. Never throws; // failures come back as Launched=false with a descriptive Error so the // caller can surface a clean CommandResult. @@ -51,9 +61,17 @@ public static class InteractiveProcessLauncher // needs the actual outcome (run_script in the user session) can report // completed/failed instead of fire-and-forget "launched". GUI callers // (process_start) leave it false and return as soon as the window is up. + // + // waitTimeout bounds that wait. This matters because the caller may be the + // telemetry loop, and telemetry IS the heartbeat: a script that hangs in the + // user session used to block here forever (the wait was hardcoded to + // Infinite), which would stall reporting and make a perfectly healthy PC + // read as offline in the WebUI. On expiry the child is left running and the + // outcome comes back with TimedOut=true. Null means wait forever and should + // only be used by a caller that is not on a timed path. public static LaunchOutcome LaunchInActiveSession( string applicationPath, string? arguments, string? workingDirectory, - bool waitForExit = false) + bool waitForExit = false, TimeSpan? waitTimeout = null) { var sessionId = WTSGetActiveConsoleSessionId(); if (sessionId == InvalidSessionId) @@ -129,11 +147,25 @@ public static class InteractiveProcessLauncher } int? exitCode = null; + bool timedOut = false; if (waitForExit && processInformation.hProcess != IntPtr.Zero) { - WaitForSingleObject(processInformation.hProcess, Infinite); - if (GetExitCodeProcess(processInformation.hProcess, out var code)) + uint milliseconds = waitTimeout is TimeSpan budget + ? (uint)Math.Clamp(budget.TotalMilliseconds, 0, uint.MaxValue - 1) + : Infinite; + + if (WaitForSingleObject(processInformation.hProcess, milliseconds) + == WaitTimeout) + { + // Still running. Leave it alone - killing it would abandon + // half-done work, and a file-spooling probe will still write + // its result for the next cycle to pick up. + timedOut = true; + } + else if (GetExitCodeProcess(processInformation.hProcess, out var code)) + { exitCode = unchecked((int)code); + } } if (processInformation.hProcess != IntPtr.Zero) @@ -141,7 +173,7 @@ public static class InteractiveProcessLauncher if (processInformation.hThread != IntPtr.Zero) CloseHandle(processInformation.hThread); - return new LaunchOutcome(true, null, exitCode); + return new LaunchOutcome(true, null, exitCode, timedOut); } finally { @@ -154,6 +186,93 @@ public static class InteractiveProcessLauncher } } + // Profile directory of the active console user, for example + // C:\Users\ShopFloor. The agent runs as LocalSystem, so expanding + // %USERPROFILE% here would return SYSTEM's own profile under + // config\systemprofile rather than the operator's. The interactive user's + // token is the only reliable source, and this class already knows how to + // obtain it. Returns null with a reason when nobody is signed in. + public static string? TryGetActiveUserProfileDirectory(out string? error) + { + var sessionId = WTSGetActiveConsoleSessionId(); + if (sessionId == InvalidSessionId) + { + error = "No active console session (no interactive user is signed in)."; + return null; + } + + if (!WTSQueryUserToken(sessionId, out var userToken)) + { + error = "Could not obtain the interactive user token: " + + new Win32Exception(Marshal.GetLastWin32Error()).Message; + return null; + } + + try + { + uint size = 0; + GetUserProfileDirectory(userToken, null, ref size); + if (size == 0) + { + error = "Could not size the user profile directory: " + + new Win32Exception(Marshal.GetLastWin32Error()).Message; + return null; + } + + var buffer = new StringBuilder((int)size); + if (!GetUserProfileDirectory(userToken, buffer, ref size)) + { + error = "Could not resolve the user profile directory: " + + new Win32Exception(Marshal.GetLastWin32Error()).Message; + return null; + } + + error = null; + return buffer.ToString(); + } + finally + { + if (userToken != IntPtr.Zero) + CloseHandle(userToken); + } + } + + // SID of the active console user, so SYSTEM can grant that one account + // access to a file it stages. Null with a reason when nobody is signed in. + public static SecurityIdentifier? TryGetActiveUserSid(out string? error) + { + var sessionId = WTSGetActiveConsoleSessionId(); + if (sessionId == InvalidSessionId) + { + error = "No active console session (no interactive user is signed in)."; + return null; + } + + if (!WTSQueryUserToken(sessionId, out var userToken)) + { + error = "Could not obtain the interactive user token (WTSQueryUserToken failed): " + + new Win32Exception(Marshal.GetLastWin32Error()).Message; + return null; + } + + try + { + using var identity = new WindowsIdentity(userToken); + error = identity.User is null ? "Interactive user token carries no SID." : null; + return identity.User; + } + catch (Exception exception) + { + error = "Could not read the interactive user SID: " + exception.Message; + return null; + } + finally + { + if (userToken != IntPtr.Zero) + CloseHandle(userToken); + } + } + private enum SecurityImpersonationLevel { SecurityAnonymous, @@ -222,6 +341,10 @@ public static class InteractiveProcessLauncher [DllImport("userenv.dll", SetLastError = true)] private static extern bool DestroyEnvironmentBlock(IntPtr environment); + [DllImport("userenv.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool GetUserProfileDirectory( + IntPtr token, StringBuilder? profileDirectory, ref uint size); + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool CreateProcessAsUser( IntPtr token, diff --git a/agent/ShopDbEndpointAgent.Core/ProtectedFileSystem.cs b/agent/ShopDbEndpointAgent.Core/ProtectedFileSystem.cs new file mode 100644 index 00000000..af63ff07 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/ProtectedFileSystem.cs @@ -0,0 +1,285 @@ +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace ShopDbEndpointAgent.Core; + +// File access for paths the agent touches AS SYSTEM where a standard user could +// otherwise interfere. +// +// %ProgramData% lets any user create files and folders beneath it, so a staging +// path the agent does not lock down can be pre-created, swapped between the hash +// check and execution, or replaced with a junction that redirects a SYSTEM read +// or delete to an arbitrary file. Directories made here carry a protected DACL: +// SYSTEM and BUILTIN\Administrators only, inheritance off. SIDs are well-known +// strings so a localized OS resolves them the same way. +internal static class ProtectedFileSystem +{ + private static readonly SecurityIdentifier LocalSystemSid = new("S-1-5-18"); + private static readonly SecurityIdentifier AdministratorsSid = new("S-1-5-32-544"); + + private const uint GenericRead = 0x80000000; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint FileShareDelete = 0x00000004; + private const uint OpenExisting = 3; + private const uint FileFlagOpenReparsePoint = 0x00200000; + + // %ProgramData%\ShopDB\EndpointAgent. The Defender-excluded agent root. + public static string AgentDataRoot => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "ShopDB", "EndpointAgent"); + + // Create or re-secure \. readerSid, when set, gets + // list/traverse on the directory itself (not inherited) so a process in that + // user's session can run from it and open files it was granted explicitly. + // Throws when the directory cannot be proven safe; callers fail closed. + public static string EnsureDirectory(string name, SecurityIdentifier? readerSid = null) + { + var root = AgentDataRoot; + Directory.CreateDirectory(root); + RefuseReparseChain(root); + + var path = Path.Combine(root, name); + var attributes = TryGetAttributes(path); + if (attributes is FileAttributes existing + && (existing.HasFlag(FileAttributes.ReparsePoint) + || !existing.HasFlag(FileAttributes.Directory))) + { + // Link or planted file at our name. Removing a link never touches + // its target. + if (existing.HasFlag(FileAttributes.Directory)) + Directory.Delete(path); + else + File.Delete(path); + attributes = null; + } + + if (attributes is null) + new DirectoryInfo(path).Create(DirectorySecurityFor(readerSid)); + + var info = new DirectoryInfo(path); + if (!IsTrustedOwner(info)) + { + // Pre-created by someone else. The owner can always rewrite the + // DACL, so re-securing is not enough: replace the directory. + Directory.Delete(path, recursive: true); + new DirectoryInfo(path).Create(DirectorySecurityFor(readerSid)); + info = new DirectoryInfo(path); + if (!IsTrustedOwner(info)) + throw new IOException($"Staging directory {path} has an untrusted owner"); + } + + if (IsReparsePoint(path)) + throw new IOException($"Staging directory {path} is a reparse point"); + + info.SetAccessControl(DirectorySecurityFor(readerSid)); + return path; + } + + // New file inside a protected directory. Any existing entry at the name is + // removed first, then CreateNew so nothing can be slipped in between. The + // DACL is protected; userSid, when set, is granted userRights explicitly. + public static FileStream CreateFile( + string path, SecurityIdentifier? userSid = null, + FileSystemRights userRights = FileSystemRights.ReadAndExecute) + { + if (TryGetAttributes(path) is not null) + File.Delete(path); + + var security = new FileSecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + LocalSystemSid, FileSystemRights.FullControl, AccessControlType.Allow)); + security.AddAccessRule(new FileSystemAccessRule( + AdministratorsSid, FileSystemRights.FullControl, AccessControlType.Allow)); + if (userSid is not null) + security.AddAccessRule(new FileSystemAccessRule( + userSid, userRights, AccessControlType.Allow)); + + return new FileInfo(path).Create( + FileMode.CreateNew, FileSystemRights.Read | FileSystemRights.Write, + FileShare.Read, 4096, FileOptions.None, security); + } + + // Attributes of the entry itself (a link reports its own, not its target's). + public static FileAttributes? TryGetAttributes(string path) + { + try + { + return File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } + + public static bool IsReparsePoint(string path) + => TryGetAttributes(path) is FileAttributes attributes + && attributes.HasFlag(FileAttributes.ReparsePoint); + + // Open for reading without following a reparse point at the final + // component, and refuse if the opened object is one. Share mode is wide so + // files a running application holds open still read. + public static FileStream OpenReadNoFollow(string path) + { + var handle = CreateFileW(path, GenericRead, + FileShareRead | FileShareWrite | FileShareDelete, IntPtr.Zero, + OpenExisting, FileFlagOpenReparsePoint, IntPtr.Zero); + if (handle.IsInvalid) + { + var hr = Marshal.GetHRForLastWin32Error(); + handle.Dispose(); + throw Marshal.GetExceptionForHR(hr) ?? new IOException($"Could not open {path}"); + } + + try + { + if (!GetFileInformationByHandle(handle, out var information)) + throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error()) + ?? new IOException($"Could not query {path}"); + + var attributes = (FileAttributes)information.FileAttributes; + if (attributes.HasFlag(FileAttributes.ReparsePoint)) + throw new UnauthorizedAccessException($"{path} is a reparse point"); + if (attributes.HasFlag(FileAttributes.Directory)) + throw new UnauthorizedAccessException($"{path} is a directory"); + + return new FileStream(handle, FileAccess.Read); + } + catch + { + handle.Dispose(); + throw; + } + } + + // The path the handle really refers to after every junction and link in + // its ancestry is resolved. Null when it cannot be read. + public static string? FinalPath(SafeFileHandle handle) + { + var buffer = new StringBuilder(1024); + var length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0); + if (length == 0) + return null; + if (length >= buffer.Capacity) + { + buffer = new StringBuilder((int)length + 1); + length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0); + if (length == 0 || length >= buffer.Capacity) + return null; + } + + var final = buffer.ToString(); + if (final.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) + return @"\\" + final[8..]; + if (final.StartsWith(@"\\?\", StringComparison.Ordinal)) + return final[4..]; + return final; + } + + // Read at most maxBytes of text from the head of a file, BOM-aware. + public static string ReadTextHeadNoFollow(string path, int maxBytes) + { + using var stream = OpenReadNoFollow(path); + var buffer = new byte[(int)Math.Min(maxBytes, Math.Max(stream.Length, 0))]; + var total = 0; + int read; + while (total < buffer.Length + && (read = stream.Read(buffer, total, buffer.Length - total)) > 0) + total += read; + + using var reader = new StreamReader( + new MemoryStream(buffer, 0, total), Encoding.UTF8, true); + return reader.ReadToEnd(); + } + + // Delete the entry itself. DeleteFile removes a link, never its target; + // a directory at a file path is left alone. + public static void DeleteFileNoFollow(string path) + { + if (TryGetAttributes(path) is not FileAttributes attributes) + return; + if (attributes.HasFlag(FileAttributes.Directory)) + throw new IOException($"{path} is a directory, not a file"); + File.Delete(path); + } + + private static DirectorySecurity DirectorySecurityFor(SecurityIdentifier? readerSid) + { + var security = new DirectorySecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + const InheritanceFlags inherit = + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit; + security.AddAccessRule(new FileSystemAccessRule(LocalSystemSid, + FileSystemRights.FullControl, inherit, PropagationFlags.None, + AccessControlType.Allow)); + security.AddAccessRule(new FileSystemAccessRule(AdministratorsSid, + FileSystemRights.FullControl, inherit, PropagationFlags.None, + AccessControlType.Allow)); + if (readerSid is not null) + security.AddAccessRule(new FileSystemAccessRule(readerSid, + FileSystemRights.ReadAndExecute, InheritanceFlags.None, + PropagationFlags.None, AccessControlType.Allow)); + return security; + } + + private static bool IsTrustedOwner(DirectoryInfo info) + { + var owner = info.GetAccessControl(AccessControlSections.Owner) + .GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier; + return owner is not null + && (owner.Equals(LocalSystemSid) || owner.Equals(AdministratorsSid)); + } + + // A junction anywhere from ProgramData down to the agent root would move + // every "protected" directory somewhere else. + private static void RefuseReparseChain(string root) + { + var programData = Environment.GetFolderPath( + Environment.SpecialFolder.CommonApplicationData).TrimEnd('\\'); + var current = Path.GetFullPath(root).TrimEnd('\\'); + while (current.Length > programData.Length + && current.StartsWith(programData, StringComparison.OrdinalIgnoreCase)) + { + if (IsReparsePoint(current)) + throw new IOException($"{current} is a reparse point; refusing to stage under it"); + current = Path.GetDirectoryName(current) ?? ""; + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern SafeFileHandle CreateFileW( + string fileName, uint desiredAccess, uint shareMode, IntPtr securityAttributes, + uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle file, out ByHandleFileInformation information); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern uint GetFinalPathNameByHandleW( + SafeFileHandle file, StringBuilder filePath, uint filePathLength, uint flags); +} diff --git a/agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs b/agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs new file mode 100644 index 00000000..0a328267 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs @@ -0,0 +1,159 @@ +namespace ShopDbEndpointAgent.Core; + +// Guards which registered scheduled tasks the scheduledtask_run action may +// start. The wire carries a free-text task path (so the WebUI can expose it as +// one field, and a site can widen the set without an agent rebuild); this +// policy is the safeguard that makes that free text safe to honor. +// +// WHY THIS EXISTS AT ALL. The agent runs as SYSTEM. A scheduled task is an +// INDIRECTION: the task path names a definition, and that definition chooses +// what actually runs. So "start the task named X" is only as safe as the set of +// X the agent will accept - an unconstrained task name would be a general- +// purpose "run anything already registered on this box, as whatever context it +// asked for" primitive. That is a materially bigger grant than any other +// allowlisted action, and it would be invisible in the audit trail (the command +// log would read "scheduledtask_run \Foo" no matter what \Foo does). +// +// WHAT THIS DOES AND DOES NOT BUY. A prefix allowlist stops a compromised or +// misconfigured SERVER from starting arbitrary tasks on the fleet - that is the +// threat it actually addresses, and it is the realistic one. It does NOT stop an +// attacker who can already register or rewrite task definitions on the endpoint: +// such an attacker can define a task under an approved prefix and have the agent +// start it. That is accepted, because anyone who can write task definitions on a +// box already has the privilege the task would grant them. Local task definition +// ACLs, not this policy, are the control for that. +// +// The approved-prefix list ships with the Corporate Tools set and is overridable +// per site via AgentConfig.ApprovedTaskPaths, matching the LaunchPathPolicy +// pattern so the future Site Agent Configuration tool can narrow or widen it +// without a rebuild. +public sealed class ScheduledTaskPolicy +{ + // Shipped defaults: the Corporate Tools task set. Prefix entries end with a + // backslash and match a whole folder; entries without one match that exact + // task path only. Kept deliberately tight - widen per site, not here. + // + // The ShopDB-* entries are tasks ShopDB itself owns. They are separate task + // definitions from any corporate-initiated equivalent ON PURPOSE: a + // ShopDB-initiated Dell Command scan must write its results somewhere the + // agent's collector can attribute to ShopDB, rather than into whatever + // location a corporate-managed task already writes. Keeping the definitions + // distinct is what makes "who asked for this scan" answerable at all - see + // the note in commands.py about the DCU output path convention. + public static IReadOnlyList DefaultApprovedTaskPaths { get; } = new[] + { + @"\ReportIP", + @"\SFLD-ApplyDSCConfig", + @"\SFLD - Consume Credentials", + @"\ShopDB-DellCommandScan", + @"\ShopDB-DellCommandUpdate", + }; + + private readonly IReadOnlyList approvedPaths; + + public ScheduledTaskPolicy(IEnumerable? approvedPathsOverride = null) + { + var paths = (approvedPathsOverride ?? Enumerable.Empty()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizeTaskPath) + .ToList(); + approvedPaths = paths.Count > 0 ? paths : DefaultApprovedTaskPaths; + } + + // Validates a candidate task path. On success taskPath is the normalized + // path to hand to Task Scheduler; on failure reason explains the rejection + // so the command result tells the operator WHY rather than just "denied". + public bool TryValidate(string? candidate, out string taskPath, out string reason) + { + taskPath = ""; + reason = ""; + + if (string.IsNullOrWhiteSpace(candidate)) + { + reason = "empty task path"; + return false; + } + + var normalized = NormalizeTaskPath(candidate); + + // Traversal in a task path is meaningless to Task Scheduler but very + // meaningful to a prefix check: "\Approved\..\Evil" starts with an + // approved prefix as a STRING while naming something else entirely. + // Reject rather than try to canonicalize it away. + if (normalized.Contains("..")) + { + reason = "task path contains a traversal segment"; + return false; + } + + // Control characters and quotes have no place in a task path and are the + // shape of an attempt to break out of the argument. The invocation uses + // ArgumentList (no shell, no string concatenation) so this is defense in + // depth rather than the primary control, but a task path that needs + // these is not a task path we should be starting. + foreach (var ch in normalized) + { + if (char.IsControl(ch) || ch == '"' || ch == '&' || ch == '|') + { + reason = "task path contains an illegal character"; + return false; + } + } + + if (!IsApproved(normalized)) + { + reason = "task path is not in the approved task list"; + return false; + } + + taskPath = normalized; + return true; + } + + // Task Scheduler paths are rooted and backslash-separated. Accept the + // operator writing "ReportIP", "\ReportIP", or "/ReportIP" and settle on the + // rooted form, so an allowlist entry does not hinge on how it was typed. + private static string NormalizeTaskPath(string path) + { + var trimmed = (path ?? "").Trim().Replace('/', '\\'); + if (trimmed.Length == 0) + return ""; + if (!trimmed.StartsWith('\\')) + trimmed = "\\" + trimmed; + // Collapse a trailing separator so "\Folder\" and "\Folder" compare + // equal as exact entries; prefix semantics are handled in IsApproved. + while (trimmed.Length > 1 && trimmed.EndsWith('\\')) + trimmed = trimmed[..^1]; + return trimmed; + } + + // An entry ending in a separator is a FOLDER prefix and matches anything + // beneath it; any other entry must match the whole task path exactly. The + // exact-match case is why "\Approved" does not silently authorize + // "\ApprovedEvil". + private bool IsApproved(string candidate) + { + foreach (var entry in approvedPaths) + { + if (entry.Length == 0) + continue; + + var isFolderPrefix = entry.EndsWith('\\'); + if (isFolderPrefix) + { + if (candidate.StartsWith(entry, StringComparison.OrdinalIgnoreCase)) + return true; + continue; + } + + if (string.Equals(candidate, entry, StringComparison.OrdinalIgnoreCase)) + return true; + + // Allow the folder form without its trailing separator to still act + // as a folder: "\SFLD" approves "\SFLD\Thing" but never "\SFLDEvil". + if (candidate.StartsWith(entry + "\\", StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } +} diff --git a/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj index 013080b0..a3b2e32a 100644 --- a/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj +++ b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj @@ -15,8 +15,17 @@ + + + + + + diff --git a/agent/ShopDbEndpointAgent.Core/StartupJitter.cs b/agent/ShopDbEndpointAgent.Core/StartupJitter.cs new file mode 100644 index 00000000..14e4157a --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/StartupJitter.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// One-time startup delay, shared by TelemetryWorker and CommandWorker. +// +// Both loops otherwise make their first request the instant the service starts, +// and the service is installed start=auto - so a patch-day reboot has an entire +// fleet arriving together, twice per machine (a telemetry report AND a command +// poll). AgentConfig.JitterSeconds does not help: it spreads the gap BETWEEN +// runs, not the first contact. +// +// Lives here rather than in either worker so the two cannot drift apart, and so +// the clamp is enforced in one place. +internal static class StartupJitter +{ + // Ceiling on the configured value. A fat-fingered appsettings.json must not + // be able to park an endpoint for hours before it first checks in: the + // command channel is how an operator would correct it, so an unbounded + // delay would take away the means of recovery. + private const int MaxStartupJitterSeconds = 300; + + public static TimeSpan Next(int configuredSeconds, ILogger logger) + { + // Warn rather than clamp silently: an operator who deliberately set a + // large value and got a smaller one has config and behaviour disagreeing + // with no way to tell why. A negative value is a mistake too, so it is + // worth the same line. + if (configuredSeconds > MaxStartupJitterSeconds) + { + logger.LogWarning( + "startupjitterseconds {Configured} exceeds the {Max}s ceiling; using {Max}s", + configuredSeconds, MaxStartupJitterSeconds, MaxStartupJitterSeconds); + } + else if (configuredSeconds < 0) + { + logger.LogWarning( + "startupjitterseconds {Configured} is negative; startup jitter disabled", + configuredSeconds); + } + + int spread = Math.Clamp(configuredSeconds, 0, MaxStartupJitterSeconds); + if (spread == 0) + return TimeSpan.Zero; + + // Milliseconds, not whole seconds: 700 endpoints over a 30s window would + // otherwise land in 30 clumps of ~23 simultaneous arrivals, which is the + // same pile-up in miniature. + return TimeSpan.FromMilliseconds(Random.Shared.Next(0, spread * 1000)); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/StrictModePreamble.cs b/agent/ShopDbEndpointAgent.Core/StrictModePreamble.cs new file mode 100644 index 00000000..a37db6c5 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/StrictModePreamble.cs @@ -0,0 +1,293 @@ +using System.Text; + +namespace ShopDbEndpointAgent.Core; + +// Inserts the strict error-mode statement into a PowerShell script body. +// +// The statement cannot simply go first: using statements, [CmdletBinding()] and +// param(...) must precede every other statement or the script stops parsing. +// So the scanner walks the leading header (whitespace, # and <# #> comments +// including #Requires and comment help, using lines, attributes, the param +// block) and inserts on the SAME line as the first real statement, followed by +// '; ', so every line number PowerShell reports still matches the stored body. +// Anything it cannot read with confidence (named begin/process/end blocks, +// subexpressions or here-strings inside the header, unbalanced brackets, bytes +// that are not valid text) returns null and the caller skips injection. +internal static class StrictModePreamble +{ + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + private static readonly UTF8Encoding Utf8WithBom = new(true); + + private static readonly string[] NamedBlocks = + { "begin", "process", "end", "dynamicparam", "clean" }; + + // Returns the rewritten body as UTF-8 WITH BOM (Windows PowerShell 5.1 + // reads a BOM-less file as the ANSI code page), or null with a reason. + public static byte[]? TryApply(byte[] body, string statement, out string? reason) + { + if (!TryDecode(body, out var text)) + { + reason = "script is not valid UTF-8 or UTF-16 text"; + return null; + } + + var index = FindInsertionPoint(text, out reason); + if (index < 0) + return null; + + var rewritten = text.Insert(index, statement + "; "); + var preamble = Utf8WithBom.GetPreamble(); + var encoded = Utf8WithBom.GetBytes(rewritten); + var result = new byte[preamble.Length + encoded.Length]; + preamble.CopyTo(result, 0); + encoded.CopyTo(result, preamble.Length); + return result; + } + + private static bool TryDecode(byte[] body, out string text) + { + text = ""; + try + { + if (body.Length >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF) + text = StrictUtf8.GetString(body, 3, body.Length - 3); + else if (body.Length >= 2 && body[0] == 0xFF && body[1] == 0xFE) + text = new UnicodeEncoding(false, false, true).GetString(body, 2, body.Length - 2); + else if (body.Length >= 2 && body[0] == 0xFE && body[1] == 0xFF) + text = new UnicodeEncoding(true, false, true).GetString(body, 2, body.Length - 2); + else + text = StrictUtf8.GetString(body); + return true; + } + catch (DecoderFallbackException) + { + return false; + } + } + + // Index of the first statement after the script header, or -1. + internal static int FindInsertionPoint(string text, out string? reason) + { + reason = null; + var index = SkipTrivia(text, 0); + while (index >= 0 && MatchWord(text, index, "using")) + { + var lineEnd = text.IndexOf('\n', index); + index = SkipTrivia(text, lineEnd < 0 ? text.Length : lineEnd); + } + if (index < 0) + return Uncertain("unterminated block comment in header", out reason); + + var cursor = index; + while (cursor < text.Length && text[cursor] == '[') + { + cursor = SkipBalanced(text, cursor); + if (cursor < 0) + return Uncertain("could not read attribute in header", out reason); + cursor = SkipTrivia(text, cursor); + if (cursor < 0) + return Uncertain("unterminated block comment in header", out reason); + } + + if (MatchWord(text, cursor, "param")) + { + var open = SkipTrivia(text, cursor + "param".Length); + if (open < 0 || open >= text.Length || text[open] != '(') + return Uncertain("could not read param block", out reason); + var close = SkipBalanced(text, open); + if (close < 0) + return Uncertain("could not read param block", out reason); + index = SkipTrivia(text, close); + if (index < 0) + return Uncertain("unterminated block comment after param block", out reason); + } + // Brackets not followed by param are a type literal starting the first + // statement, so index stays in front of them. + + if (index >= text.Length) + return Uncertain("no statements after the script header", out reason); + + foreach (var block in NamedBlocks) + { + if (MatchWord(text, index, block)) + return Uncertain($"script uses a named '{block}' block", out reason); + } + + return index; + } + + private static int Uncertain(string why, out string? reason) + { + reason = why; + return -1; + } + + // Skip whitespace, line comments and block comments. -1 on an unterminated + // block comment. + private static int SkipTrivia(string text, int index) + { + while (index < text.Length) + { + var character = text[index]; + if (char.IsWhiteSpace(character) || character == '\uFEFF') + { + index++; + } + else if (character == '<' && index + 1 < text.Length && text[index + 1] == '#') + { + var end = text.IndexOf("#>", index + 2, StringComparison.Ordinal); + if (end < 0) + return -1; + index = end + 2; + } + else if (character == '#') + { + var lineEnd = text.IndexOf('\n', index); + index = lineEnd < 0 ? text.Length : lineEnd + 1; + } + else + { + break; + } + } + return index; + } + + private static bool MatchWord(string text, int index, string word) + { + if (index < 0 || index + word.Length > text.Length) + return false; + if (string.Compare(text, index, word, 0, word.Length, StringComparison.OrdinalIgnoreCase) != 0) + return false; + var after = index + word.Length; + if (after == text.Length) + return true; + var next = text[after]; + return !(char.IsLetterOrDigit(next) || next == '_' || next == '-'); + } + + private static bool IsSingleQuote(char character) + => character is '\'' or '\u2018' or '\u2019' or '\u201A' or '\u201B'; + + private static bool IsDoubleQuote(char character) + => character is '"' or '\u201C' or '\u201D' or '\u201E'; + + // From an opening ( [ or { return the index just past its matching closer, + // honoring quotes and comments. -1 when unsure. + private static int SkipBalanced(string text, int index) + { + var closers = new Stack(); + while (index < text.Length) + { + var character = text[index]; + switch (character) + { + case '(': + closers.Push(')'); + index++; + break; + case '[': + closers.Push(']'); + index++; + break; + case '{': + closers.Push('}'); + index++; + break; + case ')': + case ']': + case '}': + if (closers.Count == 0 || closers.Pop() != character) + return -1; + index++; + if (closers.Count == 0) + return index; + break; + case '`': + index += 2; + break; + case '@' when index + 1 < text.Length + && (IsSingleQuote(text[index + 1]) || IsDoubleQuote(text[index + 1])): + // Here-string; not worth reading in a header. + return -1; + case '<' when index + 1 < text.Length && text[index + 1] == '#': + var end = text.IndexOf("#>", index + 2, StringComparison.Ordinal); + if (end < 0) + return -1; + index = end + 2; + break; + case '#': + if (index > 0 && (char.IsLetterOrDigit(text[index - 1]) || text[index - 1] == '_')) + return -1; + var lineEnd = text.IndexOf('\n', index); + if (lineEnd < 0) + return -1; + index = lineEnd + 1; + break; + default: + if (IsSingleQuote(character)) + { + index = SkipSingleQuoted(text, index + 1); + if (index < 0) + return -1; + } + else if (IsDoubleQuote(character)) + { + index = SkipDoubleQuoted(text, index + 1); + if (index < 0) + return -1; + } + else + { + index++; + } + break; + } + } + return -1; + } + + private static int SkipSingleQuoted(string text, int index) + { + while (index < text.Length) + { + if (IsSingleQuote(text[index])) + { + if (index + 1 < text.Length && IsSingleQuote(text[index + 1])) + { + index += 2; + continue; + } + return index + 1; + } + index++; + } + return -1; + } + + private static int SkipDoubleQuoted(string text, int index) + { + while (index < text.Length) + { + var character = text[index]; + if (character == '`') + { + index += 2; + continue; + } + if (character == '$' && index + 1 < text.Length && text[index + 1] == '(') + return -1; + if (IsDoubleQuote(character)) + { + if (index + 1 < text.Length && IsDoubleQuote(text[index + 1])) + { + index += 2; + continue; + } + return index + 1; + } + index++; + } + return -1; + } +} diff --git a/agent/ShopDbEndpointAgent.Core/TcpTableReader.cs b/agent/ShopDbEndpointAgent.Core/TcpTableReader.cs new file mode 100644 index 00000000..26af6257 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/TcpTableReader.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Runtime.InteropServices; + +namespace ShopDbEndpointAgent.Core; + +// TCP connection state with owning process ids, via GetExtendedTcpTable. +// +// Why P/Invoke instead of shelling out to netstat.exe: parsing netstat text +// output means spawning a process on every telemetry tick and depending on the +// column layout of a localized command. GetExtendedTcpTable is the API netstat +// itself calls, returns process ids directly, and costs a single allocation. +// +// Listeners are the cheap, bounded half of this (a handful of rows, stable over +// time) and ride the normal cadence. The full connection table is unbounded and +// churns constantly, so it stays on-demand. +public static class TcpTableReader +{ + private const int AF_INET = 2; + private const uint ERROR_INSUFFICIENT_BUFFER = 122; + private const int TCP_TABLE_OWNER_PID_ALL = 5; + + public enum TcpState + { + Closed = 1, + Listen = 2, + SynSent = 3, + SynReceived = 4, + Established = 5, + FinWait1 = 6, + FinWait2 = 7, + CloseWait = 8, + Closing = 9, + LastAck = 10, + TimeWait = 11, + DeleteTcb = 12, + } + + public readonly record struct TcpRow( + string LocalAddress, + int LocalPort, + string RemoteAddress, + int RemotePort, + TcpState State, + int ProcessId); + + // Full IPv4 TCP table. Throws only on catastrophic interop failure; callers + // treat an empty list as "not observed". + public static List GetTcpTable() + { + var rows = new List(); + var buffer = IntPtr.Zero; + var size = 0; + + try + { + // First call sizes the buffer. It is expected to fail with + // INSUFFICIENT_BUFFER; anything else is a real error. + var result = GetExtendedTcpTable( + IntPtr.Zero, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0); + if (result != ERROR_INSUFFICIENT_BUFFER && result != 0) + return rows; + + buffer = Marshal.AllocHGlobal(size); + result = GetExtendedTcpTable( + buffer, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0); + if (result != 0) + return rows; + + var entryCount = Marshal.ReadInt32(buffer); + var rowSize = Marshal.SizeOf(); + var cursor = IntPtr.Add(buffer, 4); + + for (var index = 0; index < entryCount; index++) + { + var row = Marshal.PtrToStructure(cursor); + rows.Add(new TcpRow( + FormatAddress(row.localAddress), + FormatPort(row.localPort), + FormatAddress(row.remoteAddress), + FormatPort(row.remotePort), + (TcpState)row.state, + (int)row.owningProcessId)); + cursor = IntPtr.Add(cursor, rowSize); + } + } + finally + { + if (buffer != IntPtr.Zero) + Marshal.FreeHGlobal(buffer); + } + + return rows; + } + + public static List GetListeners() + => GetTcpTable().FindAll(row => row.State == TcpState.Listen); + + private static string FormatAddress(uint address) + => new IPAddress(BitConverter.GetBytes(address)).ToString(); + + // The port arrives as a big-endian value packed into the low two bytes of a + // host-order uint, so the bytes must be swapped rather than simply cast. + private static int FormatPort(uint port) + { + var bytes = BitConverter.GetBytes(port); + return (bytes[0] << 8) | bytes[1]; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MIB_TCPROW_OWNER_PID + { + public uint state; + public uint localAddress; + public uint localPort; + public uint remoteAddress; + public uint remotePort; + public uint owningProcessId; + } + + [DllImport("iphlpapi.dll", SetLastError = true)] + private static extern uint GetExtendedTcpTable( + IntPtr tcpTable, + ref int size, + bool order, + int addressFamily, + int tableClass, + int reserved); +} diff --git a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs index 2efa847d..04f4cb20 100644 --- a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs +++ b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs @@ -15,6 +15,11 @@ namespace ShopDbEndpointAgent.Core; // cadence tick rather than hammering the server. public sealed class TelemetryWorker : BackgroundService { + // The one agent utility whose output is forwarded to the server today. The + // name is the .ps1 filename without its extension, which is how + // PowerShellUtilityRunner keys its results. + public const string DellUpdatesUtilityName = "shopdb-dellupdates"; + private readonly ICollector collector; private readonly CollectorClient client; private readonly IAgentUtilityRunner? utilities; @@ -22,12 +27,38 @@ public sealed class TelemetryWorker : BackgroundService private readonly string agentVersion; private readonly ILogger logger; private readonly CollectSignal? collectSignal; + private readonly UserContextSpool? userContextSpool; + private readonly UserContextRefreshChannel? userContextRefresh; + private readonly EnrollmentService? enrollment; private readonly Random jitter = new(); + // Upper bound on holding first contact for the enrollment attempt. + private static readonly TimeSpan EnrollmentWaitLimit = TimeSpan.FromSeconds(20); + + // How long to wait for the tray to answer a refresh request. + // + // Bounded, and bounded SHORT. Telemetry is this agent's liveness signal: a + // report that arrives with slightly stale mapped drives is a working + // endpoint, while a report that never arrives because we blocked on an + // absent tray is an endpoint that looks dead. The probe itself is a single + // short PowerShell run, so a tray that is alive answers well inside this; + // anything longer means nobody is home. + private static readonly TimeSpan UserContextRefreshTimeout = TimeSpan.FromSeconds(12); + + // Poll interval while waiting for the stamp to advance. There is no + // completion signal coming back the other way - the tray's answer IS the + // rewritten spool file - so this is a poll by design, kept coarse enough to + // be free and fine enough not to add visible latency. + private static readonly TimeSpan UserContextPollInterval = TimeSpan.FromMilliseconds(400); + public TelemetryWorker(ICollector collector, CollectorClient client, AgentConfig config, string agentVersion, ILogger logger, - IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null) + IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null, + UserContextSpool? userContextSpool = null, + UserContextRefreshChannel? userContextRefresh = null, + EnrollmentService? enrollment = null) { + this.enrollment = enrollment; this.collector = collector; this.client = client; this.config = config; @@ -35,6 +66,77 @@ public sealed class TelemetryWorker : BackgroundService this.logger = logger; this.utilities = utilities; this.collectSignal = collectSignal; + this.userContextSpool = userContextSpool; + this.userContextRefresh = userContextRefresh; + } + + // Request a tray-side re-probe and wait, briefly, for the spool to be + // rewritten. Never throws and never fails the run. + // + // The service does NOT collect session data itself and must not start doing + // so: mapped drives belong to the operator's logon token, and a LocalSystem + // probe reads a different, near-empty drive table. See the tombstone in + // Program.cs. This method is the entire service-side involvement - ask, then + // wait for the answer to appear on disk. + private async Task RefreshUserContextAsync(CancellationToken token) + { + if (userContextSpool is null || userContextRefresh is null + || !OperatingSystem.IsWindows()) + { + return; + } + + var resultPath = userContextSpool.TryGetResultPath(out _); + if (resultPath is null) + return; + + // Captured BEFORE the request so "advanced" is measured against what we + // would otherwise have reported. A missing spool reads as null, and any + // stamp at all then counts as progress - that is the first-probe case. + var before = userContextSpool.TryReadCollectedAt(resultPath); + + if (!userContextRefresh.RequestRefresh()) + { + // Could not signal at all. Note that the inverse does NOT mean a tray + // is listening: the channel opens-or-creates and does not hold the + // handle, so signalling into an empty machine succeeds by creating + // the event and finding nobody waiting on it. Detecting an absent + // tray is the timeout's job below, not this return value's. + logger.LogDebug( + "Could not request a user-context refresh; reporting the existing spool."); + return; + } + + var deadline = DateTime.UtcNow + UserContextRefreshTimeout; + while (DateTime.UtcNow < deadline) + { + try + { + await Task.Delay(UserContextPollInterval, token); + } + catch (OperationCanceledException) + { + return; + } + + var after = userContextSpool.TryReadCollectedAt(resultPath); + if (after is not null && (before is null || after > before)) + { + logger.LogDebug( + "User-context spool refreshed by the tray (collectedat={Stamp}).", + after); + return; + } + } + + // Timed out. Proceed anyway - deliberately. Session data is one card on + // one page; telemetry carries heartbeat, inventory, events and command + // results, and holding all of that hostage to a tray that did not answer + // in twelve seconds trades a small inaccuracy for a total outage. + logger.LogDebug( + "The tray did not refresh the user-context spool within {Timeout}; " + + "reporting with the existing session data.", + UserContextRefreshTimeout); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -43,6 +145,37 @@ public sealed class TelemetryWorker : BackgroundService "Endpoint agent {Version} -> {BaseUrl}, cadence {Cadence}s +/-{Jitter}s", agentVersion, config.BaseUrl, config.CadenceSeconds, config.JitterSeconds); + // Spread first contact across the fleet. JitterSeconds only spreads the + // gap BETWEEN runs, so without this the whole fleet reports the instant + // its service starts. See StartupJitter. + var startupDelay = StartupJitter.Next(config.StartupJitterSeconds, logger); + if (startupDelay > TimeSpan.Zero) + { + logger.LogInformation( + "Delaying first telemetry report by {Delay}ms (startup jitter)", + (int)startupDelay.TotalMilliseconds); + try + { + await Task.Delay(startupDelay, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + + if (enrollment is not null) + { + try + { + await enrollment.WaitForFirstAttemptAsync(EnrollmentWaitLimit, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + bool firstRun = true; while (!stoppingToken.IsCancellationRequested) { @@ -111,6 +244,15 @@ public sealed class TelemetryWorker : BackgroundService config.Monitors.Processes.Length, config.Monitors.Services.Length); } + // Ask the tray to re-probe session data and WAIT for it, before + // collecting. Ordering is the whole point: the collector reads the spool + // file, so a refresh that lands after Collect() is a refresh the server + // does not see until the NEXT cycle. That was the bug - "Send Status" + // and "Refresh Status" both re-sent a spool nobody had rewritten, so a + // drive the operator had just unmapped kept reporting OK for up to half + // an hour and no amount of clicking changed it. + await RefreshUserContextAsync(token); + var report = collector.Collect(agentVersion, adopt); if (utilities is not null) @@ -118,6 +260,17 @@ public sealed class TelemetryWorker : BackgroundService var extra = utilities.RunAll(token); if (extra.Count > 0) logger.LogDebug("Loaded {Count} agent utility result(s)", extra.Count); + + // NARROW ON PURPOSE. The runner executes every .ps1 in the utilities + // directory, but only this one known key is attached to the report. + // Forwarding the whole dictionary would mean any script dropped into + // that directory starts uploading its output to the server on every + // cadence - see the signing caveat in PowerShellUtilityRunner's + // header. Widening this to the full map is the intended end state of + // the IAgentUtilityRunner seam, but it needs script signing or an + // allowlist first. Until then, add a key here per utility. + if (extra.TryGetValue(DellUpdatesUtilityName, out var dellUpdates)) + report.DellUpdates = dellUpdates; } logger.LogInformation( diff --git a/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs b/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs new file mode 100644 index 00000000..04a42006 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs @@ -0,0 +1,362 @@ +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Collects the session-scoped data that does not exist in the agent's own +// context, by running a short PowerShell probe IN THE CALLING PROCESS'S +// SESSION and writing the result to the user's spool file. +// +// Collects only data that genuinely does not exist in the agent's own context: +// netuse - mapped drives are per logon session; "net use" from +// LocalSystem returns SYSTEM's (empty) mapping table, never the +// operator's Z: to the file server. +// userlisteners- TCP listeners owned by the signed-in user's processes, which +// is what identifies an operator-launched DNC or CMM listener. +// idleseconds - GetLastInputInfo is per session, so session 0 cannot answer +// it. Sampled at probe time only, so treat it as a coarse +// "was anyone at the machine" signal, not live presence. +// +// This runs from `shopdbagent.exe --collect-usercontext`, which the tray - and +// only the tray - invokes, because the tray is already running as the signed-in +// operator. The service does NOT call this: it previously tried to descend into +// the session with CreateProcessAsUser, which is the direction that has to +// duplicate a token, is what endpoint protection watches for, and produced +// nothing on a live endpoint. Nothing here crosses a privilege boundary now; +// the probe simply runs as whoever the tray is. +// +// The script is embedded rather than shipped as a .ps1 so the probe cannot drift +// from the agent binary or be tampered with on disk between cycles. +public sealed class UserContextProbe +{ + // The spool directory is injected by BuildProbeScript rather than derived in + // the script, so the writer cannot disagree with the reader. See + // UserContextSpool.PinnedSpoolDirectory for why $env:USERPROFILE was wrong. + private const string SpoolDirectoryToken = "__SHOPDB_SPOOL_DIR__"; + + private const string ProbeScriptTemplate = """ + $ErrorActionPreference = 'Stop' + $result = [ordered]@{} + # Injected by the agent from UserContextSpool.PinnedSpoolDirectory() so the + # writer and the reader resolve the same directory. Deliberately NOT + # $env:USERPROFILE: the tray can inherit another account's environment on a + # locked-down image, which silently wrote this file into the wrong profile. + $spool = '__SHOPDB_SPOOL_DIR__' + $target = Join-Path $spool 'usercontext.json' + $temporary = "$target.tmp" + + try { + New-Item -ItemType Directory -Path $spool -Force | Out-Null + + $netuse = @() + try { + Get-CimInstance -ClassName Win32_NetworkConnection -ErrorAction Stop | + ForEach-Object { + $netuse += [ordered]@{ + localname = $_.LocalName + remotename = $_.RemoteName + status = $_.Status + username = $_.UserName + persistent = [bool]$_.Persistent + } + } + } catch { + $result['netuseerror'] = $_.Exception.Message + } + $result['netuse'] = $netuse + + # Second, independent view of the same drives. Win32_NetworkConnection + # is the "net use" table and Get-SmbMapping is the SMB client's own, + # and they disagree: a share can be Unavailable in one and absent or + # OK in the other. Neither is authoritative, so both are reported as + # collected and the server decides what to show. Normalising them + # here would discard exactly the disagreement that is diagnostic. + $smbmapping = @() + try { + Get-SmbMapping -ErrorAction Stop | + ForEach-Object { + $smbmapping += [ordered]@{ + localpath = $_.LocalPath + remotepath = $_.RemotePath + status = [string]$_.Status + } + } + } catch { + # Absent on older builds and in some session types. A missing + # source is not a drive fault, so it is recorded and left out + # rather than reported as a failed mapping. + $result['smbmappingerror'] = $_.Exception.Message + } + $result['smbmapping'] = $smbmapping + + $listeners = @() + try { + $ownprocesses = @{} + Get-Process -ErrorAction SilentlyContinue | ForEach-Object { + $ownprocesses[$_.Id] = $_.ProcessName + } + Get-NetTCPConnection -State Listen -ErrorAction Stop | + Where-Object { $ownprocesses.ContainsKey($_.OwningProcess) } | + ForEach-Object { + $listeners += [ordered]@{ + localaddress = $_.LocalAddress + localport = $_.LocalPort + processid = $_.OwningProcess + processname = $ownprocesses[$_.OwningProcess] + } + } + } catch { + $result['userlistenerserror'] = $_.Exception.Message + } + $result['userlisteners'] = $listeners + + # Idle time must be measured inside the user's session: + # GetLastInputInfo is per session, so asking from the agent's + # session 0 would report the service's own idle time, which is + # meaningless. This is what distinguishes "powered on" from + # "someone is actually working at this machine". + try { + # Built as a single-quoted string rather than a here-string: a + # here-string terminator must sit at column 0, which cannot be + # expressed inside an indented C# raw string literal. + $signature = 'using System; using System.Runtime.InteropServices; ' + + 'public class ShopDbIdle { ' + + '[StructLayout(LayoutKind.Sequential)] ' + + 'struct LASTINPUTINFO { public uint cbSize; public uint dwTime; } ' + + '[DllImport("user32.dll")] ' + + 'static extern bool GetLastInputInfo(ref LASTINPUTINFO info); ' + + 'public static long IdleSeconds() { ' + + 'LASTINPUTINFO info = new LASTINPUTINFO(); ' + + 'info.cbSize = (uint)Marshal.SizeOf(info); ' + + 'if (!GetLastInputInfo(ref info)) return -1; ' + + 'return (long)((uint)Environment.TickCount - info.dwTime) / 1000; } }' + Add-Type -TypeDefinition $signature -ErrorAction Stop + $idle = [ShopDbIdle]::IdleSeconds() + if ($idle -ge 0) { $result['idleseconds'] = $idle } + } catch { + $result['idlesecondserror'] = $_.Exception.Message + } + + $result['collectedat'] = (Get-Date).ToUniversalTime().ToString('o') + $result['username'] = $env:USERNAME + + # Stamp the token's elevation state. The username alone does not + # settle it: the SAME account elevated and unelevated gets two + # different logon tokens and therefore two different drive tables, + # so a spool from an elevated ShopFloor session passes the account + # check while still reporting every drive as broken. + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + $result['elevated'] = [bool] $principal.IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + $result['elevatederror'] = $_.Exception.Message + } + + # Write to a temporary name and rename into place, so the reader can + # never observe a half-written file and discard a good result. + $result | ConvertTo-Json -Depth 4 -Compress | + Set-Content -Path $temporary -Encoding UTF8 -NoNewline + Move-Item -Path $temporary -Destination $target -Force + } catch { + exit 1 + } + exit 0 + """; + + // Substitute the resolved spool directory into the template. The path goes in + // as a PowerShell single-quoted literal, so an embedded quote is doubled and + // nothing else in the path is interpreted - backslashes and $ are literal + // inside single quotes, which is exactly what a Windows path needs. + private static string BuildProbeScript(string spoolDirectory) + => ProbeScriptTemplate.Replace( + SpoolDirectoryToken, spoolDirectory.Replace("'", "''")); + + // How long the probe is allowed to take. Generous: this no longer runs on + // the telemetry path, so an unresponsive WMI call delays nothing but itself. + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(60); + + // powershell.exe's exit code when it rejects its command line and prints + // usage instead of running anything. Named so the failure is recognisable. + private const int PowerShellStartupFailure = -196608; + + private readonly ILogger logger; + + public UserContextProbe(ILogger logger) + { + this.logger = logger; + } + + // Run the probe in THIS process's session and write the spool file. + // Returns true only when the spool file is present on disk afterwards. + // + // Called from the --collect-usercontext entry point, which the tray runs as + // the signed-in operator. Never throws: this is background collection and a + // failure must not take the tray or the agent process down with it. + public bool Collect() + { + var spoolDirectory = UserContextSpool.PinnedSpoolDirectory(); + var resultPath = UserContextSpool.PinnedResultPath(); + + try + { + // Logged before the launch, not after. When this goes wrong the + // interesting question is always "which directory did it target", + // and a failure later in this method must not take the answer with + // it. + logger.LogInformation( + "Running the user-context probe as {User}; spool target {Path}", + Environment.UserName, resultPath); + + // The probe writes a .tmp beside the target and moves it into place, + // so the directory has to exist before powershell.exe starts. The + // script creates it too, but doing it here means a permission problem + // is reported by the agent with a real exception rather than showing + // up as an opaque non-zero exit code. + try + { + Directory.CreateDirectory(spoolDirectory); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + logger.LogWarning(error, + "Could not create the user-context spool directory at {Directory}", + spoolDirectory); + return false; + } + // Passed as -EncodedCommand rather than staged as a .ps1 and run with + // -File. The script used to be written into %TEMP% and executed from + // there, which put a dependency on that file still existing, and still + // being readable, at the moment powershell.exe started. On a hardened + // endpoint that window is not safe: endpoint protection quarantines a + // freshly written script in the temp directory between the write and + // the launch, and roaming/redirected profiles can make the path + // unusable outright. + // + // When powershell.exe cannot use its -File argument it prints its usage + // banner and exits -196608 (0xFFFD0000) WITHOUT running a single + // statement. That is a startup failure, not a script failure, so the + // script's own try/catch never runs, nothing is logged about drives, + // and the operator sees a silent "no mapped drives reported" while the + // log only shows the bare exit code. -EncodedCommand carries the script + // in the command line itself, so there is no file to lose and no + // execution-policy or quarantine step between us and the probe. + var encoded = Convert.ToBase64String( + System.Text.Encoding.Unicode.GetBytes(BuildProbeScript(spoolDirectory))); + + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = + "-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand " + + encoded, + WorkingDirectory = Path.GetTempPath(), + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = System.Diagnostics.Process.Start(startInfo); + if (process is null) + { + logger.LogWarning("The user-context probe could not be started."); + return false; + } + + if (!process.WaitForExit((int)ProbeTimeout.TotalMilliseconds)) + { + logger.LogWarning( + "The user-context probe did not finish within {Seconds} seconds.", + ProbeTimeout.TotalSeconds); + TryKill(process); + return false; + } + + if (process.ExitCode != 0) + { + // 0xFFFD0000 is powershell.exe refusing its own command line and + // printing usage: the probe never ran. Called out by name because + // as a bare number it reads like a script error and sends the + // reader looking for a WMI or drive fault that is not there. + var detail = process.ExitCode == PowerShellStartupFailure + ? " (powershell.exe rejected its command line; the probe did not run)" + : string.Empty; + logger.LogWarning( + "The user-context probe exited with code {ExitCode}.{Detail}", + process.ExitCode, detail); + return false; + } + + // Exit code 0 is necessary but NOT sufficient. The probe used to be + // trusted on its exit code alone, which meant a run that wrote its + // file into a different profile entirely still reported success - the + // log claimed a spool had been written for weeks while nothing was + // ever there. Confirm the file exists where the reader will look. + if (!File.Exists(resultPath)) + { + logger.LogWarning( + "The user-context probe reported success but no spool file is " + + "present at {Path}.", resultPath); + return false; + } + + // Byte count alone does not distinguish a healthy spool from a + // well-formed empty one, and "0 drives" is the exact symptom this + // whole path exists to diagnose. Summarising the contents means a + // single log line from any given task run answers whether the probe + // saw the operator's drives, without needing the spool itself. + var summary = SummariseSpool(resultPath); + logger.LogInformation( + "The user-context probe wrote {Bytes} bytes to {Path} as {User}: {Summary}", + new FileInfo(resultPath).Length, resultPath, Environment.UserName, summary); + return true; + } + catch (Exception exception) + { + logger.LogWarning(exception, "The user-context probe failed."); + return false; + } + } + + // One-line description of what the probe actually captured, for the run log. + // Best-effort by design: this is diagnostic colour on an already-successful + // run, so a parse problem here must never turn that run into a failure. + private static string SummariseSpool(string resultPath) + { + try + { + using var document = System.Text.Json.JsonDocument.Parse( + File.ReadAllText(resultPath)); + var root = document.RootElement; + + static int CountOf(System.Text.Json.JsonElement root, string name) => + root.TryGetProperty(name, out var value) + && value.ValueKind == System.Text.Json.JsonValueKind.Array + ? value.GetArrayLength() + : 0; + + var user = root.TryGetProperty("username", out var stamped) + ? stamped.GetString() + : "(unstamped)"; + + return $"user={user} netuse={CountOf(root, "netuse")} " + + $"smbmapping={CountOf(root, "smbmapping")} " + + $"listeners={CountOf(root, "userlisteners")}"; + } + catch (Exception) + { + return "(could not summarise)"; + } + } + + private void TryKill(System.Diagnostics.Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception exception) + { + logger.LogDebug(exception, "Could not stop the timed-out user-context probe."); + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs b/agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs new file mode 100644 index 00000000..af07004d --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs @@ -0,0 +1,179 @@ +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Threading; + +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// The service-to-tray request channel for an on-demand user-context refresh. +// +// WHY THIS EXISTS AT ALL. Mapped drives are a property of the operator's logon +// token, not of the machine. The agent is a session-0 LocalSystem service, so it +// CANNOT enumerate them - not by impersonation, not by CreateProcessAsUser, not +// by starting the probe task itself. That approach was built once and reverted; +// see the tombstone in Program.cs where UserContextProbe is deliberately kept +// out of the service's DI container. The only process on the box that can see +// the right drive table is one already running inside the operator's session: +// the tray. +// +// So the service does not probe. It ASKS. This channel is the ask, and the +// spool file remains the answer. +// +// WHY A NAMED EVENT rather than a pipe or a socket. The request carries no data +// - it is a single bit, "please re-probe now" - and the reply already has a +// transport (the spool file, whose collectedat stamp the service watches +// advance). A kernel event is the smallest thing that crosses the session-0 +// boundary, needs no serialization, no port, no firewall rule, and no cleanup if +// either side dies. A file-watcher trigger would also work but leaves litter on +// disk when the service is killed mid-request. +// +// THE Global\ PREFIX IS LOAD-BEARING. Without it the name is scoped to the +// caller's session, so the service (session 0) and the tray (session 1+) would +// create two unrelated events with the same name and never see each other. That +// failure is silent: both sides "work", nothing is ever refreshed. +// +// THE DACL IS ALSO LOAD-BEARING. The service creates the event as LocalSystem, +// which by default produces an object a non-elevated user cannot even open. The +// tray runs unelevated as ShopFloor by design (an ELEVATED tray is refused by +// the probe, because it would read the wrong drive table). So the creator grants +// Users explicit Synchronize + Modify rights, or the tray gets access-denied on +// open and the refresh silently never happens. +public sealed class UserContextRefreshChannel +{ + // Session-global so session 0 and the interactive session share one object. + public const string EventName = @"Global\ShopDbEndpointAgent.UserContextRefresh"; + + private readonly ILogger logger; + + // Takes the base ILogger rather than ILogger: both the service (generic + // host) and the tray (its own file-backed logger named "Tray") construct + // this, and the tray has no DI container to resolve a typed logger from. + public UserContextRefreshChannel(ILogger logger) + { + this.logger = logger; + } + + // Signal the tray that a fresh probe is wanted. + // + // Returns false when the event could not be opened or created, which is the + // normal state on a machine with nobody signed in and no tray running. The + // caller treats that as "no refresh available" and reports what it already + // has - a stale mapped-drive list is worth strictly more than a missed + // heartbeat, because telemetry IS the liveness signal. + // + // AutoReset, so exactly one waiting tray is released per request and the + // event does not latch on for every subsequent listener iteration. + [SupportedOSPlatform("windows")] + public bool RequestRefresh() + { + try + { + using var handle = OpenOrCreate(out var created); + if (handle is null) + return false; + + handle.Set(); + logger.LogDebug( + "Requested a user-context refresh on {Event} (created={Created})", + EventName, created); + return true; + } + catch (Exception error) + { + // Never throws to the caller. This sits directly on the telemetry + // path, and a refresh is an enhancement to the report, not a + // precondition for sending it. + logger.LogDebug(error, + "Could not request a user-context refresh on {Event}", EventName); + return false; + } + } + + // Tray side: block until the service asks for a refresh, the timeout expires, + // or the token is cancelled. Returns true only for an actual request. + [SupportedOSPlatform("windows")] + public bool WaitForRequest(TimeSpan timeout, CancellationToken token) + { + try + { + using var handle = OpenOrCreate(out _); + if (handle is null) + return false; + + var index = WaitHandle.WaitAny( + new[] { handle, token.WaitHandle }, timeout); + + // Index 1 is cancellation, WaitTimeout is the idle path; only index 0 + // is a genuine request. + return index == 0; + } + catch (Exception error) + { + logger.LogDebug(error, + "Could not wait on the user-context refresh channel {Event}", EventName); + return false; + } + } + + // Open the shared event, creating it if this process is first. + // + // Either side may start first - the service can boot before anyone signs in, + // and the tray can start before the service finishes initializing - so both + // sides use the same create-or-open call rather than designating an owner. + // Whoever wins applies the DACL; the loser opens the existing object. + [SupportedOSPlatform("windows")] + private EventWaitHandle? OpenOrCreate(out bool created) + { + created = false; + try + { + var security = new EventWaitHandleSecurity(); + + // BUILTIN\Users by SID, not by name: on a non-English Windows the + // group is not called "Users", and this agent ships to whatever + // locale the site runs. Same reasoning as the task registration. + var users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null); + security.AddAccessRule(new EventWaitHandleAccessRule( + users, + EventWaitHandleRights.Synchronize | EventWaitHandleRights.Modify, + AccessControlType.Allow)); + + // FullControl for SYSTEM and Administrators so the service retains + // ownership semantics regardless of which side created the object. + var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + security.AddAccessRule(new EventWaitHandleAccessRule( + system, EventWaitHandleRights.FullControl, AccessControlType.Allow)); + + var admins = new SecurityIdentifier( + WellKnownSidType.BuiltinAdministratorsSid, null); + security.AddAccessRule(new EventWaitHandleAccessRule( + admins, EventWaitHandleRights.FullControl, AccessControlType.Allow)); + + return EventWaitHandleAcl.Create( + initialState: false, + mode: EventResetMode.AutoReset, + name: EventName, + createdNew: out created, + eventSecurity: security); + } + catch (UnauthorizedAccessException) + { + // The object exists with a DACL that does not admit this caller. + // Retry read-side-only rights: the tray only ever needs to wait, and + // an event created by an older agent build may not grant Modify. + try + { + return EventWaitHandle.OpenExisting(EventName); + } + catch (Exception error) + { + logger.LogDebug(error, + "The user-context refresh event {Event} exists but is not accessible", + EventName); + return null; + } + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs new file mode 100644 index 00000000..8bb5e8a3 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs @@ -0,0 +1,389 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Reads the result file that the tray-run user-context probe writes into the +// signed-in user's ShopDB spool folder. +// +// Why a file and not a return value: the agent is a session-0 LocalSystem +// service, so anything session-scoped (mapped drives from "net use", per-user +// network state) simply does not exist in its own context. The probe runs as the +// operator under the tray, in a completely separate process with no pipe back to +// the service. A file in the user's own profile is the handoff, and the user can +// always write there without elevation. +// +// Freshness is judged from the 'collectedat' stamp inside the file, NOT by +// deleting the file around each read. Writer and reader are now independent +// processes on unrelated schedules, so a delete-then-read would race the tray +// and usually destroy a perfectly good result before it was ever reported. The +// file is therefore left alone and simply ignored once it ages out, which also +// means a logged-off machine stops reporting session data instead of pinning the +// last operator's mapped drives forever. +public sealed class UserContextSpool +{ + public const string SpoolFolderName = "ShopDB"; + public const string ResultFileName = "usercontext.json"; + + // The one definition of where the spool lives. Both the probe (writer) and + // this class (reader) call it, so the two can no longer disagree. + // + // The writer used to build its own path from $env:USERPROFILE while the + // reader used the pinned ShopFloor profile. On a locked-down image those are + // not the same directory - the tray is launched through explorer.exe by an + // elevated installer and can inherit another account's environment block, as + // the tray already documents for %TEMP%. The probe then wrote a perfectly + // good file into the wrong profile, exited 0, and the service found nothing: + // no spool ever appeared and no mapped drive was ever reported. + public static string PinnedSpoolDirectory() + => Path.Combine(FileLoggerProvider.ShopFloorProfileDirectory(), SpoolFolderName); + + public static string PinnedResultPath() + => Path.Combine(PinnedSpoolDirectory(), ResultFileName); + + // A probe result is small - a handful of mapped drives and listeners. A file + // larger than this is either corrupt or hostile, and we will not parse it. + private const long MaxResultBytes = 512 * 1024; + + // A result older than this is treated as absent. Sized at roughly three tray + // probe intervals (30 min each), so a single missed run never blanks the card + // but genuinely stale session data retires quickly. + // + // This was 24h when the reader deleted the file after each read; the delete + // was doing the real retiring and the age check was only a backstop. Now that + // the spool persists, this value IS the staleness bound, so it is sized + // against the writer's cadence rather than the server's quiet-report window. + // + // Not read from the server: this is an offline-safe local bound, and the + // agent must retire stale session data even when it cannot reach ShopDB. + private static readonly TimeSpan MaxResultAge = TimeSpan.FromMinutes(90); + + private readonly ILogger logger; + + public UserContextSpool(ILogger logger) + { + this.logger = logger; + } + + // Full path to the spool file. Fixed at C:\Users\ShopFloor\ShopDB, because + // every shop-floor endpoint signs in as the same local ShopFloor account. + // + // This used to be resolved through WTSQueryUserToken/GetUserProfileDirectory, + // and that lookup is precisely what fails on a hardened endpoint: querying + // another session's token is a privileged operation that endpoint protection + // blocks. When it failed the service simply reported "no session data" while + // the tray was writing a perfectly good file, so writer and reader silently + // disagreed about the location and the operator saw an empty card. + // + // The token lookup is kept ONLY as a fallback for a machine whose operator + // account is named something else. If both are unavailable there is no + // session data, which costs mapped drives and idle time but never affects + // telemetry, commands or inventory. + public string? TryGetResultPath(out string? reason) + { + var pinned = FileLoggerProvider.ShopFloorProfileDirectory(); + if (Directory.Exists(pinned)) + { + reason = null; + return PinnedResultPath(); + } + + var profileDirectory = + InteractiveProcessLauncher.TryGetActiveUserProfileDirectory(out var error); + if (string.IsNullOrWhiteSpace(profileDirectory)) + { + reason = $"No {FileLoggerProvider.ShopFloorUserName} profile at {pinned}, " + + "and the interactive profile could not be resolved either: " + + (error ?? "unknown error"); + return null; + } + + reason = null; + return Path.Combine(profileDirectory, SpoolFolderName, ResultFileName); + } + + // Remove a result file. Only used to discard one that is corrupt or has aged + // out; a valid result is left in place for the next read. + public void ClearStaleResult(string resultPath) + { + try + { + if (File.Exists(resultPath)) + { + File.Delete(resultPath); + logger.LogDebug( + "Removed a stale user-context spool file at {Path}", resultPath); + } + } + catch (Exception exception) + { + // Not fatal on its own, but it does mean we cannot trust whatever we + // read next, so the caller must know. + logger.LogWarning(exception, + "Could not remove the stale user-context spool file at {Path}", resultPath); + } + } + + public void EnsureSpoolDirectory(string resultPath) + { + var directory = Path.GetDirectoryName(resultPath); + if (string.IsNullOrWhiteSpace(directory)) + return; + + try + { + Directory.CreateDirectory(directory); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not create the user-context spool directory at {Directory}", directory); + } + } + + // Read the probe result without consuming it. Returns null when there is no + // usable, sufficiently recent result, which the caller reports as "no session + // data this cycle" rather than as empty data - absent and empty mean + // different things here. + public Dictionary? TryReadResult(string resultPath) + { + try + { + if (!File.Exists(resultPath)) + return null; + + var info = new FileInfo(resultPath); + if (info.Length == 0) + { + logger.LogDebug("The user-context spool file was empty."); + return null; + } + + if (info.Length > MaxResultBytes) + { + logger.LogWarning( + "The user-context spool file was {Bytes} bytes, above the {Limit} byte " + + "limit; discarding it.", + info.Length, MaxResultBytes); + return null; + } + + var json = File.ReadAllText(resultPath); + if (string.IsNullOrWhiteSpace(json)) + return null; + + var parsed = JsonSerializer.Deserialize>(json); + if (parsed is null || parsed.Count == 0) + return null; + + if (IsExpired(parsed, resultPath)) + return null; + + if (IsWrongAccount(parsed)) + return null; + + if (IsElevatedCollection(parsed)) + return null; + + return parsed; + } + catch (JsonException exception) + { + // A truncated file is the expected shape of this failure: the probe + // was still writing when we looked. Next cycle gets a clean one. + logger.LogWarning(exception, + "The user-context spool file was not valid JSON; discarding it."); + return null; + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not read the user-context spool file at {Path}", resultPath); + return null; + } + } + + // Read the spool WITHOUT removing it. The probe overwrites the file on every + // run (write .tmp, move into place), so the newest result always replaces the + // previous one and there is nothing for the reader to clean up. + // + // This used to delete the file after a successful read, to stop a dead tray + // re-reporting the same drives forever. The collectedat age check already + // does that job, and deleting created a worse problem: lastUserContext in the + // collector is in-memory only, so a service restart wiped the cache while the + // spool had already been consumed. The agent then reported NO mapped drives + // until the tray's next probe - and the installer restarts the service and + // the tray on every install, so a fresh install was guaranteed to show an + // empty card at exactly the moment someone looked at it. + // + // The contents are the operator's own session data, visible to them in their + // own context, so leaving the file in place costs nothing. + public Dictionary? ConsumeResult(string resultPath) + => TryReadResult(resultPath); + + // The collectedat stamp on disk, or null when there is no readable stamp. + // + // This is how the service PROVES a refresh actually happened rather than + // assuming it. Signalling the tray is asynchronous and the probe takes a + // second or two, so a caller that simply slept would either send the old + // spool anyway or pad the delay and hope. Watching this value advance past + // the pre-request stamp is the only honest way to know the file in front of + // us is the answer to OUR request and not the previous one. + // + // Deliberately NOT routed through TryReadResult: this must report the stamp + // of whatever is on disk, including a spool that is expired, elevated, or + // from the wrong account. Those are all rejected later for reporting, but + // for ordering we only care whether the file was rewritten - conflating "not + // usable" with "not fresh" would make the service wait out the full timeout + // on every cycle for a machine whose spool is being rejected for some other + // reason. + // + // Also deliberately NOT the file's LastWriteTime, for the reason IsExpired + // gives: a spool can be copied or restored with a roaming profile and carry + // a misleading filesystem timestamp. The stamp inside the file is written by + // the probe itself. + public DateTimeOffset? TryReadCollectedAt(string resultPath) + { + try + { + if (!File.Exists(resultPath)) + return null; + + var info = new FileInfo(resultPath); + if (info.Length == 0 || info.Length > MaxResultBytes) + return null; + + var json = File.ReadAllText(resultPath); + if (string.IsNullOrWhiteSpace(json)) + return null; + + var parsed = JsonSerializer.Deserialize>(json); + if (parsed is null + || !parsed.TryGetValue("collectedat", out var raw) + || raw is null) + { + return null; + } + + if (DateTimeOffset.TryParse( + raw.ToString(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, + out var collectedat)) + { + return collectedat; + } + + return null; + } + catch (Exception exception) + { + // A torn read while the probe is mid-write is the expected failure + // here and is not worth a warning: the caller polls, so the next + // attempt a moment later sees the completed file. + logger.LogDebug(exception, + "Could not read the user-context collectedat stamp at {Path}", resultPath); + return null; + } + } + + // Reject a spool that some other account produced. + // + // Pinning the path guarantees the file lands where the reader looks, but it + // cannot guarantee WHO ran the probe. Mapped drives and idle time are per + // logon session, so a probe that ran as an administrator (for example a tray + // that inherited an elevated installer's environment) would enumerate that + // account's drive table and write it into the operator's spool. That is worse + // than reporting nothing, because the card would look populated and correct + // while showing drives the operator does not have. + // + // The probe stamps $env:USERNAME into the file, so the mismatch is checkable. + // Logged at warning, not debug: this is a misconfigured endpoint, not a quiet + // absence of data. + private bool IsWrongAccount(Dictionary parsed) + { + if (!parsed.TryGetValue("username", out var raw) || raw is null) + { + logger.LogWarning( + "The user-context spool has no username stamp; ignoring it because " + + "the session it describes cannot be confirmed."); + return true; + } + + var username = raw.ToString(); + if (string.Equals(username, FileLoggerProvider.ShopFloorUserName, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + logger.LogWarning( + "The user-context spool was written by {Actual}, not {Expected}; " + + "ignoring it. Session data is per logon session, so this would " + + "report the wrong account's mapped drives.", + username, FileLoggerProvider.ShopFloorUserName); + return true; + } + + // An elevated probe runs under a different logon token to the operator's + // interactive one and enumerates a different, usually empty, drive table. + // The agent refuses to collect while elevated, so a spool stamped elevated + // should not exist; this rejects any that predates that guard or arrives + // from a hand-run --collect-usercontext in an admin shell. + // + // Note the asymmetry with IsWrongAccount: a MISSING stamp is accepted here + // rather than rejected. An unstamped spool is simply one written by an older + // agent, and treating that as elevated would blank out session data across a + // fleet mid-upgrade. An unstamped spool has already had to pass the username + // check to reach this point. + private bool IsElevatedCollection(Dictionary parsed) + { + if (!parsed.TryGetValue("elevated", out var raw) || raw is null) + return false; + + var elevated = raw is JsonElement element + ? element.ValueKind == JsonValueKind.True + : string.Equals(raw.ToString(), "true", StringComparison.OrdinalIgnoreCase); + + if (!elevated) + return false; + + logger.LogWarning( + "The user-context spool was collected by an ELEVATED process; ignoring " + + "it. Mapped drives are per logon token, so an elevated probe reports " + + "the wrong drive table even under the right account. Collection should " + + "come from the 'ShopDB user-context probe' scheduled task, which runs " + + "at RunLevel Limited."); + return true; + } + + // Age is taken from the stamp the probe wrote, not from the file's last + // write time, because the file can be copied or restored with a roaming + // profile and carry a misleading timestamp. A result with no readable stamp + // is treated as expired: we cannot prove it is current, so we will not claim + // it is. + private bool IsExpired(Dictionary parsed, string resultPath) + { + if (!parsed.TryGetValue("collectedat", out var raw) || raw is null + || !DateTimeOffset.TryParse( + raw.ToString(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, + out var collectedat)) + { + logger.LogDebug( + "The user-context spool file has no usable collectedat stamp; ignoring it."); + return true; + } + + var age = DateTimeOffset.UtcNow - collectedat; + if (age <= MaxResultAge) + return false; + + logger.LogDebug( + "The user-context spool file is {Hours} hours old, past the {Limit} hour limit.", + Math.Round(age.TotalHours, 1), MaxResultAge.TotalHours); + ClearStaleResult(resultPath); + return true; + } +} diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs index bee73158..ddaaee67 100644 --- a/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs +++ b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs @@ -92,6 +92,57 @@ else var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe"); Check("8.3 short name resolving into the approved root", true, shortRoot, policy); +// --- ScheduledTaskPolicy ---------------------------------------------------- +// Pure string logic, unlike LaunchPathPolicy: a task path names an entry in the +// Task Scheduler namespace, not a file, so there is no disk state to plant. The +// cases that matter are the ones where a candidate LOOKS like an approved entry +// as a string but is not the same task. + +void CheckTask(string label, bool expectAccept, string candidate, ScheduledTaskPolicy policy) +{ + var ok = policy.TryValidate(candidate, out var full, out var reason); + var pass = ok == expectAccept; + if (!pass) failures++; + var verdict = pass ? "PASS" : "FAIL"; + var got = ok ? $"accepted -> {full}" : $"rejected ({reason})"; + Console.WriteLine($"{verdict} {label}"); + Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}"); +} + +Console.WriteLine(); +Console.WriteLine("--- ScheduledTaskPolicy ---"); + +var taskPolicy = new ScheduledTaskPolicy(new[] { @"\ReportIP", @"\SFLD\" }); + +CheckTask("exact approved task", true, @"\ReportIP", taskPolicy); +// Task Scheduler is case-insensitive, so the policy must be too, or the same +// task typed differently would be refused. +CheckTask("approved task, different case", true, @"\reportip", taskPolicy); +// The operator should not have to know the rooted form. +CheckTask("approved task without leading separator", true, "ReportIP", taskPolicy); +CheckTask("approved task with forward slashes", true, "/ReportIP", taskPolicy); +// The prefix-suffix confusion this policy exists to prevent: "\ReportIP" must +// NOT authorize "\ReportIPEvil". +CheckTask("sibling task sharing an approved prefix", false, @"\ReportIPEvil", taskPolicy); +CheckTask("unapproved task", false, @"\Evil", taskPolicy); +CheckTask("empty task path", false, "", taskPolicy); +// Folder entries authorize everything beneath them, but not a sibling folder +// whose name merely starts the same way. +CheckTask("task inside an approved folder", true, @"\SFLD\ApplyDSCConfig", taskPolicy); +CheckTask("folder itself", true, @"\SFLD", taskPolicy); +CheckTask("sibling folder sharing the prefix", false, @"\SFLDEvil\Thing", taskPolicy); +// Traversal would let an approved prefix string-match something else entirely. +CheckTask("traversal out of an approved folder", false, @"\SFLD\..\Evil", taskPolicy); +// Defense in depth: the invocation uses ArgumentList, but a task path with +// these characters is not one we should be starting. +CheckTask("argument-injection characters", false, "\\ReportIP\" & calc", taskPolicy); +CheckTask("control character", false, "\\Report\u0000IP", taskPolicy); +// An empty override must fall back to the shipped defaults, never to "allow +// everything". +var defaultPolicy = new ScheduledTaskPolicy(Array.Empty()); +CheckTask("shipped default is honored when override is empty", true, @"\ReportIP", defaultPolicy); +CheckTask("empty override does not mean allow-all", false, @"\Evil", defaultPolicy); + Console.WriteLine(); Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET"); return failures == 0 ? 0 : 1; diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj index 9112673f..1736af61 100644 --- a/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj +++ b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj @@ -16,5 +16,9 @@ + + diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs new file mode 100644 index 00000000..7ccb132b --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs @@ -0,0 +1,457 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using Microsoft.Extensions.Logging; +using ShopDbEndpointAgent.Core; + +namespace ShopDbEndpointAgent.Tray; + +// Displays fleet-wide alert notifications, relayed by the local agent, in a toast-style +// window. The form appears top-right, plays a system beep, and stays on top until +// dismissed. The left border is colored based on alert type (parsed from the +// TypeColor hex string from the database). The operator acknowledges the alert by +// clicking the button or the X with the mouse. The form steals focus when shown, +// so neither button is a tab stop, there is no Accept/Cancel button, keyboard +// clicks are ignored, and mouse clicks are ignored until ArmDelay has passed: +// keystrokes meant for another app must not acknowledge an unseen alert. +// Closing any other way (server withdrew it, timeout, shutdown) is NOT an +// acknowledgement. +internal sealed class FleetAlertForm : Form +{ + private const int MaxMessageLength = 8000; + private const string DefaultTypeName = "Alert"; + private const string DefaultTitle = "(no title)"; + private static readonly TimeSpan ArmDelay = TimeSpan.FromMilliseconds(1500); + private static readonly Color DefaultTypeColor = Color.Gray; + + private readonly FleetAlert alert; + private readonly AgentPipeClient? client; + private readonly ILogger? logger; + private System.Windows.Forms.Timer? countdownTimer; + private System.Windows.Forms.Timer? statusCheckTimer; + private readonly CancellationTokenSource statusCheckCancellation = new(); + private int remainingSeconds; + private Label? countdownLabel; + private TextBox? messageBox; + private readonly List fonts = new(); + private DateTime armedAtUtc = DateTime.MaxValue; + private bool acknowledged; + + public event EventHandler? OnAcknowledgeClicked; + + public FleetAlertForm( + FleetAlert alert, AgentPipeClient? client = null, ILogger? logger = null) + { + this.alert = alert ?? throw new ArgumentNullException(nameof(alert)); + this.client = client; + this.logger = logger; + + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; + Text = string.Empty; + FormBorderStyle = FormBorderStyle.None; + TopMost = true; + ShowInTaskbar = false; + StartPosition = FormStartPosition.Manual; + ClientSize = new Size(400, 320); + BackColor = Color.White; + KeyPreview = false; + + BuildLayout(); + ResumeLayout(false); + + Load += FleetAlertForm_Load; + Shown += FleetAlertForm_Shown; + Resize += (s, e) => ApplyRoundedCorners(); + FormClosing += FleetAlertForm_FormClosing; + } + + private void FleetAlertForm_FormClosing(object? sender, FormClosingEventArgs e) + { + countdownTimer?.Stop(); + statusCheckTimer?.Stop(); + statusCheckCancellation.Cancel(); + } + + private void FleetAlertForm_Load(object? sender, EventArgs e) + { + // Size is final (DPI scaled) by Load; place and shape here. + var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen?.Bounds ?? new Rectangle(0, 0, 1920, 1080); + Location = new Point(screen.Right - Width - 20, screen.Top + 20); + ApplyRoundedCorners(); + + System.Media.SystemSounds.Beep.Play(); + + if (client != null) + { + StartStatusCheck(); + } + } + + private void FleetAlertForm_Shown(object? sender, EventArgs e) + { + armedAtUtc = DateTime.UtcNow + ArmDelay; + if (messageBox != null) + { + ActiveControl = messageBox; + messageBox.Select(0, 0); + } + } + + private void StartStatusCheck() + { + statusCheckTimer = new System.Windows.Forms.Timer { Interval = 30000 }; + statusCheckTimer.Tick += StatusCheckTimer_Tick; + statusCheckTimer.Start(); + } + + private void StatusCheckTimer_Tick(object? sender, EventArgs e) + { + if (client == null) + return; + + var token = statusCheckCancellation.Token; + if (token.IsCancellationRequested) + return; + + Task.Run(async () => + { + try + { + var alerts = await client.GetAlertsAsync(token) + .ConfigureAwait(false); + var stillActive = alerts.Any(a => a.NotificationId == alert.NotificationId); + + if (!stillActive && !token.IsCancellationRequested && IsHandleCreated && !IsDisposed) + { + BeginInvoke(new Action(() => + { + statusCheckTimer?.Stop(); + Close(); + })); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Form is closing + } + catch (Exception exception) + { + // Agent unreachable or refused: keep the form open + logger?.LogDebug(exception, + "Status check for fleet alert {NotificationId} failed.", alert.NotificationId); + } + }); + } + + private void ApplyRoundedCorners() + { + using var path = new GraphicsPath(); + int radius = LogicalToDeviceUnits(8); + var rect = new Rectangle(0, 0, Width, Height); + + path.AddArc(rect.X, rect.Y, radius, radius, 180, 90); + path.AddArc(rect.Right - radius, rect.Y, radius, radius, 270, 90); + path.AddArc(rect.Right - radius, rect.Bottom - radius, radius, radius, 0, 90); + path.AddArc(rect.X, rect.Bottom - radius, radius, radius, 90, 90); + path.CloseFigure(); + + var old = Region; + Region = new Region(path); + old?.Dispose(); + } + + // Not wired (1.6.2 made alerts manual-only). If wired, a timeout closes the + // form WITHOUT acknowledging, so the alert can be surfaced again. + private void StartCountdown() + { + var minutes = alert.TimeoutMinutes; + if (minutes <= 0) + return; + + remainingSeconds = minutes * 60; + + countdownTimer = new System.Windows.Forms.Timer { Interval = 1000 }; + countdownTimer.Tick += CountdownTimer_Tick; + countdownTimer.Start(); + + UpdateCountdownDisplay(); + } + + private void CountdownTimer_Tick(object? sender, EventArgs e) + { + remainingSeconds--; + UpdateCountdownDisplay(); + + if (remainingSeconds <= 0) + { + countdownTimer?.Stop(); + Close(); + } + } + + private void UpdateCountdownDisplay() + { + if (countdownLabel != null) + { + int minutes = remainingSeconds / 60; + int seconds = remainingSeconds % 60; + countdownLabel.Text = $"Closes in {minutes}:{seconds:D2}"; + } + } + + private Font CreateFont(string family, float size, FontStyle style = FontStyle.Regular) + { + var font = new Font(family, size, style); + fonts.Add(font); + return font; + } + + private static string SafeText(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + } + + private static string PrepareMessage(string? value) + { + var text = value ?? string.Empty; + if (text.Length > MaxMessageLength) + text = text.Substring(0, MaxMessageLength) + "..."; + return text.Replace("\r\n", "\n").Replace('\r', '\n').Replace("\n", "\r\n"); + } + + private void BuildLayout() + { + // Dock order: Fill added first, outermost edges added last. + var contentPanel = new Panel + { + Dock = DockStyle.Fill, + BackColor = Color.White, + }; + Controls.Add(contentPanel); + + var borderPanel = new Panel + { + BackColor = ParseTypeColor(alert.TypeColor), + Dock = DockStyle.Left, + Width = 5, + }; + Controls.Add(borderPanel); + + var messagePanel = new Panel + { + Dock = DockStyle.Fill, + Padding = new Padding(20, 4, 12, 4), + BackColor = Color.White, + }; + contentPanel.Controls.Add(messagePanel); + + messageBox = new TextBox + { + Text = PrepareMessage(alert.Message), + Dock = DockStyle.Fill, + Multiline = true, + ReadOnly = true, + WordWrap = true, + ScrollBars = ScrollBars.Vertical, + BorderStyle = BorderStyle.None, + BackColor = Color.White, + Font = CreateFont("Segoe UI", 9.75f), + ForeColor = Color.FromArgb(85, 85, 85), + TabIndex = 0, + }; + messagePanel.Controls.Add(messageBox); + + var titleLabel = new Label + { + Text = SafeText(alert.Title, DefaultTitle), + Dock = DockStyle.Top, + Height = 50, + Padding = new Padding(16, 8, 16, 0), + Font = CreateFont("Segoe UI", 11, FontStyle.Bold), + ForeColor = Color.FromArgb(51, 51, 51), + AutoSize = false, + AutoEllipsis = true, + UseMnemonic = false, + }; + contentPanel.Controls.Add(titleLabel); + + var buttonPanel = new Panel + { + Dock = DockStyle.Bottom, + Height = 46, + BackColor = Color.White, + }; + contentPanel.Controls.Add(buttonPanel); + + var acknowledgeButton = new MouseOnlyButton(IsArmed) + { + Text = "Acknowledge", + Width = 140, + Height = 32, + Left = (395 - 140) / 2, + Top = 7, + Anchor = AnchorStyles.None, + BackColor = Color.FromArgb(76, 175, 80), + ForeColor = Color.White, + FlatStyle = FlatStyle.Flat, + Font = CreateFont("Segoe UI", 9, FontStyle.Bold), + Cursor = Cursors.Hand, + TabStop = false, + UseMnemonic = false, + }; + acknowledgeButton.FlatAppearance.BorderSize = 0; + acknowledgeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(69, 160, 73); + acknowledgeButton.Click += AcknowledgeButton_Click; + buttonPanel.Controls.Add(acknowledgeButton); + + var footerPanel = new Panel + { + Dock = DockStyle.Bottom, + Height = 40, + BackColor = Color.White, + }; + contentPanel.Controls.Add(footerPanel); + + countdownLabel = new Label + { + Text = "Manual acknowledgment required", + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleCenter, + Font = CreateFont("Segoe UI", 8.25f), + ForeColor = Color.FromArgb(153, 153, 153), + UseMnemonic = false, + }; + footerPanel.Controls.Add(countdownLabel); + + var footerBorder = new Panel + { + Dock = DockStyle.Top, + Height = 1, + BackColor = Color.FromArgb(238, 238, 238), + }; + footerPanel.Controls.Add(footerBorder); + + var headerPanel = new Panel + { + Dock = DockStyle.Top, + Height = 45, + BackColor = Color.FromArgb(245, 245, 245), + }; + contentPanel.Controls.Add(headerPanel); + + var typeLabel = new Label + { + Text = SafeText(alert.TypeName, DefaultTypeName).ToUpperInvariant(), + Dock = DockStyle.Fill, + Padding = new Padding(12, 0, 0, 0), + TextAlign = ContentAlignment.MiddleLeft, + AutoEllipsis = true, + Font = CreateFont("Segoe UI", 10, FontStyle.Bold), + ForeColor = Color.FromArgb(51, 51, 51), + UseMnemonic = false, + }; + headerPanel.Controls.Add(typeLabel); + + var closeButton = new MouseOnlyButton(IsArmed) + { + Text = "X", + Dock = DockStyle.Right, + Width = 40, + FlatStyle = FlatStyle.Flat, + Font = CreateFont("Segoe UI", 11), + ForeColor = Color.FromArgb(102, 102, 102), + BackColor = Color.Transparent, + Cursor = Cursors.Hand, + TabStop = false, + UseMnemonic = false, + }; + closeButton.FlatAppearance.BorderSize = 0; + closeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(224, 224, 224); + closeButton.Click += CloseButton_Click; + headerPanel.Controls.Add(closeButton); + } + + private bool IsArmed() => DateTime.UtcNow >= armedAtUtc; + + private static Color ParseTypeColor(string? hexColor) + { + if (string.IsNullOrWhiteSpace(hexColor)) + return DefaultTypeColor; + + var hex = hexColor.Trim().TrimStart('#'); + if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.AllowHexSpecifier, + System.Globalization.CultureInfo.InvariantCulture, out var rgb)) + return DefaultTypeColor; + + return Color.FromArgb((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF); + } + + private void Acknowledge() + { + if (acknowledged) + return; + acknowledged = true; + + countdownTimer?.Stop(); + OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); + DialogResult = DialogResult.OK; + Close(); + } + + private void AcknowledgeButton_Click(object? sender, EventArgs e) => Acknowledge(); + + private void CloseButton_Click(object? sender, EventArgs e) => Acknowledge(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + countdownTimer?.Stop(); + countdownTimer?.Dispose(); + statusCheckTimer?.Stop(); + statusCheckTimer?.Dispose(); + statusCheckCancellation.Cancel(); + statusCheckCancellation.Dispose(); + foreach (var font in fonts) + { + font.Dispose(); + } + fonts.Clear(); + } + base.Dispose(disposing); + } + + // Raises Click only for a left mouse release on the button once armed. + // Space, Enter, mnemonics and PerformClick all route through OnClick + // without passing OnMouseUp, so they are dropped. + private sealed class MouseOnlyButton : Button + { + private readonly Func isArmed; + private bool inMouseUp; + + public MouseOnlyButton(Func isArmed) + { + this.isArmed = isArmed; + } + + protected override void OnMouseUp(MouseEventArgs mevent) + { + inMouseUp = true; + try + { + base.OnMouseUp(mevent); + } + finally + { + inMouseUp = false; + } + } + + protected override void OnClick(EventArgs e) + { + if (inMouseUp && isArmed()) + base.OnClick(e); + } + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs new file mode 100644 index 00000000..1ead5d25 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs @@ -0,0 +1,269 @@ +using Microsoft.Extensions.Logging; +using ShopDbEndpointAgent.Core; + +namespace ShopDbEndpointAgent.Tray; + +// Asks the local agent for fleet-wide alerts every 60 seconds and displays new +// alerts to the operator via FleetAlertForm. The agent holds the credential and +// talks to the server; the tray only speaks the pipe. An alert is recorded in +// FleetAlertStore only once the agent confirms the acknowledgement, so a failed +// ack lets it reappear. Runs for the tray's lifetime on a background thread. +internal sealed class FleetAlertWorker : IDisposable +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60); + private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15); + + // Agent down polls every cycle; warn once, then at most this often. + private static readonly TimeSpan UnreachableLogInterval = TimeSpan.FromMinutes(15); + + private readonly AgentPipeClient client; + private readonly FleetAlertStore store; + private readonly ILogger logger; + private readonly SynchronizationContext? uiContext; + private readonly CancellationTokenSource cancellation; + private readonly Thread workerThread; + + // Set and cleared on the UI thread only. + private FleetAlertForm? openForm; + + // Poller thread only. + private DateTime lastUnreachableLogUtc = DateTime.MinValue; + private int unreachableCount; + + public FleetAlertWorker( + AgentPipeClient client, + FleetAlertStore store, + ILogger logger, + SynchronizationContext? uiContext = null) + { + this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.store = store ?? throw new ArgumentNullException(nameof(store)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.uiContext = uiContext ?? SynchronizationContext.Current; + cancellation = new CancellationTokenSource(); + + workerThread = new Thread(Run) + { + IsBackground = true, + Name = "ShopDB fleet alert poller", + }; + } + + public void Start() + { + logger.LogInformation( + "Fleet alert worker starting; first poll in {Delay}, then every {Interval}.", + StartupDelay, PollInterval); + workerThread.Start(); + } + + private void Run() + { + var token = cancellation.Token; + if (token.WaitHandle.WaitOne(StartupDelay)) + return; + + while (!token.IsCancellationRequested) + { + try + { + PollAndDisplayAlerts(token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return; + } + catch (AgentPipeException exception) + { + LogUnreachable(exception); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle."); + } + + if (token.WaitHandle.WaitOne(PollInterval)) + return; + } + } + + private void LogUnreachable(AgentPipeException exception) + { + unreachableCount++; + var now = DateTime.UtcNow; + if (now - lastUnreachableLogUtc < UnreachableLogInterval) + return; + lastUnreachableLogUtc = now; + logger.LogWarning( + "Could not get fleet alerts from the agent ({Count} failed poll(s)): {Error}. " + + "Retrying on next cycle.", unreachableCount, exception.Message); + } + + private void PollAndDisplayAlerts(CancellationToken token) + { + var alerts = client.GetAlertsAsync(token).GetAwaiter().GetResult(); + if (unreachableCount > 0) + { + logger.LogInformation( + "Agent answered the alert poll again after {Count} failed poll(s).", unreachableCount); + unreachableCount = 0; + lastUnreachableLogUtc = DateTime.MinValue; + } + if (alerts is FleetAlertList { Skipped.Count: > 0 } parsed) + { + logger.LogWarning( + "Skipped {Count} malformed fleet alert(s): {Details}", + parsed.Skipped.Count, string.Join("; ", parsed.Skipped)); + } + if (alerts.Count == 0) + { + logger.LogDebug("No active fleet alerts."); + return; + } + + var newAlerts = alerts + .Where(alert => !store.IsAcknowledged(alert.NotificationId)) + .ToList(); + + if (newAlerts.Count == 0) + { + logger.LogDebug("All {Count} fleet alerts already acknowledged.", alerts.Count); + return; + } + + logger.LogInformation("Found {Count} new fleet alert(s).", newAlerts.Count); + + foreach (var alert in newAlerts) + { + if (token.IsCancellationRequested) + return; + + ShowAlertOnUIThread(alert, token); + } + } + + private void ShowAlertOnUIThread(FleetAlert alert, CancellationToken token) + { + var shown = new ManualResetEventSlim(false); + Exception? formException = null; + + var showAction = new SendOrPostCallback(_ => + { + try + { + if (token.IsCancellationRequested) + return; + + using var form = new FleetAlertForm(alert, client, logger); + form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token); + openForm = form; + try + { + form.ShowDialog(); + } + finally + { + openForm = null; + } + } + catch (Exception exception) + { + formException = exception; + } + finally + { + shown.Set(); + } + }); + + if (uiContext is not null) + uiContext.Post(showAction, null); + else + showAction(null); + + shown.Wait(token); + + if (formException is not null) + { + logger.LogWarning(formException, + "Could not display fleet alert {NotificationId}.", alert.NotificationId); + } + } + + private void OnAlertAcknowledged(FleetAlert alert, CancellationToken token) + { + logger.LogInformation( + "Fleet alert {NotificationId} acknowledged; sending to the agent.", alert.NotificationId); + + // Off the UI thread. Recorded locally only on Ok, so a failed ack + // shows the alert again on a later poll instead of losing it. + Task.Run(async () => + { + try + { + var response = await client.AckAlertAsync(alert.NotificationId, token); + if (response.Ok) + { + store.MarkAcknowledged(alert.NotificationId); + logger.LogInformation("Fleet alert {NotificationId} acknowledged by the agent.", + alert.NotificationId); + } + else + { + logger.LogWarning( + "Agent did not acknowledge fleet alert {NotificationId}: {Error}. It may be shown again.", + alert.NotificationId, response.Error); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + logger.LogInformation( + "Acknowledgement of fleet alert {NotificationId} cancelled by shutdown.", + alert.NotificationId); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not acknowledge fleet alert {NotificationId}; it may be shown again.", + alert.NotificationId); + } + }); + } + + // Closes a displayed alert without acknowledging it, so the form's status + // check stops polling the agent on shutdown. + public void CloseOpenAlert() + { + var form = openForm; + if (form is null || form.IsDisposed) + return; + + try + { + if (form.InvokeRequired) + form.BeginInvoke(new Action(form.Close)); + else + form.Close(); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not close open fleet alert on shutdown."); + } + } + + public void Dispose() + { + cancellation.Cancel(); + CloseOpenAlert(); + + if (workerThread.IsAlive) + { + if (!workerThread.Join(TimeSpan.FromSeconds(5))) + { + logger.LogWarning("Fleet alert worker thread did not exit within timeout."); + } + } + + cancellation.Dispose(); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 322eb69c..90c2d483 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -8,11 +8,122 @@ true ShopDbEndpointAgent.Tray shopdb-endpoint-tray + - 1.0.0 + + + + + + + + + + + + + + + + + 1.7.1 true win-x64 true diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs index f6b80d72..82dc0cbb 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -1,6 +1,8 @@ using System.Diagnostics; using System.Reflection; +using Microsoft.Extensions.Logging; + using ShopDbEndpointAgent.Core; namespace ShopDbEndpointAgent.Tray; @@ -9,11 +11,11 @@ namespace ShopDbEndpointAgent.Tray; // and headless-friendly: no main window, the icon is the whole UI surface until // the operator opens the ticket dialog. // -// "Send Status now" reuses the exact production collection path by shelling out -// to the installed shopdbagent.exe with --once. That keeps the collector key and -// all collection logic in the service binary: the tray never holds a secret and -// never duplicates wiring, it just asks the agent to report on demand so the -// ShopDB monitor page reflects the box immediately instead of at next cadence. +// "Send Status now", "Report an issue" and fleet alerts all go through the +// SYSTEM agent's local pipe (AgentPipeClient). The agent holds the credential +// and all collection logic: the tray never reads a key or config and never +// duplicates wiring, it just asks the agent to act on demand so the ShopDB +// monitor page reflects the box immediately instead of at next cadence. internal sealed class TrayContext : ApplicationContext { // The headless agent/service executable. The tray ships next to it in the @@ -25,8 +27,98 @@ internal sealed class TrayContext : ApplicationContext private readonly Icon brandIcon; private bool sending; + private const string AgentUnreachableText = + "Could not reach the ShopDB Endpoint Agent. Is the ShopDB Endpoint Agent service running?"; + + private readonly AgentPipeClient pipeClient = new(); + + // Cancels in-flight pipe calls from menu handlers when the tray exits. + private readonly CancellationTokenSource shutdownCancellation = new(); + + // Session-scoped collection. The service is LocalSystem and cannot see the + // operator's mapped drives, user-owned listeners, or input idle time, so the + // tray - which is already the operator - collects them and leaves the result + // in a spool file the service reads on its own cadence. + // + // Deliberately longer than the telemetry cadence: this data changes rarely + // (a mapped drive is mapped at logon and stays mapped), and each run spawns + // a short PowerShell process on a machine an operator is actively using. + private static readonly TimeSpan UserContextInterval = TimeSpan.FromMinutes(30); + + // Logon is the worst moment to add work: drive mappings and profile setup + // are still settling, and a probe fired now would capture an incomplete + // picture while competing with everything else starting up. + private static readonly TimeSpan UserContextStartupDelay = TimeSpan.FromMinutes(2); + + // ...but that reasoning only applies at logon. When the tray starts into a + // session that has been up for a while - which is what an installer restart + // or a manual relaunch does - nothing is settling and the full delay just + // extends the window where the server has no session data. The installer + // kills and restarts the tray on every install, so the 2-minute delay was + // being paid at exactly the moment someone was watching the card to see + // whether the install had worked. + private static readonly TimeSpan UserContextEstablishedDelay = TimeSpan.FromSeconds(15); + + // How long the session must have been up to count as established. Comfortably + // past the logon storm, well short of any realistic install-to-check gap. + private static readonly TimeSpan EstablishedSessionUptime = TimeSpan.FromMinutes(5); + + private readonly System.Windows.Forms.Timer userContextTimer; + private bool userContextStarted; + private bool collectingUserContext; + + // Lets the service ask this tray to re-probe on demand, so a "Refresh + // Status" click in the WebUI produces genuinely current session data instead + // of whatever the last 30-minute tick happened to capture. The service + // cannot collect this itself at any price - mapped drives live on the + // operator's logon token - so the only way for an on-demand refresh to be + // accurate is for the request to end up here, in the operator's session. + private readonly UserContextRefreshChannel? refreshChannel; + private readonly CancellationTokenSource refreshCancellation = new(); + + // How long each wait blocks before looping. The wait returns false on + // timeout and the loop simply waits again, so this is not a deadline for + // anything - it just bounds how long the handle stays open, which lets a + // channel that was somehow broken get re-opened on the next pass instead of + // leaving the tray deaf until it restarts. + private static readonly TimeSpan RefreshWaitTimeout = TimeSpan.FromMinutes(5); + + // The UI thread's context, captured in the constructor. The listener runs on + // a background thread, but every probe attempt is marshalled back here so + // that collectingUserContext is only ever read or written from one thread. + // Without this the timer tick and a service request could both pass the + // guard and launch two probes at the same spool file. + private readonly SynchronizationContext? uiContext; + + // Fleet alerts are fetched through the agent pipe and displayed to the + // operator as toast notifications in the top-right corner. The worker polls + // every 60 seconds, filters out already-acknowledged alerts via the store, + // and sends acknowledgements to the agent when the operator dismisses the form. + private readonly FleetAlertStore? fleetAlertStore; + private readonly FleetAlertWorker? fleetAlertWorker; + + // The tray logs into the operator's own profile + // (C:\Users\ShopFloor\ShopDB\Logs\-trayagent.log), NOT into the + // service's ProgramData directory. The operator cannot write there, and the + // file sink fails silently when it cannot create its directory, so tray-side + // failures previously left no trace anywhere. Keeping the two apart means a + // tray problem is diagnosable from a file the operator can actually reach. + private readonly FileLoggerProvider logProvider; + private readonly ILogger logger; + public TrayContext() { + logProvider = new FileLoggerProvider( + FileLoggerProvider.UserProfileLogDirectory(), + filePrefix: "trayagent"); + logger = logProvider.CreateLogger("Tray"); + + var startupDelay = ResolveStartupDelay(); + logger.LogInformation( + "Tray {Version} started; user-context probe in {Delay}, then every {Interval}.", + typeof(TrayContext).Assembly.GetName().Version?.ToString() ?? "unknown", + startupDelay, UserContextInterval); + var menu = new ContextMenuStrip(); menu.Items.Add("Send Status now", null, OnSendStatus); menu.Items.Add("Report an issue...", null, OnReportIssue); @@ -45,6 +137,239 @@ internal sealed class TrayContext : ApplicationContext ContextMenuStrip = menu, }; trayIcon.DoubleClick += OnSendStatus; + + // A dead log sink cannot report itself through the log. Complaining via + // the logger would write the one message that explains the silence into + // the sink that is swallowing messages, so the balloon is the only + // channel that survives. Shown once at startup and never repeated: this + // is a "your diagnostics are blind" notice for whoever is standing at the + // machine, not a recurring nag about something the operator cannot fix. + if (logProvider.SinkProblem is not null) + { + trayIcon.ShowBalloonTip( + 10000, + "ShopDB Endpoint Agent", + "Tray logging is unavailable, so tray diagnostics will not be " + + $"recorded. {logProvider.SinkProblem}", + ToolTipIcon.Warning); + } + + userContextTimer = new System.Windows.Forms.Timer + { + Interval = (int)startupDelay.TotalMilliseconds, + }; + userContextTimer.Tick += OnUserContextTimer; + userContextTimer.Start(); + + uiContext = SynchronizationContext.Current; + + // Best-effort. A tray that cannot open the channel still probes on its + // timer, so the failure mode is "refresh is not immediate", not "session + // data stops working" - which is why this is caught and logged rather + // than allowed to take the tray down at startup. + try + { + refreshChannel = new UserContextRefreshChannel( + logProvider.CreateLogger(nameof(UserContextRefreshChannel))); + StartRefreshListener(); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not open the user-context refresh channel; on-demand " + + "refresh requests from the service will be ignored and session " + + "data will only update on the {Interval} timer.", + UserContextInterval); + } + + // Fleet alert worker. Best-effort: if the worker fails to start, the tray + // still runs and the other features work. An agent that is down is not a + // startup failure; the worker retries the pipe every cycle. + try + { + fleetAlertStore = new FleetAlertStore( + logProvider.CreateLogger(nameof(FleetAlertStore))); + fleetAlertWorker = new FleetAlertWorker( + pipeClient, + fleetAlertStore, + logProvider.CreateLogger(nameof(FleetAlertWorker)), + uiContext); + fleetAlertWorker.Start(); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not start the fleet alert worker; fleet-wide alerts will not " + + "be displayed. Other tray features are unaffected."); + } + } + + // Waits for service refresh requests for the life of the tray. + // + // A dedicated background thread rather than a Task: this blocks on a kernel + // wait handle essentially forever, which is exactly the workload that should + // not sit on a thread-pool thread. IsBackground keeps it from holding the + // process open at exit. + private void StartRefreshListener() + { + var thread = new Thread(() => + { + var token = refreshCancellation.Token; + while (!token.IsCancellationRequested) + { + try + { + if (!refreshChannel!.WaitForRequest(RefreshWaitTimeout, token)) + continue; + + logger.LogInformation( + "The service requested a user-context refresh."); + + // Marshalled, not awaited: the service is not waiting on a + // reply through this channel, it is watching the spool file's + // collectedat stamp. Our only job is to get the probe started + // on the UI thread and go back to waiting. + var post = uiContext; + if (post is not null) + post.Post(_ => _ = CollectUserContextAsync(), null); + else + _ = CollectUserContextAsync(); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception exception) + { + // Never let a bad wait kill the listener - if this thread + // exits, on-demand refresh is silently dead for the rest of + // the session with no other symptom. + logger.LogWarning(exception, + "The user-context refresh listener failed; retrying."); + try + { + Thread.Sleep(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + return; + } + } + } + }) + { + IsBackground = true, + Name = "ShopDB user-context refresh listener", + }; + + thread.Start(); + } + + // Fires once after the startup delay, then re-arms at the steady-state + // interval. Failures are swallowed on purpose: session data is a + // nice-to-have, and an operator must never see a dialog or a crashed tray + // because a probe could not read a drive mapping. + private async void OnUserContextTimer(object? sender, EventArgs e) + { + if (!userContextStarted) + { + userContextStarted = true; + userContextTimer.Interval = (int)UserContextInterval.TotalMilliseconds; + } + + await CollectUserContextAsync(); + } + + // Runs the probe once and returns when the spool has been rewritten. + // + // Shared by all three callers - the cadence timer, an on-demand service + // request, and "Send Status now" - so there is exactly one place that knows + // how to invoke the probe and how to interpret its exit codes. Returns true + // only when a spool file is actually on disk afterwards, which is what lets + // Send Status decide whether it is worth waiting on. + private async Task CollectUserContextAsync() + { + if (collectingUserContext) + { + // Another probe is already in flight against the same spool path. + // Its result is at most a second or two old and satisfies whatever + // this caller wanted, so coalescing is correct here, not a shortcut. + logger.LogDebug( + "A user-context probe is already running; skipping this request."); + return false; + } + + var agentExe = ResolveAgentExe(); + if (agentExe is null) + { + // Previously a bare return. This is the single most likely reason for + // "no mapped drives" with nothing at all in any log, so it must say so. + logger.LogWarning( + "Skipping the user-context probe: {Agent} was not found beside the tray " + + "({Beside}) or at {Installed}.", + AgentExeName, AppContext.BaseDirectory, DefaultInstallDir); + return false; + } + + collectingUserContext = true; + try + { + logger.LogInformation("Running the user-context probe: {Agent}", agentExe); + var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext"); + + // Check the disk rather than announcing what the exit code implies. + // This line used to print a hardcoded path whenever the child exited + // 0, so it reported "spool written to C:\Users\ShopFloor\ShopDB" for + // weeks while the probe was actually writing into a different profile + // and the service was finding nothing. + var resultPath = UserContextSpool.PinnedResultPath(); + if (exitCode == 0 && File.Exists(resultPath)) + { + logger.LogInformation( + "The user-context probe finished; spool present at {Spool}.", + resultPath); + return true; + } + else if (exitCode == 0) + { + logger.LogWarning( + "The user-context probe reported success but no spool file is " + + "present at {Spool}.", resultPath); + } + else if (exitCode == 2) + { + // The agent refused because it was elevated. Called out by name + // because as a bare exit code it reads like a probe fault and + // sends the reader looking for a drive or WMI problem that is + // not there - the fix is to start the tray unelevated, or let + // the scheduled task do the collecting. + logger.LogWarning( + "The user-context probe refused to run because this tray is " + + "ELEVATED. Mapped drives are per logon token, so an elevated " + + "probe would report the wrong drive table. Start the tray " + + "unelevated, or rely on the 'ShopDB user-context probe' " + + "scheduled task."); + } + else + { + logger.LogWarning( + "The user-context probe exited with code {ExitCode}; expected a " + + "spool at {Spool}.", exitCode, resultPath); + } + + return false; + } + catch (Exception exception) + { + // No longer silent. The agent logs its own detail, but if the process + // could not even be started the agent never ran and never logged. + logger.LogWarning(exception, "Could not run the user-context probe."); + return false; + } + finally + { + collectingUserContext = false; + } } private async void OnReportIssue(object? sender, EventArgs e) @@ -61,13 +386,7 @@ internal sealed class TrayContext : ApplicationContext includeScreenshot = dialog.IncludeScreenshot; } - var agentExe = ResolveAgentExe(); - if (agentExe is null) - { - ShowBalloon("Report an issue", "Could not find " + AgentExeName + - ". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error); - return; - } + var token = shutdownCancellation.Token; // Capture AFTER the dialog closes so the report form is never in the // shot. Stage into the interactive operator's own profile @@ -97,16 +416,23 @@ internal sealed class TrayContext : ApplicationContext ShowBalloon("Report an issue", "Sending your report to ShopDB...", ToolTipIcon.Info); - int exitCode = await RunAgentAsync(agentExe, "--report \"" + payloadPath + "\""); - if (exitCode == 0) + // The agent checks the path is inside this user's own profile. + var response = await pipeClient.SendReportAsync(payloadPath, token); + if (response.Ok) ShowBalloon("Report an issue", "Your report was sent to ShopDB.", ToolTipIcon.Info); + else if (response.Data is null) + ShowBalloon("Report an issue", AgentUnreachableText, ToolTipIcon.Error); else ShowBalloon("Report an issue", - "Report send failed (exit " + exitCode + + "Report send failed (" + response.Error + "). See the agent log under ProgramData\\ShopDB.", ToolTipIcon.Warning); } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Tray is exiting. + } catch (Exception error) { ShowBalloon("Report an issue", "Could not send the report: " + @@ -145,23 +471,15 @@ internal sealed class TrayContext : ApplicationContext } } - // Runs shopdbagent.exe --once out of process so the operator can push a - // fresh report without waiting for the next cadence tick. The exit code is - // the agent's own contract: 0 = reported OK, non-zero = failed (bad key, - // host removed, network) and the detail is in the rolling agent log. + // Asks the agent over the pipe to run telemetry once so the operator can + // push a fresh report without waiting for the next cadence tick. The agent + // answers ok or an error; the detail is in the rolling agent log. private async void OnSendStatus(object? sender, EventArgs e) { if (sending) return; - var agentExe = ResolveAgentExe(); - if (agentExe is null) - { - ShowBalloon("Send Status", "Could not find " + AgentExeName + - ". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error); - return; - } - + var token = shutdownCancellation.Token; sending = true; trayIcon.Text = "ShopDB Endpoint Agent (sending status...)"; ShowBalloon("Send Status", "Sending this PC's status to ShopDB...", @@ -169,25 +487,45 @@ internal sealed class TrayContext : ApplicationContext try { - int exitCode = await RunAgentAsync(agentExe, "--once"); - if (exitCode == 0) + // Probe BEFORE reporting, and await it. The operator clicking this + // has just changed something - remapped a drive, signed in - and + // expects the result to show up. sendstatus reads the spool file, so a + // probe started after it, or not awaited, would publish the previous + // probe's answer and the operator would click again and see the same + // stale card. + // + // Not fatal if it fails: a report with older session data still + // carries the heartbeat, inventory and events, which is most of what + // this button is for. + await CollectUserContextAsync(); + + var response = await pipeClient.SendStatusAsync(token); + if (response.Ok) ShowBalloon("Send Status", "Status sent to ShopDB.", ToolTipIcon.Info); + else if (response.Data is null) + ShowBalloon("Send Status", AgentUnreachableText, ToolTipIcon.Error); else ShowBalloon("Send Status", - "Status send failed (exit " + exitCode + + "Status send failed (" + response.Error + "). See the agent log under ProgramData\\ShopDB.", ToolTipIcon.Warning); } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Tray is exiting. + return; + } catch (Exception error) { - ShowBalloon("Send Status", "Could not run the agent: " + + ShowBalloon("Send Status", "Could not reach the agent: " + error.Message, ToolTipIcon.Error); } finally { sending = false; - trayIcon.Text = "ShopDB Endpoint Agent"; + if (!token.IsCancellationRequested) + trayIcon.Text = "ShopDB Endpoint Agent"; } } @@ -249,6 +587,35 @@ internal sealed class TrayContext : ApplicationContext startInfo.Environment.Remove(key); } + // Full delay at logon, short delay when the session is already established. + // + // Environment.TickCount64 is machine uptime, not session uptime, which is a + // deliberate simplification: the two differ only when someone signs out and + // back in without rebooting, and treating that as "established" is harmless - + // the profile is already built and the drives are already mapped in that + // case too. A failure to read it falls back to the conservative long delay. + private TimeSpan ResolveStartupDelay() + { + try + { + var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64); + if (uptime >= EstablishedSessionUptime) + { + logger.LogInformation( + "Session has been up {Uptime}; using the established-session probe delay.", + uptime); + return UserContextEstablishedDelay; + } + } + catch (Exception exception) + { + logger.LogDebug(exception, + "Could not read system uptime; using the logon probe delay."); + } + + return UserContextStartupDelay; + } + // Same directory first (the installer ships the tray next to the agent), // then the known Program Files install directory as a fallback. private static string? ResolveAgentExe() @@ -316,6 +683,19 @@ internal sealed class TrayContext : ApplicationContext { if (disposing) { + userContextTimer.Stop(); + userContextTimer.Dispose(); + + // Cancel and let the listener unwind on its own. The channel opens + // and closes its handle inside each wait rather than holding one, so + // there is nothing here to dispose. + refreshCancellation.Cancel(); + refreshCancellation.Dispose(); + + shutdownCancellation.Cancel(); + fleetAlertWorker?.Dispose(); + shutdownCancellation.Dispose(); + trayIcon.Dispose(); brandIcon.Dispose(); } diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index b003d9f6..afbd2032 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -11,26 +11,156 @@ using ShopDbEndpointAgent.Core; // --console run interactively in the foreground for debugging // --once collect and report exactly once, then exit (smoke test / cron) // --report upload one operator issue report (JSON sidecar written by -// the tray), then exit. Keeps the collector key in the -// service so the tray never holds a secret. +// the tray), then exit. +// Not running as SYSTEM, --once and --report are pipe clients: they ask the +// running service (AgentPipeServer) to do the work and never load a secret. // // Secrets are never read here: AgentConfigProvider layers the registry // (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret -// appsettings.json. The executable is intentionally named shopdbagent.exe so it +// appsettings.json, and the per-PC credential lives in CredentialStore. The executable is intentionally named shopdbagent.exe so it // never collides with third-party agent.exe processes/services (for example the // MTConnect agent this same build can monitor). var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase)); var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase)); +var collectUserContext = args.Any(a => + string.Equals(a, "--collect-usercontext", StringComparison.OrdinalIgnoreCase)); var reportPayloadPath = ArgValue(args, "--report"); var agentVersion = Assembly.GetExecutingAssembly() .GetName().Version?.ToString() ?? "0.0.0"; +// FIRST, ahead of everything else. This verb runs as the signed-in operator (the +// tray invokes it) and must not touch a single privileged resource on the way in. +// +// It used to sit below the bootstrap logger, the fault monitor, and the config +// load, which meant the operator-context process opened the SYSTEM-owned +// ProgramData log directory, attempted an EventLog source registration that only +// an administrator can perform, and read CollectorKey out of HKLM - all before it +// discovered it was only being asked to write a file into the user's own profile. +// On an EPM-managed host (CyberArk) those are exactly the calls a broker +// intercepts, and the registry read put the collector secret in a process that +// has no business holding it. +// +// It also has no config dependency by design: it only writes into the caller's +// own profile and never talks to the server, so it must still work on a machine +// whose CollectorKey has not been provisioned yet - otherwise session data would +// be missing on precisely the endpoints that are hardest to diagnose. +if (collectUserContext) +{ + // First statement in this branch. The scheduled task runs this exe directly + // in the operator's interactive session, and shopdbagent.exe is a console + // app, so without this a black window flashes up on every collection cycle. + // Done before any logging or probing so the window is gone immediately + // rather than lingering for the life of the probe. + ConsoleWindow.Hide(); + + // Logs to the OPERATOR's profile, not the service's ProgramData directory. + // A non-privileged user cannot write to the SYSTEM-owned log directory, and + // the file sink degrades to a no-op when it cannot create its directory, so + // pointing this at the service location meant every probe failure was + // discarded unread - which is exactly how a broken probe looked identical to + // a probe that was never invoked. + using var probeLogProvider = new FileLoggerProvider( + FileLoggerProvider.UserProfileLogDirectory(), + filePrefix: "trayagent"); + using var probeLoggerFactory = LoggerFactory.Create( + logging => logging.AddProvider(probeLogProvider)); + + var probeLogger = probeLoggerFactory.CreateLogger(); + ReportSinkProblem(probeLogProvider, "user-context probe"); + + // Refuse to collect from an elevated process. Drive letters are a property + // of the logon token, not the machine: an elevated process gets a different + // token to the operator's interactive one and enumerates a DIFFERENT, almost + // always empty, drive table. Measured on a live endpoint, the same two + // shares reported Unavailable from the elevated shell and OK from the + // operator's, in both Win32_NetworkConnection and Get-SmbMapping. + // + // Refusing here rather than filtering later is deliberate. An elevated probe + // does not fail - it succeeds and reports every mapped drive as broken, + // which is indistinguishable from a genuine outage at the far end. Better to + // write nothing and say why than to publish a confident wrong answer. + if (IsProcessElevated()) + { + probeLogger.LogError( + "Refusing to collect user context: this process is elevated. Mapped drives " + + "are per-logon-token, so an elevated probe reports the wrong drive table. " + + "Run the '{Task}' scheduled task (RunLevel Limited) instead of invoking " + + "--collect-usercontext from an admin shell.", + "ShopDB user-context probe"); + Console.Error.WriteLine( + "shopdbagent: refusing to collect user context from an elevated process; " + + "mapped drives would be wrong. Use the scheduled task."); + return 2; + } + + var probe = new UserContextProbe(probeLogger); + return probe.Collect() ? 0 : 1; +} + +// True when the current process holds the elevated (high-integrity) token. +static bool IsProcessElevated() +{ + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + var principal = new System.Security.Principal.WindowsPrincipal(identity); + return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); + } + catch + { + // Cannot tell. Treat as not elevated so an unreadable token does not + // suppress collection on a host where the probe would have worked; the + // spool's own account stamp is still checked on the way back in. + return false; + } +} + +// Not SYSTEM: a user (the tray, or an operator at a prompt) cannot read the +// credential or the key, so hand the request to the service over the pipe. +// Ahead of the bootstrap logger for the same reason as the probe above. +if ((runOnce || !string.IsNullOrWhiteSpace(reportPayloadPath)) && !IsRunningAsSystem()) + return await RunPipeClientAsync(runOnce, reportPayloadPath); + +static bool IsRunningAsSystem() +{ + if (!OperatingSystem.IsWindows()) + return true; + try + { + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + return identity.IsSystem; + } + catch + { + return false; + } +} + +// Exit code is the tray's contract: 0 = done, non-zero = failed (reason on stderr). +static async Task RunPipeClientAsync(bool runOnce, string? reportPath) +{ + var client = new AgentPipeClient(); + var response = runOnce + ? await client.SendStatusAsync(CancellationToken.None) + : await client.SendReportAsync(Path.GetFullPath(reportPath!), CancellationToken.None); + if (response.Ok) + return 0; + Console.Error.WriteLine("shopdbagent: " + response.Error); + return response.Data is null ? 3 : 1; +} + // Bootstrap logger first so config loading (including the registry read that // CyberArk/EPM may gate) is fully diagnosable before the host starts. using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory()); var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup"); +ReportSinkProblem(bootstrapFileLogger, "service"); var faultMonitor = new AgentFaultMonitor(bootstrapLogger); faultMonitor.Install(); @@ -41,15 +171,24 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath) ? FileLoggerProvider.DefaultDirectory() : config.LogPath; -if (!config.IsUsable()) +// BaseUrl is the only hard requirement: an enrolled PC runs without the shared +// key, and an unenrolled one can still request approval without it. +if (string.IsNullOrWhiteSpace(config.BaseUrl)) { bootstrapLogger.LogError( - "Config incomplete: provision CollectorKey and BaseUrl in " + - "HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl/apikey in appsettings.json for dev). " + + "Config incomplete: provision BaseUrl in " + + "HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl in appsettings.json for dev). " + "See the registry lines above to tell 'missing' from 'access denied'."); return 1; } +if (!config.HasSharedKey()) +{ + bootstrapLogger.LogWarning( + "No CollectorKey provisioned. Fine once this PC is enrolled; until then " + + "requests are unauthenticated and enrollment needs admin approval."); +} + var baseUrlProblem = config.BaseUrlProblem(); if (baseUrlProblem is not null) { @@ -65,8 +204,7 @@ try if (!string.IsNullOrWhiteSpace(reportPayloadPath)) return await RunReportAsync(config, reportPayloadPath, logDirectory, faultMonitor); - await RunHostAsync(config, agentVersion, logDirectory, forceConsole); - return 0; + return await RunHostAsync(config, agentVersion, logDirectory, forceConsole); } catch (Exception error) { @@ -74,7 +212,7 @@ catch (Exception error) return 1; } -static async Task RunHostAsync(AgentConfig config, string agentVersion, +static async Task RunHostAsync(AgentConfig config, string agentVersion, string logDirectory, bool forceConsole) { var builder = Host.CreateApplicationBuilder(); @@ -85,10 +223,44 @@ static async Task RunHostAsync(AgentConfig config, string agentVersion, ConfigureLogging(builder.Logging, logDirectory); RegisterCoreServices(builder.Services, config, agentVersion); + + // Enrollment is registered (and so started) first; the workers wait briefly + // for its first attempt so an enrollable PC reports on its own credential. + builder.Services.AddSingleton(sp => new EnrollmentService( + config, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + agentVersion, + sp.GetRequiredService>())); + builder.Services.AddSingleton(sp => new AgentPipeServer( + config, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + agentVersion, + sp.GetRequiredService>())); + + builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); - await builder.Build().RunAsync(); + var host = builder.Build(); + + // A newly issued credential reports straight away rather than at the next + // cadence tick, so the server sees the PC switch over. + var credentials = host.Services.GetRequiredService(); + var collectSignal = host.Services.GetRequiredService(); + credentials.Changed += (_, _) => + { + if (credentials.Current is not null) + collectSignal.Request(); + }; + + await host.RunAsync(); + return 0; } static async Task RunOnceAsync(AgentConfig config, string agentVersion, @@ -111,8 +283,8 @@ static async Task RunOnceAsync(AgentConfig config, string agentVersion, } // Uploads one operator issue report described by a tray-written JSON sidecar, -// then deletes the payload folder. Runs out of process from the tray so the -// collector key stays in the service binary. The exit code is the tray's +// then deletes the sidecar. SYSTEM only; a user-context --report goes through +// the pipe (RunPipeClientAsync) and the service validates the path. The exit code is the tray's // contract: 0 = uploaded, non-zero = failed (detail in the agent log). static async Task RunReportAsync(AgentConfig config, string payloadPath, string logDirectory, AgentFaultMonitor faultMonitor) @@ -191,13 +363,60 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config sp.GetRequiredService>())); } + // Persisted per-channel event watermark so each Windows event record is + // reported exactly once, instead of re-sending the newest window of every + // channel on every cadence tick. + services.AddSingleton(sp => new EventWatermarkStore( + sp.GetRequiredService>())); + + // Session-scoped collection. The agent is LocalSystem, so mapped drives and + // user-owned listeners are invisible to it. The tray, which already runs as + // the operator, writes the spool file; the service only reads it. + // + // UserContextProbe is deliberately NOT registered here. The service must + // never run the probe: descending into the session with CreateProcessAsUser + // is the reverted design, and leaving a resolvable dependency in the + // container is an invitation to reintroduce it. The probe is constructed + // only on the --collect-usercontext path, which the tray invokes. + services.AddSingleton(sp => new UserContextSpool( + sp.GetRequiredService>())); + + // The ask-the-tray side of the same split. Registering this is NOT a + // loophole around the paragraph above: the service still cannot collect + // session data, it can only request that the process which can does so, and + // then read the file that appears. The probe stays out of this container. + services.AddSingleton(sp => new UserContextRefreshChannel( + sp.GetRequiredService>())); + services.AddSingleton(sp => new EndpointCollector( sp.GetRequiredService>(), config, - sp.GetService())); + sp.GetService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + // Per-PC credential (if enrolled) or shared key, swapped at runtime. One + // factory builds every HttpClient so pins and auth cannot be skipped. + services.AddSingleton(sp => new CredentialStore( + sp.GetRequiredService().CreateLogger("Credentials"))); + services.AddSingleton(sp => new AgentCredentialProvider( + config, + sp.GetRequiredService(), + sp.GetRequiredService().CreateLogger("Credentials"))); + services.AddSingleton(sp => + sp.GetRequiredService()); + services.AddSingleton(sp => new AgentHttpFactory( + config, + sp.GetRequiredService(), + sp.GetRequiredService().CreateLogger("Http"))); services.AddSingleton(sp => new CollectorClient( - config, sp.GetRequiredService>())); + sp.GetRequiredService(), + sp.GetRequiredService>())); + + services.AddSingleton(sp => new FleetAlertClient( + sp.GetRequiredService(), + sp.GetRequiredService().CreateLogger(nameof(FleetAlertClient)))); services.AddSingleton(sp => new TelemetryWorker( sp.GetRequiredService(), @@ -206,23 +425,30 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config agentVersion, sp.GetRequiredService>(), sp.GetService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetService())); // Command channel: outbound long-poll for allowlisted WebUI commands, run // through the fail-closed executor. Hosted alongside telemetry but on its // own loop so a held-open poll never delays a report. services.AddSingleton(sp => new CommandClient( - config, sp.GetRequiredService>())); + sp.GetRequiredService(), + sp.GetRequiredService>())); services.AddSingleton(sp => new CommandExecutor( sp.GetRequiredService>(), - config)); + config, + sp.GetRequiredService())); services.AddSingleton(sp => new CommandWorker( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), - sp.GetRequiredService())); + sp.GetRequiredService(), + config, + sp.GetService())); } static void ConfigureLogging(ILoggingBuilder logging, string logDirectory) @@ -231,3 +457,22 @@ static void ConfigureLogging(ILoggingBuilder logging, string logDirectory) logging.AddProvider(new FileLoggerProvider(logDirectory)); logging.SetMinimumLevel(LogLevel.Information); } + +// Say so, loudly, when a file sink could not open its directory. +// +// FileLoggerProvider never throws on construction and silently drops lines when +// its directory is unusable. That is the right behaviour - logging must not take +// the agent down - but it made two different failures indistinguishable: a probe +// that ran and failed, and a probe whose log had nowhere to go. The provider +// records the reason; this is the part that was missing, the caller actually +// asking for it. stderr is the only sink guaranteed to exist at this point, so +// that is where the complaint goes. +static void ReportSinkProblem(FileLoggerProvider provider, string which) +{ + if (provider.SinkProblem is null) + return; + + Console.Error.WriteLine( + $"shopdbagent: the {which} log sink is unusable and log lines will be " + + $"dropped - {provider.SinkProblem}"); +} diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 63e33106..f7eb2f7d 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,7 +7,88 @@ enable ShopDbEndpointAgent shopdbagent - + + + + + - 1.6.3 + + + + + + + + + + + + + + +1.20.1 true win-x64 true diff --git a/agent/ShopDbEndpointAgent/appsettings.json b/agent/ShopDbEndpointAgent/appsettings.json index 6db6ca67..4c640d63 100644 --- a/agent/ShopDbEndpointAgent/appsettings.json +++ b/agent/ShopDbEndpointAgent/appsettings.json @@ -1,8 +1,10 @@ { "baseurl": "", "apikey": "REPLACE_WITH_COLLECTOR_KEY", + "serverpins": [], "cadenceseconds": 900, "jitterseconds": 60, + "startupjitterseconds": 30, "adoptonfirstrun": true, "utilitiespath": "", "logpath": "", diff --git a/agent/installer/install-shopdbagent.ps1 b/agent/installer/install-shopdbagent.ps1 deleted file mode 100644 index 5a08d3db..00000000 --- a/agent/installer/install-shopdbagent.ps1 +++ /dev/null @@ -1,355 +0,0 @@ -# install-shopdbagent.ps1 -# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED -# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies -# the agent files to Program Files, runs a one-shot smoke test, then installs -# and starts the Windows service. -# -# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts -# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same -# values are also stamped into the installed appsettings.json as a fallback. -# -# The agent and tray are published as single-file self-contained executables, -# so copying the two .exe files plus appsettings.json is the complete payload. - -[CmdletBinding()] -param( - # ShopDB server root URL for this instance. REQUIRED - there is no default, - # because a default here means one site's hostname shipped to every site - # (ADR-015) and, worse, a working install pointed at somebody else's server. - # - # https is required. The collector key travels in the X-API-Key header and - # every telemetry payload follows it, so plain http puts both on the wire in - # clear. Pass -AllowInsecureHttp only for a local test instance. - [Parameter(Mandatory = $true)] - [string] $BaseUrl, - - # Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS. - # REQUIRED and never defaulted - a key in source is a key in git history, and - # this one is also the command channel's kill switch. - [Parameter(Mandatory = $true)] - [string] $CollectorKey, - - # Permit an http:// BaseUrl. For a local test instance only. - [switch] $AllowInsecureHttp, - - - [string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent", - [string] $ServiceName = "ShopDbEndpointAgent", - - # Interactive tray app that shows the system-tray icon and the on-demand - # "Send Status now" action. Separate process from the SYSTEM service (a - # service cannot draw a tray icon in a user session). Pass -NoTray to skip. - [string] $TrayExeName = "shopdb-endpoint-tray.exe", - [switch] $NoTray, - - # Add a Microsoft Defender path exclusion for the install directory. The - # agent and tray are unsigned single-file self-contained executables, which - # Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as - # a false positive and quarantine, killing the tray before its icon appears. - # Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions - # centrally or the binaries are code-signed). - [switch] $NoDefenderExclusion -) - -# Refuse cleartext unless it was asked for explicitly. The key is a bearer -# credential for the whole fleet's telemetry and command channel. -if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) { - throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance." -} - -$ErrorActionPreference = "Stop" -$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path - -function Assert-Admin { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = New-Object Security.Principal.WindowsPrincipal($identity) - if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - throw "This script must be run from an elevated (Run as administrator) PowerShell." - } -} - -Assert-Admin - -if ([string]::IsNullOrWhiteSpace($BaseUrl)) { - throw "BaseUrl is not set. Pass -BaseUrl 'http://:' when running the script." -} - -# Write HKLM values through the explicit 64-bit registry view. If this script -# runs under 32-bit PowerShell (some management tooling launches x86), the -# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent -# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and -# see nothing, so config "goes missing" and the tray never autostarts. Pinning -# the view removes that dependency on the launching shell's bitness. -function Set-Hklm64Value { - param( - [Parameter(Mandatory)] [string] $SubKey, - [Parameter(Mandatory)] [string] $Name, - [Parameter(Mandatory)] [string] $Value - ) - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey( - [Microsoft.Win32.RegistryHive]::LocalMachine, - [Microsoft.Win32.RegistryView]::Registry64) - try { - $key = $base.CreateSubKey($SubKey) - try { - $key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String) - } - finally { $key.Dispose() } - } - finally { $base.Dispose() } -} - -# Tear down any previously installed agent BEFORE touching files. The agent and -# tray are single-file self-contained exes; while the old service or tray is -# running it holds those .exe files open, so Copy-Item over them fails with a -# file-in-use error. Stopping the service, deleting it, and killing any lingering -# agent/tray processes up front makes the install a clean overwrite. Best-effort -# throughout: a missing service or process is the normal fresh-install case. -function Uninstall-PreviousAgent { - param( - [Parameter(Mandatory)] [string] $Service, - [Parameter(Mandatory)] [string] $TrayExe, - [Parameter(Mandatory)] [string] $TargetDir - ) - - $existing = Get-Service -Name $Service -ErrorAction SilentlyContinue - if ($existing) { - Write-Host " stopping and removing existing service $Service ..." - if ($existing.Status -ne "Stopped") { - try { Stop-Service -Name $Service -Force -ErrorAction Stop } - catch { Write-Warning " could not stop service: $($_.Exception.Message)" } - } - # sc.exe delete only marks the service for deletion; the SCM finishes the - # removal once every handle closes. Give it a moment so the subsequent - # sc.exe create does not hit a "marked for deletion" (1072) error. - sc.exe delete $Service | Out-Null - Start-Sleep -Seconds 2 - } - - # Kill any lingering worker or tray processes that would keep the exes - # locked. Match by the installed exe base names; the service host process is - # shopdbagent, the tray is the TrayExe base name. - $trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe) - foreach ($name in @("shopdbagent", $trayBase)) { - $procs = Get-Process -Name $name -ErrorAction SilentlyContinue - foreach ($p in $procs) { - Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..." - try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } - catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" } - } - } - # Also kill anything still running out of the target install directory, - # regardless of exe name. A tray from an older build (different exe name) or - # one launched from a stale path would otherwise survive the name-based sweep - # above, keep its exe locked, and leave a duplicate icon once step 5 starts - # the fresh tray. Match on the executable path so we only touch our own dir. - $targetPrefix = $TargetDir.TrimEnd('\') + '\' - foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) { - $path = $null - try { $path = $p.Path } catch { $path = $null } - if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..." - try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } - catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" } - } - } - # Brief settle so file handles are released before the copy overwrites them. - Start-Sleep -Milliseconds 500 -} - -Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan -Write-Host "BaseUrl : $BaseUrl" -Write-Host "InstallDir : $InstallDir" -Write-Host "ServiceName : $ServiceName" - -# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator -# typically extracts installer.zip into Downloads, so every file carries a -# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with -# "Windows protected your PC" the first time they run - including the [3/5] -# smoke test below, which launches shopdbagent.exe out of $sourceDir before the -# installed copy exists. Unblocking the source folder up front keeps the smoke -# test (and any manual run from the extract folder) clean; the installed copies -# get unblocked again in step 2a after they land in Program Files. Best-effort: -# a file with no MotW is a no-op. -Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..." -Get-ChildItem -Path $sourceDir -File | ForEach-Object { - try { Unblock-File -Path $_.FullName -ErrorAction Stop } - catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" } -} - -# 0. Uninstall any previous agent first, so the file copy is a clean overwrite -# and does not fail on exes locked by a running service or tray. -Write-Host "`n[0/5] Removing any previously installed agent ..." -Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir - -# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value). -$regPath = "HKLM:\SOFTWARE\GE\ShopDB" -Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..." -Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl -Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey - -# 2. Copy agent files to the install directory. The agent and tray are -# single-file self-contained executables, so the .exe plus appsettings.json is -# the whole payload; there are no side-by-side runtime DLLs to copy. -Write-Host "[2/5] Copying agent files to $InstallDir ..." -New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null -$agentSource = Join-Path $sourceDir "shopdbagent.exe" -if (-not (Test-Path $agentSource)) { - throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder." -} -Copy-Item -Path $agentSource -Destination $InstallDir -Force -$appsettings = Join-Path $sourceDir "appsettings.json" -$installedSettings = Join-Path $InstallDir "appsettings.json" -if (Test-Path $appsettings) { - Copy-Item -Path $appsettings -Destination $InstallDir -Force -} - -# Copy the interactive tray app next to the agent so its same-dir resolution -# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a -# service-only (headless) build. -$trayInstalled = $null -if (-not $NoTray) { - $traySource = Join-Path $sourceDir $TrayExeName - if (Test-Path $traySource) { - Copy-Item -Path $traySource -Destination $InstallDir -Force - $trayInstalled = Join-Path $InstallDir $TrayExeName - Write-Host " copied tray app $TrayExeName" - } - else { - Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)" - } -} - -# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM -# hive so the agent cannot READ the values the registry step just wrote. Stamp -# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads -# appsettings first, then lets the registry override it, so this is a safe -# no-op where the registry read works and the rescue where it does not. -if (Test-Path $installedSettings) { - try { - $json = Get-Content $installedSettings -Raw | ConvertFrom-Json - $json.baseurl = $BaseUrl - $json.apikey = $CollectorKey - $json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8 - Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)" - } - catch { - Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)" - } -} -$exePath = Join-Path $InstallDir "shopdbagent.exe" - -# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files -# extracted from a downloaded zip with a Zone.Identifier alternate data stream; -# SmartScreen reads that and blocks the unsigned exe with "Windows protected -# your PC" the first time it runs, and the operator sees only a "Don't run" -# button. Unblock-File removes the ADS so the smoke test and the tray launch -# start clean. Best-effort: a file with no MotW is a no-op. -Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..." -Get-ChildItem -Path $InstallDir -File | ForEach-Object { - try { Unblock-File -Path $_.FullName -ErrorAction Stop } - catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" } -} - -# 2b. Defender path exclusion for the install dir. Unsigned single-file -# self-contained binaries (and the tray's icon P/Invoke) can trip Defender -# heuristics and get quarantined, which is why the tray dies before showing an -# icon. Excluding the fixed install directory is the least-privilege fix short -# of code-signing. Best-effort: Set-MpPreference is absent on servers without -# Defender and can be blocked by tamper protection; a failure is not fatal. -if (-not $NoDefenderExclusion) { - Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..." - if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) { - try { - Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop - Write-Host " excluded $InstallDir from Defender scanning." - } - catch { - Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)" - Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy." - } - } - else { - Write-Host " Defender cmdlets not present; skipping exclusion." - } -} - -# 3. One-shot smoke test (exact production collection path, then exits). -Write-Host "[3/5] Running one-shot smoke test (--once) ..." -$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow -Write-Host " smoke test exit code: $($proc.ExitCode)" - -$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs" -$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd")) -if (Test-Path $logFile) { - Write-Host " --- last log lines ---" -ForegroundColor DarkGray - Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } -} -if ($proc.ExitCode -ne 0) { - throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service." -} - -# 4. Install the Windows service. Any previous instance was already stopped, -# deleted, and its processes killed in step 0, so this is a clean create. Guard -# against the rare case where the SCM has not finished a pending deletion yet. -Write-Host "[4/5] Installing service $ServiceName ..." -$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue -if ($existing) { - Write-Host " service still present (pending deletion); removing again ..." - if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force } - sc.exe delete $ServiceName | Out-Null - Start-Sleep -Seconds 2 -} -$binPath = '"' + $exePath + '"' -sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null -sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null -sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null - -Start-Service -Name $ServiceName -Write-Host " service started." - -# 5. Register the interactive tray app to launch at user logon (per-machine). -Write-Host "[5/5] Registering tray app for logon ..." -if ($trayInstalled -and (Test-Path $trayInstalled)) { - # Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A - # 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores. - Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` - -Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"') - Write-Host " tray app registered to start at logon (Registry64): $trayInstalled" - - # Start it now so the icon appears without waiting for the next logon. The - # Run key only fires at logon, so a fresh install would otherwise show no - # tray until the operator signs out and back in. - # - # The installer runs elevated, so a direct Start-Process would launch the - # tray ELEVATED too. That is wrong for a tray app (it should run at the - # user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an - # unsigned exe triggers a broker prompt that comes back as Win32 1223 - # "The operation was canceled by the user" - which is exactly what we hit. - # Launching through explorer.exe hands the exe to the already-running user - # shell, which spawns it de-elevated in the interactive session, sidestepping - # the elevation broker entirely. - $interactiveUser = $env:USERNAME - if ($interactiveUser -and $interactiveUser -ne "SYSTEM") { - try { - # explorer.exe re-parents the launch under the user shell at - # medium integrity; it returns immediately regardless of the tray's - # own lifetime, so there is nothing to wait on here. - Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null - Write-Host " tray app started for the current session (de-elevated via explorer)." - } - catch { - Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon." - } - } - else { - Write-Host " no interactive user session; tray will start at next logon." - } -} -else { - Write-Host " tray skipped (headless install)." -} - -Write-Host "`nInstall complete." -ForegroundColor Green -Write-Host "Service '$ServiceName' is running and set to auto-start." -Write-Host "Logs: $logDir" diff --git a/agent/test-fingerprint.ps1 b/agent/test-fingerprint.ps1 new file mode 100644 index 00000000..c59184e5 --- /dev/null +++ b/agent/test-fingerprint.ps1 @@ -0,0 +1,76 @@ +# Test fingerprint computation matches Python backend +# Expected: SHA256("Google Chrome|117.0.5938.132|Google LLC") + +$code = @" +using System; +using System.Security.Cryptography; +using System.Text; + +public class FingerprintTester +{ + public static string ComputeFingerprint(string displayName, string displayVersion, string publisher) + { + var normalized = $"{displayName}|{displayVersion}|{publisher}"; + var bytes = Encoding.UTF8.GetBytes(normalized); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} +"@ + +Add-Type -TypeDefinition $code + +# Test vectors from backend +$tests = @( + @{ + Name = "Google Chrome" + Version = "117.0.5938.132" + Publisher = "Google LLC" + Description = "Standard application" + }, + @{ + Name = "Microsoft Office" + Version = "16.0.16827.20166" + Publisher = "Microsoft Corporation" + Description = "Office suite" + }, + @{ + Name = "Test App No Version" + Version = "" + Publisher = "Test Publisher" + Description = "Empty version string" + }, + @{ + Name = "Test App Empty" + Version = "" + Publisher = "" + Description = "Empty strings" + } +) + +Write-Host "C# Fingerprint Algorithm Test" -ForegroundColor Cyan +Write-Host "==============================" -ForegroundColor Cyan +Write-Host "" + +foreach ($test in $tests) { + $fp = [FingerprintTester]::ComputeFingerprint($test.Name, $test.Version, $test.Publisher) + Write-Host "Test: $($test.Description)" -ForegroundColor Yellow + Write-Host " Input: '$($test.Name)|$($test.Version)|$($test.Publisher)'" + Write-Host " Fingerprint: $fp" -ForegroundColor Green + Write-Host " Length: $($fp.Length) chars" -ForegroundColor Gray + Write-Host "" +} + +# Verify format +$testFp = [FingerprintTester]::ComputeFingerprint("Test", "1.0", "Publisher") +if ($testFp.Length -eq 64) { + Write-Host "Format check: PASS (64 characters)" -ForegroundColor Green +} else { + Write-Host "Format check: FAIL (expected 64, got $($testFp.Length))" -ForegroundColor Red +} + +if ($testFp -match '^[0-9a-f]{64}$') { + Write-Host "Hex check: PASS (lowercase hexadecimal)" -ForegroundColor Green +} else { + Write-Host "Hex check: FAIL (invalid characters)" -ForegroundColor Red +} diff --git a/agent/utilities/shopdb-dellupdates.ps1 b/agent/utilities/shopdb-dellupdates.ps1 new file mode 100644 index 00000000..98451c6b --- /dev/null +++ b/agent/utilities/shopdb-dellupdates.ps1 @@ -0,0 +1,119 @@ +<# +.SYNOPSIS + ShopDB agent utility: reports pending Dell Command | Update items. + +.DESCRIPTION + Discovered and executed by PowerShellUtilityRunner on the telemetry cadence. + Emits a single JSON object on stdout, which the agent attaches to the report + as `dellupdates`. Never throws and never writes anything but JSON: a failure + here must not break a telemetry run, so every error path still emits a valid + object with a `status` explaining why the list is empty. + + THIS SCRIPT DOES NOT SCAN. It only reads the log a previous scan wrote. The + scan itself is fired by the \ShopDB-DellCommandScan scheduled task, which is + what pins dcu-cli.exe's -outputLog to the path below. Keeping the two apart + is deliberate: a telemetry collector that shelled out to dcu-cli would block + the cadence for minutes and hammer Dell's servers every 15 minutes. + + OUTPUT CONTRACT (keep in sync with plugins/endpoints/plugin.py): + status unknown | ok | error - "unknown" means no scan has ever run, + which is NOT the same as "ok" with an empty list (known clean). + scannedat ISO-8601 UTC timestamp of the scan log's last write, or null. + count total pending updates found. + updates up to $MaxUpdates entries of { name, severity, version }. + truncated true when count exceeded $MaxUpdates. + + Dell's own long description text is deliberately NOT carried: it is + sentence-length marketing prose per update, which would add roughly an order + of magnitude to every report for no operational value. Name plus severity is + what an operator decides on. +#> + +$ErrorActionPreference = 'Stop' + +# Cap the list rather than the payload. A machine that is years behind can have +# dozens of pending updates, and an unbounded list would bloat every report on +# the cadence. Mirrors the MaxReportedConnections precedent in CommandExecutor: +# a truncated answer is more useful than a rejected payload. +$MaxUpdates = 25 + +# Must match the -outputLog path set in the \ShopDB-DellCommandScan task +# definition. ShopDB-initiated scans write HERE rather than to Dell's default +# location, which is what keeps them distinguishable from the corporate-managed +# DCU tasks that also run on these machines. +$ScanLog = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\dellcommand\scan.xml' + +function Write-Result { + param($Status, $ScannedAt, $Count, $Updates, $Truncated) + # Compress so the payload carries no pretty-printing whitespace. Depth 3 + # covers the nested updates array. + [pscustomobject]@{ + status = $Status + scannedat = $ScannedAt + count = $Count + updates = @($Updates) + truncated = [bool]$Truncated + } | ConvertTo-Json -Depth 3 -Compress +} + +try { + if (-not (Test-Path -LiteralPath $ScanLog)) { + # No scan has run yet. This is the normal state for a freshly adopted + # endpoint, not an error, but it must stay distinct from "scanned and + # found nothing" or the UI would claim a machine is up to date when + # nobody has ever checked. + Write-Result -Status 'unknown' -ScannedAt $null -Count 0 -Updates @() -Truncated $false + return + } + + $logFile = Get-Item -LiteralPath $ScanLog + $scannedAt = $logFile.LastWriteTimeUtc.ToString('o') + + [xml]$report = Get-Content -LiteralPath $ScanLog -Raw + + # dcu-cli's scan report shape has varied across DCU versions, so select the + # update nodes by name anywhere in the tree rather than by a fixed path. + $nodes = @($report.SelectNodes('//update')) + if ($nodes.Count -eq 0) { + $nodes = @($report.SelectNodes('//Update')) + } + + $updates = foreach ($node in $nodes) { + # Attribute casing also varies by version; take whichever is present and + # fall back rather than emitting a null-filled row. + $name = $node.name + if (-not $name) { $name = $node.Name } + if (-not $name) { $name = $node.display } + if (-not $name) { continue } + + $severity = $node.urgency + if (-not $severity) { $severity = $node.Urgency } + if (-not $severity) { $severity = $node.criticality } + + $version = $node.version + if (-not $version) { $version = $node.Version } + + [pscustomobject]@{ + name = [string]$name + severity = if ($severity) { [string]$severity } else { 'unknown' } + version = if ($version) { [string]$version } else { $null } + } + } + + $updates = @($updates) + $count = $updates.Count + $truncated = $count -gt $MaxUpdates + if ($truncated) { + $updates = $updates[0..($MaxUpdates - 1)] + } + + Write-Result -Status 'ok' -ScannedAt $scannedAt -Count $count ` + -Updates $updates -Truncated $truncated +} +catch { + # Emit a valid object rather than failing: the runner would log a non-JSON + # utility and drop it, which looks identical to "never ran" on the server. + # An explicit error status tells an operator the scan log exists but could + # not be read, which is a different problem from no scan at all. + Write-Result -Status 'error' -ScannedAt $null -Count 0 -Updates @() -Truncated $false +} diff --git a/deploy/ci/buildkit-remote.yaml b/deploy/ci/buildkit-remote.yaml index 654d9d27..c2edab62 100644 --- a/deploy/ci/buildkit-remote.yaml +++ b/deploy/ci/buildkit-remote.yaml @@ -14,9 +14,10 @@ # Deployment (not a per-job container) means the build cache in the PVC survives # across runs, so the heavy frontend npm build is only paid in full once. # -# Cameron: apply once with `kubectl apply -f deploy/ci/buildkit-remote.yaml`. -# This changes cluster infra only. It does NOT modify any application code or -# any branch of the repo. +# Apply once with `kubectl apply -f deploy/ci/buildkit-remote.yaml`. This +# changes cluster infra only. It does NOT modify any application code or any +# branch of the repo. A site that builds its own image with plain `docker +# build` or `docker compose build` needs none of this. --- apiVersion: v1 kind: Namespace diff --git a/deploy/site-profile-universal.json b/deploy/site-profile-universal.json index 12d928bd..cb59903f 100644 --- a/deploy/site-profile-universal.json +++ b/deploy/site-profile-universal.json @@ -3,22 +3,17 @@ "plugins": [ "backups", "computers", - "employees", "endpoints", - "geenforce", "guides", "knowledgebase", "machines", - "measuringtools", "network", "notifications", - "printedparts", "printers", "slides", "tools", - "usb", "warranty" ], "locked": [], - "_comment": "The profile the released Windows installer is built from. Every bundled plugin that carries a manifest, so ONE exe serves any site: the wizard offers all of them and the operator ticks what that site uses. Plugins left unticked are never installed, and 'flask plugin prune-schema' drops their tables at provisioning (ADR-014). 'applications' is deliberately absent - it is manifest-less core and always ships. Build with: deploy/windows/installer/build-installer.sh deploy/site-profile-universal.json . Use site-profile.example.json instead only when a site genuinely needs a lean build; see ADR-013." + "_comment": "Every plugin this branch bundles, which is what a receiving site installs by default. It is the profile docs/DEPLOY.md provisions from: 'flask plugin apply-profile deploy/site-profile-universal.json'. A plugin named here that is not on disk fails the command loudly, so this file and plugins/ must agree - they did not on 2026-09-19, when this still named five plugins that had been left on the site line, and provisioning a fresh site failed with PluginError. 'applications' is deliberately absent: it is manifest-less core and always ships. A site that wants fewer than all twelve copies site-profile.example.json and prunes; 'flask plugin prune-schema' drops the tables of anything left out (ADR-014, ADR-013)." } diff --git a/deploy/windows/installer/.gitignore b/deploy/windows/installer/.gitignore deleted file mode 100644 index 5a3b04ac..00000000 --- a/deploy/windows/installer/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -# Build output and staged payload - regenerable, and ~220MB. -bundle/ -Output/ -# Generated by build-installer.sh from the staged bundle. -plugins.iss -version.iss diff --git a/deploy/windows/installer/README.md b/deploy/windows/installer/README.md deleted file mode 100644 index 0e50dc4b..00000000 --- a/deploy/windows/installer/README.md +++ /dev/null @@ -1,247 +0,0 @@ -# Windows installer - -Builds a single self-contained `.exe` that installs ShopDB-Flask on an -**air-gapped** Windows Server. Nothing here ever touches the network at install -time: Python, the wheels, the SPA and (optionally) MySQL all ship inside it. - -Lives with the application on purpose. The installer depends on app internals - -`flask plugin` verbs, `site-profile.json`, `MOUNT_PATH`, the plugin registry - -so a separate repo would drift out of step with the thing it installs. - -## Files - -| File | What it is | -|---|---| -| `shopdb-preflight.ps1` | Stage 1. Read-only. Changes nothing, reports what this server is missing. | -| `shopdb-install.ps1` | Stages 0 and 2-5 plus `uninstall`. All the actual work. | -| `shopdb-admin.ps1` | Operator console installed alongside the app: status, restart, logs, backup, plugins. | -| `ShopDBFlask.iss` | Inno Setup wizard. A thin wrapper - it collects input and runs the stages. | -| `build-installer.sh` | Stages the bundle from a site profile. | -| `make-branding.py` | Generates wizard artwork and the icon from `frontend/public/*.svg`. | -| `*.bmp`, `shopdb.ico` | Generated artwork, committed so a Windows build box needs no Python. | -| `build-installer.ps1` | The same build, natively on Windows. No Bash needed. | -| `bundle-lock.json` | The exact third-party payload this installer ships. Reviewed by commit. | -| `bundle-lock.ps1` | Creates and checks that lock. Also runs on the target server. | -| `refresh-bundle-lock.ps1` | Regenerates the lock, after showing what changed. | -| `verify_bundle_lock.py` | The same check for the Bash builder, so Linux needs no pwsh. | - -## Building - -Two builders, same result. Use whichever machine you are on - `build-installer.ps1` -does the whole job natively so a Windows work PC needs no Bash. - -```bash -# Linux / WSL -./build-installer.sh ../../site-profile.example.json -``` - -```powershell -# Windows -.\build-installer.ps1 -Profile ..\..\site-profile.example.json -``` - -Both stage the app tree, build the SPA twice, write `plugins.iss`, copy the -installer scripts **from this directory**, and then verify the third-party -payload against `bundle-lock.json`. They exit non-zero if it does not match. - -The payload itself is added by hand, and no longer needs Windows to produce: - -``` -bundle/wheels/ pip download -r requirements.txt --only-binary=:all: \ - --platform win_amd64 --python-version 314 \ - --implementation cp --abi cp314 -d wheels -bundle/python/ python-3.14.x-amd64.exe -bundle/httpplatformhandler/ httpPlatformHandler_amd64.msi -bundle/urlrewrite/ rewrite_amd64.msi (client-IP rule; see below) -bundle/vcredist/ VC_redist.x64.exe (MySQL will not install without it) -bundle/mysql/ mysql-8.4.x-winx64.msi (bundled-database option only) -bundle/mysqlclient/ mysql.exe, mysqldump.exe + libcrypto/libssl (backups) -``` - -Then compile on Windows: - -``` -iscc ShopDBFlask.iss -``` - -**Inno Setup 6.6.0 or newer.** The wizard uses the built-in `windows11` custom -style, which earlier versions reject. The script fails at compile time with that -sentence rather than with a bare "WizardStyle is invalid". - -The wheelhouse is **cp314-locked**. A different Python minor version means a -different wheelhouse; the installer will not use a Python it did not install. - -## What is pinned, and where - -Three layers, because no one of them covers the whole problem. - -| Layer | Covers | Enforced | -|---|---|---| -| `requirements.txt` sha256 per package | every wheel is genuinely what upstream published | `pip --require-hashes` at install; aborts on mismatch | -| `bundle-lock.json` | the EXACT payload: wheels, Python installer, MSIs | both builders, and again on the server before anything runs | -| git | the application tree | code review | - -`--require-hashes` alone is not enough. pip lists every artifact of a pinned -version - `cffi 2.1.0` has 100 hashes - so it proves the wheel is genuine, not -that it is the wheel this bundle was built and tested with. It also ignores -extra files in the wheelhouse, and says nothing about the Python installer or -the MSIs, all of which run as SYSTEM on the target server. - -So `bundle-lock.json` records an exact file set with a sha256 and a byte size -each, and verification is **set equality**: a missing file, an unexpected extra -file, or changed content all fail. There is no install-time override. - -The lockfile is `--universal`, so one file serves Linux (dev, Docker, CI) and the -Windows wheelhouse. A Linux-only resolve had silently omitted `colorama`, a -win32-only dependency of `click` - which in hash-checking mode is a hard error -rather than a quiet omission. - -### Verifying the installer itself - -`bundle-lock.json` is **inside** the thing it describes, so it proves the payload -was not altered between build and install - not that the `.exe` you received is -the one that was built. That needs something out of band. Two options, in order -of preference: - -1. **Authenticode-sign the `.exe`** with a GE code-signing certificate. Windows - then shows a real publisher instead of "Unknown", which is also what stops an - operator learning to click through the SmartScreen warning. -2. **Publish a sha256 per release** through a different channel than the file - itself, and have the receiving site check it: - `Get-FileHash ShopDBFlask_Installer_*.exe -Algorithm SHA256` - -Neither is wired up yet. Until one is, an installer is only as trustworthy as -the share it arrived on. - -### Changing what ships - -```powershell -.\refresh-bundle-lock.ps1 # show what changed, write nothing -.\refresh-bundle-lock.ps1 -Yes # write it -``` - -Then **commit `bundle-lock.json`**. That commit is the review - it is the only -place a change to what runs as SYSTEM on a customer's server becomes visible to -a human. `refresh-bundle-lock.ps1` refuses to overwrite an existing lock until -you have seen the diff, for that reason. - -To stage a bundle before its lock exists: `ALLOW_UNLOCKED=1` (Bash) or -`-AllowUnlocked` (PowerShell). Bundles built that way must not be shipped. - -Verifying a live server, months later and offline: - -```powershell -shopdb-admin.ps1 verify -``` - -## Servers this installer did not build - -It is built for greenfield: its own Python, its own venv, its own IIS objects. -Its upgrade path assumes the thing being upgraded came out of a previous run. -Two guards keep it from damaging a server that was deployed by hand. - -**Python minor version.** An existing venv is reused, which is right for a repair -or an upgrade. It is wrong when the venv belongs to a different Python - the -wheelhouse is tagged for one minor version, so pip would die at the first -compiled package, *after* Python was installed and the app tree replaced. The -installer compares the two up front and stops with both version numbers. - -**IIS objects.** Switching deployment method removes the other method's artifact, -which is correct when the installer owns both and dangerous when it does not: a -wrong `-MountAlias` would delete a live mount with no prompt. It now refuses -unless there is a version stamp proving it made the install, or you pass -`-AdoptExisting`. The refusal lists exactly what it would have removed. - -An existing `web.config` is never overwritten in either case. - -For the West Jefferson production server specifically, this is a **migration, not -an upgrade** - prod runs Python 3.13 against a hand-built deployment, so it needs -a deliberate window, a database backup, and web.config reconciled by hand. - -## Bill of materials - -Every build stages a CycloneDX 1.6 SBOM at `sbom.cdx.json`, inside the -application tree, so it installs onto the server with the app. Both ecosystems, -in one document: - -- **Python** - every pin in `requirements.txt`, with the sha256 the installer - enforces. Environment markers are ignored: a `sys_platform == 'win32'` - dependency still installs on the target. -- **npm** - every package in `frontend/package-lock.json`. Build-only packages - are marked `scope: excluded` rather than dropped, so "not here" is - distinguishable from "not looked for". - -It ships to the server because an air-gapped site cannot be scanned from -anywhere else. When a CVE lands, the answer is already on the box: - -```powershell -shopdb-admin.ps1 verify # counts, and which bundle this is -shopdb-admin.ps1 verify -Path leaflet # is that component here, at what version -``` - -Generated by `scripts/generate_sbom.py` from files that are already pinned and -committed, so it is a translation rather than a scan - no network, no extra -toolchain on the build box, and byte-identical output for the same inputs. It is -deliberately not in `bundle-lock.json`: its provenance is git, not the payload. - -## Client IP addresses - -IIS does not set `X-Forwarded-For` on its own, and HttpPlatformHandler connects -from loopback. Without a rule, **every client reads as 127.0.0.1** - so the -GE-Enforce IP allowlist, the dashboard's visitor-location lookup and per-host -login rate limiting all stop working, silently. - -The wizard asks, because the two answers are mutually exclusive: - -- **Clients connect directly** (`-ClientIpSource direct`, the default) - installs - URL Rewrite from the bundle and sets `X-Forwarded-For` from `REMOTE_ADDR`. - Overwriting the header is what stops a client spoofing its own. -- **A proxy sits in front** (`-ClientIpSource proxy`) - leaves the rule off. - Behind ARR or a load balancer `REMOTE_ADDR` is the *proxy*, so applying the - rule would discard the real client IP. - -An existing `web.config` is never overwritten - it is the one file on a server -that legitimately carries hand-edits. The installer reports what it found -instead. - -## Deployment methods - -Chosen in the wizard, and the bundle carries a SPA build for each because Vite -compiles the base path in - it cannot be switched at install time. - -- **Its own site** on a port (default 8090). -- **Subpath** under an existing site, e.g. `http:///shopdb/`. Needs - no new DNS record. Three things must agree - the IIS application alias, - `MOUNT_PATH` in `.env`, and the SPA's build-time base - so the alias is fixed - per bundle (`SUBPATH_ALIAS`, default `shopdb`) and the installer refuses if the - bundle's build does not match what was asked for. - -Switching between methods removes the other one's IIS artifact and reconciles -`MOUNT_PATH` and `CORS_ORIGINS`, so a server never ends up with both. - -## Upgrades - -Run a newer installer over an existing install. It: - -- backs the database up first, **verifies** the dump is complete, and refuses to - migrate if it cannot; -- restores from that backup if migrations fail, and reports honestly that DDL - the failed migration committed cannot be undone; -- refuses to run a bundle older than what is installed; -- keeps `.env` unless new credentials are supplied, and copies it aside first; -- stops the app pool before replacing files, then starts it again. - -Whether a run is an upgrade is decided by probing the **target database**, not by -whether the app directory exists - a rebuilt server pointed at an existing -database is an upgrade, and treating it as fresh would drop tables. - -## Testing notes - -Verified end to end on Windows Server 2025 against both a bundled MySQL 8.4 LTS and -an existing MySQL 5.6: fresh install, upgrade, re-run idempotency, failure and -rollback, uninstall, and both deployment methods including switching between -them. - -**Not yet verified:** a fully air-gapped run with the network disabled at the -hypervisor, and any load in a real browser (all HTTP checks so far used curl, -which sends no `Origin` header - so `CORS_ORIGINS` is untested in anger). diff --git a/deploy/windows/installer/ShopDBFlask.iss b/deploy/windows/installer/ShopDBFlask.iss deleted file mode 100644 index 31e47078..00000000 --- a/deploy/windows/installer/ShopDBFlask.iss +++ /dev/null @@ -1,1340 +0,0 @@ -; ShopDB-Flask air-gapped Windows installer -; Wraps the tested PowerShell stages in an operator-facing wizard. -; Version 1.0 -; -; DESIGN NOTE - why this is a thin wrapper and not a reimplementation: -; every install action lives in shopdb-install.ps1, which is verified end to end -; on Windows Server 2025 against both a bundled MySQL 8.4 LTS and an existing MySQL -; 5.6. Reimplementing any of it in Pascal Script would create a second code path -; that nobody tests. This file only collects operator input, runs the stages in -; order, and reports which one failed. - -; Inno Setup 6.6.0 is the floor: WizardStyle below uses the built-in 'windows11' -; custom style, which 6.5 and earlier reject. Stated here so a build box on an -; older compiler fails with this sentence rather than with a bare -; "Value of [Setup] section directive WizardStyle is invalid". -#if VER < EncodeVer(6,6,0) - #error This script needs Inno Setup 6.6.0 or newer (WizardStyle=... windows11). Download it from https://jrsoftware.org/isdl.php -#endif - -#define AppName "ShopDB-Flask" -; The PRODUCT version, generated into version.iss by the builder from -; shopdb/__init__.py. It was hardcoded, and had drifted to 0.9.0 while the -; product, the frontend and the newest tag all said 0.7.0 - so the delivered exe, -; its Add/Remove Programs entry and the version stamp written onto the server all -; disagreed with the code inside it, and 0.9.0 collided with a retired contract -; version. Never hardcode it here again. -; -; Do NOT bump a version to work around a locked output file: the BUILD STAMP -; below makes every compile a unique filename, which is what that problem needed. -#include "version.iss" -#define AppPublisher "GE Aerospace" -#define BundleDir "bundle" -; Single source for the install directory - used by DefaultDirName and by the -; wizard's pre-fill fallback, so the two cannot disagree. -#define DefaultDir "C:\shopdb-flask" -; Generated by build-installer.sh from what is actually in the bundle, so the -; plugin page can never offer something the payload does not contain. -#include "plugins.iss" - -[Setup] -AppId={{7C4E1A93-2F86-4D5B-9E31-8A0C6B5D4F27} -AppName={#AppName} -AppVersion={#AppVersion} -AppPublisher={#AppPublisher} -DefaultDirName={#DefaultDir} -DisableDirPage=no -CreateAppDir=yes -PrivilegesRequired=admin -OutputDir=.\Output -; Version identifies the RELEASE; the stamp identifies the BUILD. Separating them -; means recompiling never collides with an exe you happen to have open, and the -; version only moves when something meaningful changed. -#define BuildStamp GetDateTimeString('yyyymmdd-hhnn', '', '') -OutputBaseFilename=ShopDBFlask_Installer_{#AppVersion}_{#BuildStamp} -; The payload is ~200MB of wheels, Python and an MSI, all already compressed. -; lzma2/max on top of that costs minutes and saves almost nothing. -Compression=lzma2/normal -SolidCompression=yes - -; --- Appearance ------------------------------------------------------------- -; 'modern' larger layout, Segoe UI, no 1990s bevels -; 'windows11' built-in custom style (6.6.0+) - rounded controls, current palette -; 'dynamic' follows the machine's light/dark setting automatically -; 'hidebevels' removes the remaining sunken separator lines -; Custom styles switch themselves off under a high-contrast theme or /NOSTYLE, -; so accessibility is not broken by any of this. -WizardStyle=modern windows11 dynamic hidebevels -; 15% larger than default without scaling the font up - the preflight page is -; dense and benefits from the extra room. -WizardSizePercent=115 -DisableWelcomePage=no -; Name the Start Menu folder and skip the "choose a folder" page - a server tool -; does not need the operator to invent a location for its shortcuts. -DefaultGroupName=ShopDB-Flask -DisableProgramGroupPage=yes -; Always write a setup log. Inno writes one only when asked, and every streamed -; line from the install stages goes into it, so this is the difference between a -; diagnosable failure and "exit 1" with nothing to look at. -SetupLogging=yes - -; Artwork is generated by make-branding.py from the app's OWN brand assets, so -; the installer and the running site look like the same product. The @125/@250 -; variants let Inno pick a crisp image on high-DPI displays rather than upscaling. -WizardImageFile=wizard-image.bmp,wizard-image@125.bmp,wizard-image@250.bmp -WizardSmallImageFile=wizard-small.bmp,wizard-small@125.bmp,wizard-small@250.bmp - -; Installer executable icon, and the icon shown in Apps & Features. Without this -; Windows falls back to a generic setup icon and, for uninstall, to whatever exe -; is named - which was python.exe, i.e. a Python logo for a ShopDB entry. -SetupIconFile=shopdb.ico -UninstallDisplayIcon={app}\shopdb.ico -UninstallDisplayName=ShopDB-Flask - -[Languages] -Name: "english"; MessagesFile: "compiler:Default.isl" - -[Messages] -WelcomeLabel1=Set up ShopDB-Flask on this server -; Short, and says the two things an operator actually wants up front: it will not -; need the internet, and it will tell them before it changes anything. -; The installer supplies Python, the wheels, the IIS modules and optionally MySQL. -; It does NOT install the IIS Web Server role itself - that is a Windows feature, -; and the check on the next page confirms it is present rather than adding it. -WelcomeLabel2=Everything this application needs is included - Python, its packages, the IIS modules and optionally MySQL. No internet connection is used at any point.%n%nThis server must already have the IIS Web Server role installed. The next page checks that, and everything else this needs, before anything is changed.%n%nYou will then be asked a few short questions. Nothing on this server is changed until the final confirmation. -ClickNext=Click Next to check this server. -FinishedHeadingLabel=ShopDB-Flask is ready -SetupAppTitle=ShopDB-Flask Setup -SetupWindowTitle=ShopDB-Flask Setup - -[Files] -; The whole verified bundle, staged next to this script by build-installer.sh. -; Extracted during the install step, so it is available from ssPostInstall onward -; but NOT during the wizard pages. -Source: "{#BundleDir}\*"; DestDir: "{tmp}\shopdb-bundle"; \ - Flags: ignoreversion recursesubdirs createallsubdirs deleteafterinstall -; The preflight has to run on a WIZARD PAGE, which happens long before the [Files] -; section is processed. 'dontcopy' plus ExtractTemporaryFile is the only way to get -; a file on disk that early. Listed twice on purpose - once for each phase. -Source: "{#BundleDir}\shopdb-preflight.ps1"; Flags: dontcopy -; The operator's day-to-day tool. Installed into the app directory and given -; Start Menu shortcuts, so nobody has to open IIS Manager to restart the site. -; [UninstallRun] executes this from {app}. It was only ever staged into {tmp} -; with deleteafterinstall, so uninstall ran powershell against a path that no -; longer existed, exited non-zero unnoticed (runhidden, no result check), and -; Windows reported success while the site, app pool, firewall rule and .env with -; its plaintext password were all left in place. -Source: "{#BundleDir}\shopdb-install.ps1"; DestDir: "{app}"; Flags: ignoreversion -Source: "{#BundleDir}\shopdb-preflight.ps1"; DestDir: "{app}"; Flags: ignoreversion -Source: "shopdb-admin.ps1"; DestDir: "{app}"; Flags: ignoreversion -; Kept on disk so the Start Menu shortcuts and the uninstall entry have an icon. -Source: "shopdb.ico"; DestDir: "{app}"; Flags: ignoreversion -; Status colours. These are BITMAPS, not control colours: a custom VCL style -; (WizardStyle=... windows11) repaints styled controls and ignores both -; Font.Color and TPanel.Color, so a bitmap is the only thing guaranteed to show -; the colour the operator is meant to see. -Source: "swatch-ok.bmp"; Flags: dontcopy -Source: "swatch-warn.bmp"; Flags: dontcopy -Source: "swatch-bad.bmp"; Flags: dontcopy - -[Icons] -; A folder rather than loose icons: this is a server tool, not a desktop app. -; -; EVERY shortcut passes -AppRoot and -SitePort. The console defaults to -; C:\shopdb-flask and port 8090, so on any install that chose a different -; directory or port it looked in the wrong place and reported a perfectly healthy -; site as broken - from a Start Menu shortcut the installer wrote itself. -Name: "{group}\ShopDB-Flask Console"; Filename: "powershell.exe"; \ - Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \ - WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \ - Comment: "Status, restart, logs and backups" -; Resolves the address from .env at click time rather than baking one in. The -; literal http://localhost:8090/login this used to carry was wrong for every -; subpath install and every non-default port, with no operator mistake involved. -Name: "{group}\Open ShopDB-Flask"; Filename: "powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{app}\shopdb-admin.ps1"" open -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \ - WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \ - Comment: "Open the application in a browser" -Name: "{group}\Restart ShopDB-Flask"; Filename: "powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" restart -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \ - WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \ - Comment: "Recycle the application pool" -Name: "{group}\Back up the database"; Filename: "powershell.exe"; \ - Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" backup -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \ - WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \ - Comment: "Write a .sql dump to ProgramData" - -[Run] -; Offer the console at the end, unticked - finishing the wizard should not -; surprise anyone with a shell. -; Setup is a 32-bit process, so a bare "powershell.exe" here resolves through -; WOW64 to the 32-bit build, whose Get-Website fails with REGDB_E_CLASSNOTREG -; and makes the console report "cannot read IIS". Sysnative reaches the real -; System32 from a 32-bit process. The script also self-corrects, but launching -; it correctly avoids a visible relaunch. -Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \ - Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \ - Description: "Open the ShopDB-Flask console"; \ - Flags: postinstall skipifsilent unchecked - -[Code] -var - DbChoicePage: TInputOptionWizardPage; - DbDetailsPage: TInputQueryWizardPage; - DbCredsPage: TInputQueryWizardPage; - SitePage: TInputQueryWizardPage; - PreflightPage: TWizardPage; - BannerPanel: TPanel; - BannerBar: TBitmapImage; - BannerText: TNewStaticText; - BannerSub: TNewStaticText; - DetailPanel: TPanel; - FooterText: TNewStaticText; - RecheckButton: TNewButton; - // Set by the preflight render. The wizard REFUSES to leave the results page - // while this is True: the check used to be advisory, so a server missing IIS - // sailed through every page and failed in the middle of installing, having - // already put Python on the box. - PreflightBlocked: Boolean; - PreflightDone: Boolean; - // Shown while the preflight runs. Without it, clicking Next appears to hang: - // the check takes a few seconds, runs hidden, and gives no sign of life. - CheckingPage: TOutputProgressWizardPage; - PluginPage: TInputOptionWizardPage; - CatalogPage: TInputOptionWizardPage; - PluginNames: TArrayOfString; - PluginPageReady: Boolean; - DbPageReady: Boolean; - DeployPage: TInputOptionWizardPage; - DeployPageReady: Boolean; - ClientIpPage: TInputOptionWizardPage; - // Set from the streamed stage output so a failure can name its cause. Without - // this the wizard could only report "exit 1", which points at nothing. - FailCause: String; - FailDetail: String; - UseBundledDb: Boolean; - LogPath: String; - -const - DB_BUNDLED = 0; - DB_EXISTING = 1; - // Row geometry, in the page's own coordinate space. ScaleY/ScaleX keep these - // correct at 125%/150% DPI - hardcoded pixels would overlap on a 4K display. - ROW_GAP = 4; - - -// Plugin directory names are developer-facing. Show operators what the feature -// actually is; anything unlisted falls back to its raw name so a new plugin -// still appears rather than vanishing. -function PluginLabel(const Name: String): String; -begin - if Name = 'computers' then Result := 'Computers and workstations' - else if Name = 'machines' then Result := 'Machines (CNC, CMM, lathes)' - else if Name = 'printers' then Result := 'Printers and supplies' - else if Name = 'network' then Result := 'Network devices' - else if Name = 'measuringtools' then Result := 'Measuring tools and gauges' - else if Name = 'printedparts' then Result := '3D printed parts' - else if Name = 'knowledgebase' then Result := 'Knowledge base articles' - else if Name = 'slides' then Result := 'Shopfloor display slides' - else if Name = 'warranty' then Result := 'Warranty tracking' - else if Name = 'notifications' then Result := 'Notifications and announcements' - else if Name = 'usb' then Result := 'USB device checkout' - else if Name = 'employees' then Result := 'Employee directory' - else if Name = 'geenforce' then Result := 'GE-Enforce manifests' - else Result := Name; -end; - -// True when a fresh install should tick this by default. The five omitted here -// are specialised; a site that wants them can tick them. -// Pre-tick to match each plugin's own manifest. These five ship -// "default_enabled": false, so a site gets them only by asking. The list used to -// omit measuringtools and printedparts, which meant the wizard installed and -// enabled them against their manifests on every site that took the defaults - -// creating tables nobody asked for. -// -// Keep this in step with the manifests. tests/test_installer_defaults.py fails -// if they drift. -function PluginDefault(const Name: String): Boolean; -begin - Result := (Name <> 'usb') and (Name <> 'employees') and (Name <> 'geenforce') - and (Name <> 'measuringtools') and (Name <> 'printedparts'); -end; - -// Comma-separated list of what the operator ticked. -function SelectedPlugins: String; -var - I, Count: Integer; -begin - Result := ''; - if PluginPage = nil then Exit; - // Never index past what was actually ADDED to the page. PluginNames comes from - // a build-time define and CheckListBox.Items from the Add() calls; if those - // ever disagree, reading Values[] past the end is a runtime error rather than - // a graceful miss. - Count := GetArrayLength(PluginNames); - if PluginPage.CheckListBox.Items.Count < Count then - Count := PluginPage.CheckListBox.Items.Count; - for I := 0 to Count - 1 do - if PluginPage.Values[I] then - begin - if Result <> '' then Result := Result + ','; - Result := Result + PluginNames[I]; - end; -end; - -procedure SetSwatch(Img: TBitmapImage; const Swatch: String); -begin - ExtractTemporaryFile(Swatch); - Img.Bitmap.LoadFromFile(ExpandConstant('{tmp}\') + Swatch); -end; - -// A solid colour block, drawn as a stretched bitmap so no VCL style can repaint -// it. Used for the row markers and the banner's accent bar. -function AddSwatch(Parent: TWinControl; L, T, W, H: Integer; const Swatch: String): TBitmapImage; -begin - Result := TBitmapImage.Create(WizardForm); - Result.Parent := Parent; - Result.Left := L; - Result.Top := T; - Result.Width := W; - Result.Height := H; - Result.Stretch := True; - ExtractTemporaryFile(Swatch); - Result.Bitmap.LoadFromFile(ExpandConstant('{tmp}\') + Swatch); -end; - -// Defined further down, next to the preflight rendering they drive. Declared here -// because InitializeWizard wires RecheckClick to a button before that point. -procedure RunPreflight; forward; -procedure RecheckClick(Sender: TObject); forward; - -procedure InitializeWizard; -var - I: Integer; - Domain: String; -begin - LogPath := ExpandConstant('{tmp}\shopdb-installer-wizard.log'); - - // A custom page, not a memo. Status belongs in controls with colour, not in a - // wall of monospace text that has to be read line by line. - PreflightPage := CreateCustomPage(wpWelcome, - 'Server check', 'Confirming this server is ready'); - - // Verdict banner. Colour is the whole point: an operator should know within a - // second whether they can continue, without reading anything. - BannerPanel := TPanel.Create(WizardForm); - BannerPanel.Parent := PreflightPage.Surface; - BannerPanel.Left := 0; - BannerPanel.Top := 0; - BannerPanel.Width := PreflightPage.SurfaceWidth; - BannerPanel.Height := ScaleY(58); - BannerPanel.BevelOuter := bvNone; - // No Color here: the style would repaint it. The verdict colour is carried by - // BannerBar, a stretched bitmap down the left edge, which the style cannot touch. - - BannerBar := AddSwatch(BannerPanel, 0, 0, ScaleX(6), ScaleY(58), 'swatch-ok.bmp'); - - BannerText := TNewStaticText.Create(WizardForm); - BannerText.Parent := BannerPanel; - BannerText.Left := ScaleX(18); - BannerText.Top := ScaleY(10); - BannerText.Font.Size := 12; - BannerText.Font.Style := [fsBold]; - BannerText.Caption := 'Checking...'; - - BannerSub := TNewStaticText.Create(WizardForm); - BannerSub.Parent := BannerPanel; - BannerSub.Left := ScaleX(18); - BannerSub.Top := ScaleY(33); - BannerSub.Width := PreflightPage.SurfaceWidth - ScaleX(28); - BannerSub.AutoSize := False; - BannerSub.Caption := ''; - - // Rows are added here at run time, once the results are known. - DetailPanel := TPanel.Create(WizardForm); - DetailPanel.Parent := PreflightPage.Surface; - DetailPanel.Left := 0; - DetailPanel.Top := BannerPanel.Height + ScaleY(12); - DetailPanel.Width := PreflightPage.SurfaceWidth; - DetailPanel.Height := PreflightPage.SurfaceHeight - BannerPanel.Height - ScaleY(34); - DetailPanel.BevelOuter := bvNone; - DetailPanel.Color := clWindow; - - FooterText := TNewStaticText.Create(WizardForm); - FooterText.Parent := PreflightPage.Surface; - FooterText.Left := 0; - FooterText.Top := PreflightPage.SurfaceHeight - ScaleY(16); - FooterText.Width := PreflightPage.SurfaceWidth - ScaleX(84); - FooterText.AutoSize := False; - FooterText.Caption := ''; - - // A blocking page needs a way forward that is not "cancel the installer". The - // operator fixes what the page told them to fix - installs the IIS role, frees - // the port - and re-checks without starting over. - RecheckButton := TNewButton.Create(WizardForm); - RecheckButton.Parent := PreflightPage.Surface; - RecheckButton.Width := ScaleX(78); - RecheckButton.Height := ScaleY(23); - RecheckButton.Left := PreflightPage.SurfaceWidth - RecheckButton.Width; - RecheckButton.Top := PreflightPage.SurfaceHeight - ScaleY(23); - RecheckButton.Caption := 'Check again'; - RecheckButton.OnClick := @RecheckClick; - - CheckingPage := CreateOutputProgressPage('Checking this server', - 'Reading the current configuration. Nothing is being changed.'); - - // Which features this site uses. Multi-select, and pre-ticked from the - // EXISTING install when there is one - so an upgrade shows what you already - // have and ticking a new box adds it. - PluginPage := CreateInputOptionPage(PreflightPage.ID, - 'Features', 'Which parts of ShopDB-Flask does this site use?', - 'Unticked features are simply not set up, and their database tables are not ' + - 'created. On an upgrade this shows what the site already has - ticking a new ' + - 'box adds it, but UNTICKING one does NOT remove a feature that is already ' + - 'installed. Removing is a deliberate step, not a side effect of an upgrade.', - False, True); - PluginNames := StringSplit('{#AvailablePlugins}', [','], stAll); - for I := 0 to GetArrayLength(PluginNames) - 1 do - if Trim(PluginNames[I]) <> '' then - PluginPage.Add(PluginLabel(Trim(PluginNames[I]))); - - // Offered, not forced. A site that machines nothing inherits 21 machine types - // it will never use, and a catalog nobody wanted is just clutter in every - // dropdown. Ticked by default because the alternative is retyping a vendor and - // model list another site already built. - CatalogPage := CreateInputOptionPage(PluginPage.ID, - 'Starter data', 'Load the shared equipment catalog?', - 'Vendors, model numbers and the type lists, as built up across the other ' + - 'sites. It only ADDS - nothing existing is changed or removed - and it can ' + - 'be loaded later with "flask seed catalog". No assets, locations or people ' + - 'are included; this is catalogue information only.', - False, False); - CatalogPage.Add('Load the shared vendor and model catalog'); - CatalogPage.Values[0] := True; - - DbChoicePage := CreateInputOptionPage(CatalogPage.ID, - 'Database', 'Where should ShopDB-Flask store its data?', - 'Most sites already run MySQL for the existing shopdb application. If so, ' + - 'choose the second option - installing a second server would collide on ' + - 'port 3306.', - True, False); - DbChoicePage.Add('Install the bundled MySQL 8.4 LTS (new servers only)'); - DbChoicePage.Add('Use a MySQL server this site already runs'); - DbChoicePage.SelectedValueIndex := DB_EXISTING; - - DbDetailsPage := CreateInputQueryPage(DbChoicePage.ID, - 'Existing database', 'Connection details', - 'The database and application user must already exist - the installer does ' + - 'not create them, so it never needs administrative rights on your database ' + - 'server. The exact SQL to give your DBA is in the INSTALL-WINDOWS guide.'); - DbDetailsPage.Add('Host:', False); - DbDetailsPage.Add('Port:', False); - DbDetailsPage.Add('Database:', False); - DbDetailsPage.Values[0] := '127.0.0.1'; - DbDetailsPage.Values[1] := '3306'; - DbDetailsPage.Values[2] := 'shopdb_flask'; - - // SPLIT ACROSS TWO PAGES on purpose. CreateInputQueryPage stacks its fields - // below the description and does not scroll or shrink: with five fields the - // last one is drawn past the bottom of the surface and simply does not appear. - // Trimming the description bought one field back and lost the next, which is - // guessing at a pixel budget that varies with DPI and font. Three fields and - // two fields both fit under any reasonable description, at any scaling. - DbCredsPage := CreateInputQueryPage(DbDetailsPage.ID, - 'Existing database', 'Sign-in', - 'The account the application uses to reach that database. It needs full ' + - 'rights on it, and nothing outside it.'); - DbCredsPage.Add('Username:', False); - DbCredsPage.Add('Password:', True); - DbCredsPage.Values[0] := 'shopdb'; - - // How the application is published. Offered only when the bundle actually - // carries a subpath SPA build - Vite compiles the base path in, so this can - // never be a pure runtime switch. - DeployPage := CreateInputOptionPage(DbCredsPage.ID, - 'Address', 'How should people reach ShopDB-Flask?', - 'Both options serve the same application. The second needs no new DNS name ' - + 'and no port number, because it rides this server''s existing address.', - True, False); - DeployPage.Add('Its own address, on a port - http://:8090/'); - DeployPage.Add('Under this server''s existing address - http:///{#SubpathAlias}/'); - DeployPage.SelectedValueIndex := 0; - - SitePage := CreateInputQueryPage(DeployPage.ID, - 'Web site', 'How the site is published', - 'The host name is used for CORS. It must be the name operators actually ' + - 'type in the browser, or the page will load but its data requests will fail.'); - SitePage.Add('Host name:', False); - SitePage.Add('Port:', False); - // FQDN, not the NetBIOS name: this value becomes CORS_ORIGINS, and a browser - // arriving at the fully qualified address would be refused by a bare hostname. - Domain := ''; - RegQueryStringValue(HKEY_LOCAL_MACHINE, - 'SYSTEM\CurrentControlSet\Services\Tcpip\Parameters', 'Domain', Domain); - if Domain <> '' then - SitePage.Values[0] := GetComputerNameString + '.' + Domain - else - SitePage.Values[0] := GetComputerNameString; - SitePage.Values[1] := '8090'; - - // Where the real client IP comes from. Not cosmetic: IIS sends no - // X-Forwarded-For of its own, so with neither option applied every request - // reads as 127.0.0.1 and the GE-Enforce IP allowlist, the dashboard's - // visitor-location lookup and per-host login rate limiting all fail silently. - // - // The two answers are mutually exclusive, and picking the wrong one is worse - // than picking neither: the rule OVERWRITES the header with REMOTE_ADDR, which - // is exactly right when IIS faces clients (it defeats spoofing) and exactly - // wrong behind a proxy (REMOTE_ADDR is the proxy, so the real client IP is - // discarded). Hence a question rather than a default. - ClientIpPage := CreateInputOptionPage(SitePage.ID, - 'Client addresses', 'Does anything sit between your users and this server?', - 'ShopDB records the address of every request, and some features decide what ' - + 'to show based on it. If you are not sure, choose the first option - it is ' - + 'correct for a server users reach directly.', - True, False); - ClientIpPage.Add('No - users reach this server directly (installs URL Rewrite)'); - // Phrased as "already adds the visitor's address" rather than "is a proxy": - // the operator can check that with their network team, whereas "is there a - // proxy" invites a guess, and guessing wrong here silently discards the real - // client address on every request. - ClientIpPage.Add('Yes - a load balancer or gateway that already adds the visitor''s address'); - ClientIpPage.SelectedValueIndex := 0; -end; - -// 'direct' installs URL Rewrite from the bundle and enables the X-Forwarded-For -// rule; 'proxy' leaves both alone because the proxy already sets the header. -function ClientIpSourceArg: String; -begin - if ClientIpPage.SelectedValueIndex = 1 then - Result := 'proxy' - else - Result := 'direct'; -end; - -// Full path to the 64-bit PowerShell. -// -// Setup is a 32-bit process, so 'powershell.exe' resolves through WOW64 to the -// 32-bit SysWOW64 build, which CANNOT instantiate IIS's 64-bit COM objects: -// "Retrieving the COM class factory for component with CLSID ... 80040144" -// from Get-Website, while the identical script run from a normal shell is fine. -// 'Sysnative' is the alias that lets a 32-bit process reach the real System32; -// it exists ONLY for 32-bit processes, hence the IsWin64 guard. -// -// Inno has ExecAndCaptureOutputWithNativeSysDir for this, but there is no -// ExecAndLogOutput equivalent, so resolving the path ourselves keeps both call -// sites on one mechanism instead of two that can drift apart. -function PowerShellPath: String; -begin - if IsWin64 then - Result := ExpandConstant('{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe') - else - Result := ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'); -end; - -// Streamed line by line from ExecAndLogOutput, so the operator sees the install -// happening instead of a frozen wizard. Stage 2 alone installs 47 wheels and can -// run for minutes with nothing on screen. -procedure OnStageLog(const S: String; const Error, FirstLine: Boolean); -var - Line: String; -begin - // Everything goes to the setup log verbatim - that is the diagnostic record. - Log('[shopdb] ' + S); - if Error then Exit; - Line := Trim(S); - if Line = '' then Exit; - - // Recognise the failures an operator can actually act on, and translate them - // into plain language. Matching on the SYMPTOM text (not an exit code) is what - // lets the wizard say "wrong password" instead of "exit 1". - if Pos('Access denied for user', Line) > 0 then - begin - FailCause := 'The database rejected the username or password.'; - FailDetail := 'Go back to the Database page and re-enter them. Check for ' - + 'capital letters - the password is case-sensitive.'; - end - else if Pos('Unknown database', Line) > 0 then - begin - FailCause := 'That database does not exist on the server.'; - FailDetail := 'Your DBA must create it before installing, along with the ' - + 'application user that owns it.'; - end - else if (Pos('Can''t connect to MySQL server', Line) > 0) or (Pos('timed out', Line) > 0) then - begin - FailCause := 'The database server did not answer.'; - FailDetail := 'Check the host name and port on the Database page, and that ' - + 'MySQL is running and reachable from this server.'; - end - else if Pos('index flags', Line) > 0 then - begin - FailCause := 'This MySQL 5.6 server is missing three required settings.'; - FailDetail := 'innodb_file_per_table, innodb_file_format=Barracuda and ' - + 'innodb_large_prefix must be set, then MySQL restarted.'; - end - else if Pos('does not represent a valid object', Line) > 0 then - begin - FailCause := 'IIS refused to create the site or application pool.'; - FailDetail := 'This usually means a previous ShopDB site or pool is in a ' - + 'half-removed state. Run IISRESET and try again.'; - end; - - // The status caption is a DIFFERENT job: it tells an operator what is happening - // right now. Raw log lines are useless for that - "exec python.exe (7 args)" - // says nothing, and the argument COUNT is only there because argument VALUES - // must never be logged (they can carry a password). - // So: translate the few lines that mark real progress, and ignore the rest. - if Pos('exec ', Line) > 0 then Exit; - - if Pos('STAGE 0', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Installing the bundled MySQL database...' - else if Pos('STAGE 2', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Installing Python and the application...' - else if Pos('wheelhouse', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Installing dependencies (offline)...' - else if Pos('STAGE 3', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Preparing the database...' - else if Pos('db upgrade', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Creating the database schema...' - else if Pos('seed ', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Adding reference data...' - else if Pos('apply-profile', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Installing the plugins this site uses...' - else if Pos('upgrade-all', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Applying plugin migrations...' - else if Pos('prune-schema', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Removing unused plugin tables...' - else if Pos('STAGE 4', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Configuring IIS...' - else if Pos('STAGE 5', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Checking the site responds...' - else if Pos('[FAIL]', Line) > 0 then - WizardForm.StatusLabel.Caption := 'Failed - see the message that follows'; -end; - -function RunPowerShell(const ScriptArgs: String; var ResultCode: Integer): Boolean; -var - Cmd: String; -begin - Cmd := '-NoProfile -ExecutionPolicy Bypass -File "' + - ExpandConstant('{tmp}\shopdb-bundle\shopdb-install.ps1') + '" ' + ScriptArgs; - // ExecAndLogOutput streams output through OnStageLog as it is produced, and - // also writes it into the setup log - so a failed install leaves a full trace - // without the operator having to find the PowerShell log in TEMP. - // 64-bit PowerShell: stage 4 configures IIS, which the 32-bit build cannot do. - Result := ExecAndLogOutput(PowerShellPath, Cmd, '', SW_HIDE, - ewWaitUntilTerminated, ResultCode, @OnStageLog); -end; - -// One result row: coloured dot, bold title, wrapped detail. Returns the Y for -// the next row so the caller does not have to guess heights. -// -// Status marker. Four approaches failed before this one, all recorded so nobody -// re-treads them: -// - PNG into TBitmapImage.Bitmap -> "Bitmap image is not valid" (BMP only). -// - Chr(9679) for U+25CF -> renders 'I'-diaeresis; Chr takes a BYTE. -// - Wingdings glyph + Font.Color -> draws, but BLACK: a custom VCL style owns -// text painting and ignores Font.Color. -// - TPanel + Color -> also repainted by the style; no colour. -// A stretched BMP is painted verbatim, so the colour always shows. -function AddRow(Y: Integer; const Swatch, Title, Detail: String): Integer; -var - T, D: TNewStaticText; -begin - AddSwatch(DetailPanel, ScaleX(3), Y + ScaleY(4), ScaleX(10), ScaleY(10), Swatch); - - T := TNewStaticText.Create(WizardForm); - T.Parent := DetailPanel; - T.Left := ScaleX(22); - T.Top := Y; - T.Width := DetailPanel.Width - ScaleX(24); - T.Font.Style := [fsBold]; - T.Caption := Title; - - D := TNewStaticText.Create(WizardForm); - D.Parent := DetailPanel; - D.Left := ScaleX(22); - D.Top := Y + ScaleY(15); - D.Width := DetailPanel.Width - ScaleX(24); - D.AutoSize := False; - D.WordWrap := True; - D.Height := ScaleY(28); - D.Font.Color := clGrayText; - D.Caption := Detail; - - Result := Y + ScaleY(46) + ScaleY(ROW_GAP); -end; - -// Turn the delimited records into a verdict plus rows. Passing checks are NEVER -// listed: 10 green lines bury the one amber line that actually needs reading. -// If nothing is wrong the page collapses to a single reassuring statement. -procedure RenderPreflight(Lines: TArrayOfString; var HasBlockers: Boolean); -var - I, Y, Shown, Fails, Warns, Passes, RowHeight, MaxRows, Hidden: Integer; - Parts: TArrayOfString; - Check, Detail, Fix: String; -begin - Fails := 0; Warns := 0; Passes := 0; - Y := ScaleY(4); - Shown := 0; - - // How many rows actually FIT, measured, not guessed. The old cap of 6 bore no - // relation to the panel, which holds roughly three: at four or five notes the - // rows below were drawn past the bottom edge and silently vanished - taking - // with them the warning that the bundled MySQL collides on port 3306, which is - // exactly what the operator needs before the very next page. - RowHeight := ScaleY(46) + ScaleY(ROW_GAP); - if RowHeight < 1 then RowHeight := 1; - MaxRows := DetailPanel.Height div RowHeight; - if MaxRows < 1 then MaxRows := 1; - - // Blockers first, then warnings - a second pass rather than one, so severity - // ordering does not depend on the order the checks happen to run in. - for I := 0 to GetArrayLength(Lines) - 1 do - begin - Parts := StringSplit(Lines[I], ['|'], stAll); - if GetArrayLength(Parts) < 4 then Continue; - if Parts[0] = 'FAIL' then Fails := Fails + 1 - else if Parts[0] = 'WARN' then Warns := Warns + 1 - else if Parts[0] = 'PASS' then Passes := Passes + 1; - end; - - for I := 0 to GetArrayLength(Lines) - 1 do - begin - // Blockers are capped too. Without this the FAIL loop drew every failure, - // however many, straight off the bottom of the panel. - if Shown >= MaxRows then Break; - Parts := StringSplit(Lines[I], ['|'], stAll); - if GetArrayLength(Parts) < 4 then Continue; - if Parts[0] <> 'FAIL' then Continue; - // Qualify the check with its area: "Service" and "Port 3306" on their own - // say nothing about WHAT service or whose port. - Check := Parts[1] + ' - ' + Parts[2]; Detail := Parts[3]; - if GetArrayLength(Parts) > 4 then Fix := Parts[4] else Fix := ''; - if Fix <> '' then Detail := Detail + ' - ' + Fix; - Y := AddRow(Y, 'swatch-bad.bmp', Check, Detail); - Shown := Shown + 1; - end; - - for I := 0 to GetArrayLength(Lines) - 1 do - begin - if Shown >= MaxRows then Break; - Parts := StringSplit(Lines[I], ['|'], stAll); - if GetArrayLength(Parts) < 4 then Continue; - if Parts[0] <> 'WARN' then Continue; - Check := Parts[1] + ' - ' + Parts[2]; Detail := Parts[3]; - if GetArrayLength(Parts) > 4 then Fix := Parts[4] else Fix := ''; - if Fix <> '' then Detail := Detail + ' - ' + Fix; - Y := AddRow(Y, 'swatch-warn.bmp', Check, Detail); - Shown := Shown + 1; - end; - - HasBlockers := Fails > 0; - - if Fails > 0 then - begin - SetSwatch(BannerBar, 'swatch-bad.bmp'); - BannerText.Caption := 'This server is not ready'; - BannerSub.Caption := IntToStr(Fails) + ' problem(s) must be fixed before installing.'; - end - else if Warns > 0 then - begin - SetSwatch(BannerBar, 'swatch-warn.bmp'); - BannerText.Caption := 'Ready, with notes'; - BannerSub.Caption := 'Installation can continue. ' + IntToStr(Warns) + - ' item(s) below are worth reading first.'; - end - else - begin - SetSwatch(BannerBar, 'swatch-ok.bmp'); - BannerText.Caption := 'This server is ready'; - BannerSub.Caption := 'Everything needed for ShopDB-Flask is in place.'; - // Nothing to list, so say so rather than leaving an empty white box. - AddRow(ScaleY(4), 'swatch-ok.bmp', 'All checks passed', - 'IIS, HttpPlatformHandler, disk space, ports and the database were all ' - + 'verified. Nothing needs your attention.'); - end; - - // Count what was actually left out rather than inferring it from the old cap, - // which under-reported: with 2 failures and 5 warnings it claimed everything - // was shown while four rows had been dropped. - Hidden := (Fails + Warns) - Shown; - if Hidden > 0 then - FooterText.Caption := IntToStr(Hidden) + ' further item(s) are not shown here. ' - + 'All of them are in the install log.' - else - FooterText.Caption := IntToStr(Passes) + ' checks passed. Nothing has been changed on this server.'; -end; - -// Run the read-only check and paint its results. Called from Next on the welcome -// page, and again whenever the operator fixes something and presses Check again. -// -// Stage 1 changes nothing, so running it before the operator commits to anything -// is free, and it catches the blockers that are expensive later: IIS absent, -// locked config sections, the port in use, MySQL 5.6 missing its index flags. -procedure RunPreflight; -var - I, ResultCode: Integer; - Output: TExecOutput; - PreflightScript, Detail: String; - HasBlockers: Boolean; -begin - // The bundle is NOT extracted yet at wizard time - [Files] is processed during - // the install step. Pull just this one script out of the compressed payload. - ExtractTemporaryFile('shopdb-preflight.ps1'); - PreflightScript := ExpandConstant('{tmp}\shopdb-preflight.ps1'); - - // Visible feedback for the few seconds the check takes. SW_HIDE means the - // operator sees nothing at all otherwise, and a frozen wizard reads as a - // crash. ProgressBar.Style := npbstMarquee because the duration is unknown - - // a bar that sits at 0% is worse than no bar. - CheckingPage.SetText('Looking at IIS, disk, ports and the database...', ''); - CheckingPage.SetProgress(0, 0); - CheckingPage.ProgressBar.Style := npbstMarquee; - CheckingPage.Show; - try - // ExecAndCaptureOutput (6.4.0+) hands back stdout and stderr as string arrays, - // so output never touches disk. That deletes BOTH bugs this page shipped with: - // - Exec() has no shell, so "> file" went to PowerShell as a literal - // argument and no file was ever written (page came up empty); - // - PowerShell 5.1 writes UTF-16LE, and LoadStringFromFile reads bytes as - // ANSI, so the page rendered as garbage with a null between characters. - // Neither failure mode can recur now: there is no file and no encoding step. - // 64-bit PowerShell - see PowerShellPath. The IIS checks fail without it. - ExecAndCaptureOutput(PowerShellPath, - '-NoProfile -ExecutionPolicy Bypass -File "' + PreflightScript + '" -Delimited', - '', SW_HIDE, ewWaitUntilTerminated, ResultCode, Output); - - // Every run repaints from scratch. The old code rendered ONCE and latched, - // so a re-check could not have shown a different answer even if the operator - // had fixed everything. - while DetailPanel.ControlCount > 0 do - DetailPanel.Controls[0].Free; - - PreflightDone := True; - if (not Output.Error) and (GetArrayLength(Output.StdOut) > 0) then - begin - RenderPreflight(Output.StdOut, HasBlockers); - PreflightBlocked := HasBlockers; - end - else - begin - // stderr is captured SEPARATELY, so a failure can report what actually - // went wrong instead of only an exit code. - Detail := ''; - for I := 0 to GetArrayLength(Output.StdErr) - 1 do - Detail := Detail + Output.StdErr[I] + ' '; - if Trim(Detail) = '' then Detail := 'No error output was produced.'; - SetSwatch(BannerBar, 'swatch-warn.bmp'); - BannerText.Caption := 'Could not check this server'; - BannerSub.Caption := 'Nothing has been verified. Continuing is a risk.'; - AddRow(ScaleY(4), 'swatch-warn.bmp', - 'Check did not run (exit ' + IntToStr(ResultCode) + ')', Detail); - FooterText.Caption := 'Nothing has been changed on this server.'; - // A check that could not RUN is not a check that PASSED. It does not block - - // there is no evidence of a problem - but it must not read like a green light. - PreflightBlocked := False; - end; - finally - CheckingPage.Hide; - end; -end; - -procedure RecheckClick(Sender: TObject); -begin - RunPreflight; -end; - -// The port the operator chose, for the [Icons]/[Run] entries. A subpath install -// is reached on the parent site's port, and the console works that out from -// MOUNT_PATH in .env, so the value only has to be right for the own-site case. -function SitePortValue(Param: String): String; -begin - Result := SitePage.Values[1]; - if Trim(Result) = '' then Result := '8090'; -end; - -// Where an EXISTING install would be, for the pre-fill reads. -// -// Not WizardDirValue() alone: these pages are inserted after wpWelcome and so -// run BEFORE the directory page, where that value can still be empty - and an -// empty base silently turned every pre-fill into "no existing install", which -// is why the Address page kept defaulting to 8090 on a /shopdb server. -// Falls back to the compile-time default, which is also the only directory the -// operator could have meant at that point in the wizard. -function InstalledDir: String; -begin - Result := WizardDirValue; - if Trim(Result) = '' then Result := ExpandConstant('{#DefaultDir}'); - Result := AddBackslash(Result); - Log('[shopdb] pre-fill base directory: ' + Result); -end; - -// Everything typed in the wizard ends up on a command line that -// CommandLineToArgvW parses, and reaches PowerShell parameters typed [int] or -// [string]. A blank, a space, or a trailing backslash therefore has to be caught -// HERE - by the time the script sees it, the arguments have already shifted. - -function IsValidPort(const S: String): Boolean; -var - I, N: Integer; -begin - Result := False; - if (S = '') or (Length(S) > 5) then Exit; - for I := 1 to Length(S) do - if (S[I] < '0') or (S[I] > '9') then Exit; // also rejects spaces and signs - N := StrToIntDef(S, -1); - Result := (N >= 1) and (N <= 65535); -end; - -function IsAsciiOnly(const S: String): Boolean; -var - I: Integer; -begin - Result := True; - for I := 1 to Length(S) do - if Ord(S[I]) > 126 then - begin - Result := False; - Exit; - end; -end; - -// A path that ends in a backslash - a drive root such as D:\ - closes the -// argument with \" , which CommandLineToArgvW reads as an ESCAPED quote. The -// argument never terminates and every argument after it shifts one place. -// Doubling the backslash inside the quotes is the documented way out. -function QuotePathArg(const P: String): String; -begin - if (P <> '') and (P[Length(P)] = '\') then - Result := '"' + P + '\' + '"' - else - Result := '"' + P + '"'; -end; - -function NextButtonClick(CurPageID: Integer): Boolean; -begin - Result := True; - - // A drive root is refused outright rather than quoted around. Uninstall - // removes the application directory recursively, so accepting D:\ here would - // mean uninstalling ShopDB-Flask wipes the whole drive. - if CurPageID = wpSelectDir then - begin - if Length(WizardDirValue) <= 3 then - begin - MsgBox('Choose a folder rather than a whole drive.' + #13#10#13#10 - + 'Removing ShopDB-Flask deletes the folder it was installed into, ' - + 'so installing to a drive root would delete everything on that ' - + 'drive when it is uninstalled.', mbError, MB_OK); - Result := False; - end; - end; - - if CurPageID = SitePage.ID then - begin - if not IsValidPort(SitePage.Values[1]) then - begin - MsgBox('Enter the port as a number between 1 and 65535.', mbError, MB_OK); - Result := False; - end; - end; - - if CurPageID = wpWelcome then - RunPreflight; - - // The results page BLOCKS while anything is failing. It used to be advisory: - // the operator read "IIS is not installed", pressed Next, answered five more - // pages, and the install then died partway through with Python already on the - // box. Refusing here costs them nothing - the server is untouched at this - // point - and the page already says what to do about each failure. - if CurPageID = PreflightPage.ID then - begin - if PreflightBlocked then - begin - MsgBox('This server is not ready yet.' + #13#10#13#10 - + 'Each problem above says what to do about it. Fix them, then choose ' - + '"Check again".' + #13#10#13#10 - + 'Nothing has been changed on this server.', mbError, MB_OK); - Result := False; - end; - end; - - if CurPageID = DbChoicePage.ID then - UseBundledDb := (DbChoicePage.SelectedValueIndex = DB_BUNDLED); - - if CurPageID = DbDetailsPage.ID then - begin - if not UseBundledDb then - begin - if DbDetailsPage.Values[0] = '' then - begin - MsgBox('Enter the database host.', mbError, MB_OK); - Result := False; - end - else if not IsValidPort(DbDetailsPage.Values[1]) then - begin - MsgBox('Enter the database port as a number between 1 and 65535.' - + #13#10#13#10 + 'MySQL uses 3306 unless it was changed.', - mbError, MB_OK); - Result := False; - end; - end; - end; - - if CurPageID = DbCredsPage.ID then - begin - if not UseBundledDb then - begin - if DbCredsPage.Values[0] = '' then - begin - MsgBox('Enter the username the application connects with.', mbError, MB_OK); - Result := False; - end - // Blank is allowed when the password is already ON DISK in a form the - // installer can read: .env from a previous install, or the ACL'd .dbpass - // handoff stage 0 leaves when it created the database itself. Stage 2 - // consumes .dbpass on its own, so demanding the password here blocked the - // operator on something the installer already had - and sent them looking - // for a generated secret they were never meant to handle. - else if (DbCredsPage.Values[1] = '') and - (not FileExists(InstalledDir + '.env')) and - (not FileExists(InstalledDir + '.dbpass')) then - begin - // Only required on a FRESH install. On an upgrade, blank means "keep the - // password already in .env", so the operator never has to know it. - MsgBox('Enter the password for the application database user.', mbError, MB_OK); - Result := False; - end; - end; - end; -end; - -// Pre-tick the boxes the first time the page appears. On an upgrade the existing -// site-profile.json is already on disk, so the page opens showing exactly what -// this server has today - and ticking another box adds it. -procedure CurPageChanged(CurPageID: Integer); -var - I, Count: Integer; - Existing, Creds, Rest: String; - Lines: TArrayOfString; -begin - // Pre-fill the database page from the EXISTING .env on an upgrade. Its - // defaults are 127.0.0.1 / shopdb_flask, so a site whose database lives on - // another server would otherwise have its real connection string overwritten - // by defaults the operator never looked at - and .env was the only record. - // Pre-select the method this server already uses. Without this, an upgrade of - // a /shopdb install would sit on the default and create a SECOND deployment - - // a new site on 8090 beside the existing Application, same directory, wrong - // SPA build, MOUNT_PATH still set. Read it from .env, which is the same value - // wsgi.py mounts on. - if (CurPageID = DeployPage.ID) and (not DeployPageReady) then - begin - DeployPageReady := True; - if LoadStringsFromFile(InstalledDir + '.env', Lines) then - for I := 0 to GetArrayLength(Lines) - 1 do - if Pos('MOUNT_PATH=', Lines[I]) = 1 then - begin - Log('[shopdb] existing install is a subpath deployment: ' + Lines[I]); - DeployPage.SelectedValueIndex := 1; - DeployPage.SubCaptionLabel.Caption := - 'This server is currently published under its existing address. ' - + 'Changing this will move where people reach ShopDB-Flask.'; - end; - end; - - if (CurPageID = DbDetailsPage.ID) and (not DbPageReady) then - begin - DbPageReady := True; - // A stage-0 handoff means the database was created by a previous run of this - // installer and the password is already on disk. Nobody should be asked to - // find or retype a secret the installer generated. - if FileExists(InstalledDir + '.dbpass') then - DbCredsPage.SubCaptionLabel.Caption := - 'This server already has a database created by this installer. Leave the ' - + 'password blank and it will be used automatically.'; - if LoadStringsFromFile(InstalledDir + '.env', Lines) then - for I := 0 to GetArrayLength(Lines) - 1 do - if Pos('DATABASE_URL=', Lines[I]) = 1 then - begin - // mysql+pymysql://USER:PASS@HOST:PORT/NAME?charset=... - Existing := Copy(Lines[I], Pos('//', Lines[I]) + 2, Length(Lines[I])); - if Pos('@', Existing) > 0 then - begin - Creds := Copy(Existing, 1, Pos('@', Existing) - 1); - Rest := Copy(Existing, Pos('@', Existing) + 1, Length(Existing)); - if Pos(':', Creds) > 0 then - DbCredsPage.Values[0] := Copy(Creds, 1, Pos(':', Creds) - 1); - if Pos(':', Rest) > 0 then - begin - DbDetailsPage.Values[0] := Copy(Rest, 1, Pos(':', Rest) - 1); - Rest := Copy(Rest, Pos(':', Rest) + 1, Length(Rest)); - if Pos('/', Rest) > 0 then - begin - DbDetailsPage.Values[1] := Copy(Rest, 1, Pos('/', Rest) - 1); - Rest := Copy(Rest, Pos('/', Rest) + 1, Length(Rest)); - if Pos('?', Rest) > 0 then Rest := Copy(Rest, 1, Pos('?', Rest) - 1); - DbDetailsPage.Values[2] := Rest; - end; - end; - end; - // Password intentionally left blank: blank means "keep the current - // one", so an upgrade never needs the operator to know it. - DbCredsPage.Values[1] := ''; - DbDetailsPage.SubCaptionLabel.Caption := - 'These are the settings this server is using now.'; - // The hint belongs where the password field actually is - it moved to - // the next page when this one was split. - DbCredsPage.SubCaptionLabel.Caption := - 'Leave the password blank to keep the one this server already uses.'; - end; - end; - - if (CurPageID = PluginPage.ID) and (not PluginPageReady) then - begin - PluginPageReady := True; - Existing := ''; - // Read instance\plugins.json - the PLUGIN REGISTRY, which is what is actually - // installed. site-profile.json only records the last SELECTION, and the two - // drift: unticking a plugin shrinks the profile but does NOT uninstall it - // (apply-profile never removes), so the profile would show a plugin as absent - // while its tables and data are still on the server. - // - // NOT ExpandConstant('{app}') here: this page runs BEFORE the directory page - // and {app} is not initialised yet - expanding it raises "attempt was made to - // expand the app constant before it was initialized". WizardDirValue() is the - // chosen directory and is safe at any point. - if LoadStringsFromFile(InstalledDir + 'instance\plugins.json', Lines) then - for I := 0 to GetArrayLength(Lines) - 1 do Existing := Existing + Lines[I] - else if LoadStringsFromFile(InstalledDir + 'site-profile.json', Lines) then - // Fallback for an install predating the registry, or a partial install. - for I := 0 to GetArrayLength(Lines) - 1 do Existing := Existing + Lines[I]; - - Count := GetArrayLength(PluginNames); - if PluginPage.CheckListBox.Items.Count < Count then - Count := PluginPage.CheckListBox.Items.Count; - for I := 0 to Count - 1 do - if Existing <> '' then - // Crude but sufficient: the profile lists plugins as quoted strings. - PluginPage.Values[I] := (Pos('"' + PluginNames[I] + '"', Existing) > 0) - else - PluginPage.Values[I] := PluginDefault(PluginNames[I]); - end; -end; - -function UsingSubpath: Boolean; -begin - Result := ('{#SubpathAlias}' <> '') and (DeployPage.SelectedValueIndex = 1); -end; - -function ShouldSkipPage(PageID: Integer): Boolean; -begin - // A bundle without a subpath build cannot offer the choice at all. - if (PageID = DeployPage.ID) and ('{#SubpathAlias}' = '') then - begin - Result := True; - Exit; - end; - // The bundled path generates its own credentials, so asking for them would be - // meaningless - and any value typed here would be silently ignored. - Result := ((PageID = DbDetailsPage.ID) or (PageID = DbCredsPage.ID)) and UseBundledDb; -end; - -// Runs from ssPostInstall, NOT PrepareToInstall. -// -// PrepareToInstall fires BEFORE the [Files] section is processed, so -// {tmp}\shopdb-bundle does not exist yet and powershell.exe fails on a missing -// -File path (exit -196608 / 0xFFFD0000). Everything here depends on the -// extracted bundle, so it has to run after the install step. - -// Set the "Run as administrator" bit on a .lnk (byte 21, flag 0x20). -// -// The console needs Administrator to read IIS state. The script self-elevates, -// but that spawns a SECOND window after a UAC prompt, which is easy to miss and -// looks broken. Marking the shortcut makes Windows elevate up front: one window, -// one prompt. -// -// Inno cannot set this flag, and its file helpers are string-based rather than -// binary safe, so use PowerShell - already a dependency here. -procedure MarkShortcutRunAs(const LnkPath: String); -var - ResultCode: Integer; - Cmd: String; -begin - if not FileExists(LnkPath) then Exit; - // THREE quotes each side, not four. Four is an escaped quote twice over, which - // keeps the whole expression inside one Pascal literal: LnkPath was never - // interpolated, PowerShell got the bare word "LnkPath", failed to parse, and - // no shortcut ever received the elevation flag. Self-elevation hid it - the - // only symptom was a second window after an extra prompt. - Cmd := '-NoProfile -ExecutionPolicy Bypass -Command "' - + '$p=''' + LnkPath + '''; ' - + '$b=[IO.File]::ReadAllBytes($p); ' - + '$b[21]=$b[21] -bor 0x20; ' - + '[IO.File]::WriteAllBytes($p,$b)"'; - Exec(PowerShellPath, Cmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode); - if ResultCode <> 0 then - Log('[shopdb] could not mark ' + LnkPath + ' run-as-administrator (exit ' - + IntToStr(ResultCode) + '); the console will self-elevate instead'); -end; - -function RunInstallStages: String; -var - ResultCode: Integer; - PwFile, Args, Common: String; - PwLines: TArrayOfString; -begin - Result := ''; - Common := '-BundleRoot ' + QuotePathArg(ExpandConstant('{tmp}\shopdb-bundle')) + - ' -AppRoot ' + QuotePathArg(ExpandConstant('{app}')) + - ' -SiteHost "' + SitePage.Values[0] + '"' + - ' -SitePort ' + SitePage.Values[1] + - ' -OnFailure never' + - ' -ClientIpSource ' + ClientIpSourceArg + - ' -SitePlugins "' + SelectedPlugins + '"'; - if CatalogPage.Values[0] then - Common := Common + ' -SeedCatalog'; - - // Subpath deployment: an IIS Application under the existing site instead of a - // site of its own. Appended here because Pascal has no conditional expression. - if UsingSubpath then - Common := Common + ' -MountAlias "{#SubpathAlias}"'; - - if UseBundledDb then - begin - if not RunPowerShell('-Stage 0 ' + Common, ResultCode) or (ResultCode <> 0) then - begin - if FailCause <> '' then - Result := FailCause + #13#10#13#10 + FailDetail - else - Result := 'The bundled MySQL database could not be installed (exit ' - + IntToStr(ResultCode) + ').'; - Result := Result + #13#10#13#10 + 'Full details are in:' + #13#10 - + ExpandConstant('{log}'); - Exit; - end; - // Stage 0 leaves an ACL'd handoff file that stage 2 picks up, so no password - // is passed here or anywhere else. - Args := Common; - end - else - begin - // The password goes via an ACL'd FILE, never on the command line: command - // lines are readable by any user through Win32_Process and are captured in - // PowerShell transcripts. The installer shreds the file after reading it. - // No password typed on an upgrade means "leave .env alone" - so send no - // password file, and the installer keeps the existing DATABASE_URL. - if DbCredsPage.Values[1] <> '' then - begin - PwFile := ExpandConstant('{tmp}\dbpw.txt'); - // UTF-8, no BOM. SaveStringToFile writes an AnsiString, but the installer - // reads this back with -Encoding UTF8 - so any non-ASCII character in the - // password came out mangled and the database rejected a password that was - // typed correctly, reported as "wrong username or password". - SetArrayLength(PwLines, 1); - PwLines[0] := DbCredsPage.Values[1]; - SaveStringsToUTF8FileWithoutBOM(PwFile, PwLines, False); - end - else - PwFile := ''; - Args := Common + - ' -DbHost "' + DbDetailsPage.Values[0] + '"' + - ' -DbPort ' + DbDetailsPage.Values[1] + - ' -DbName "' + DbDetailsPage.Values[2] + '"' + - ' -DbUser "' + DbCredsPage.Values[0] + '"'; - if PwFile <> '' then Args := Args + ' -DbPasswordFile "' + PwFile + '"'; - end; - - if not RunPowerShell('-Stage all ' + Args, ResultCode) or (ResultCode <> 0) then - begin - if FailCause <> '' then - Result := FailCause + #13#10#13#10 + FailDetail - else - Result := 'The installation could not be completed (exit ' - + IntToStr(ResultCode) + ').'; - // NOT "nothing was left running". The stages run with -OnFailure never, so - // nothing is rolled back: whatever had been created by the failing point is - // still there. Claiming otherwise sent operators away believing the server - // was clean when it was half-configured, and the next thing they did was - // install again on top of it. - Result := Result + #13#10#13#10 - + 'This server has been part-configured. Whatever had been done ' - + 'before the failure is still in place.' + #13#10#13#10 - + 'What to do:' + #13#10 - + ' - Fix the cause above, then run this installer again. Re-running ' - + 'is safe and picks up where it left off.' + #13#10 - + ' - Or remove it entirely from Settings > Apps.' + #13#10#13#10 - + 'Full details, including everything that was created, are in:' + #13#10 - + ExpandConstant('{log}'); - end; -end; - -procedure CurStepChanged(CurStep: TSetupStep); -var - Failure, FinalUrl: String; -begin - if CurStep = ssPostInstall then - begin - if UsingSubpath then - FinalUrl := 'http://' + SitePage.Values[0] + '/{#SubpathAlias}/login' - else - FinalUrl := 'http://' + SitePage.Values[0] + ':' + SitePage.Values[1] + '/login'; - - Failure := RunInstallStages; - - // After the stages: the shortcuts exist by now, and this is cosmetic enough - // that it must never be able to fail the install. - MarkShortcutRunAs(ExpandConstant('{group}\ShopDB-Flask Console.lnk')); - MarkShortcutRunAs(ExpandConstant('{group}\Restart ShopDB-Flask.lnk')); - MarkShortcutRunAs(ExpandConstant('{group}\Back up the database.lnk')); - // NOTE: never start a line with #13#10. The Inno PREPROCESSOR treats any line - // whose first non-blank character is '#' as a directive and fails with - // "Unknown preprocessor directive" before Pascal parsing happens. Keep the - // concatenation operator at the start of continuation lines instead. - if Failure <> '' then - begin - MsgBox(Failure, mbCriticalError, MB_OK); - // The final page is headed "ShopDB-Flask is ready" from [Messages]. After a - // failed install that is the last thing the operator reads, and it - // contradicts the error box they just dismissed. Setup cannot be made to - // fail from here, so at least stop it claiming success. - WizardForm.FinishedHeadingLabel.Caption := 'ShopDB-Flask is NOT installed'; - WizardForm.FinishedLabel.Caption := - 'The installation did not complete. This server has been part-configured.' - + #13#10#13#10 - + 'Fix the problem reported above and run this installer again - re-running ' - + 'is safe. Or remove it from Settings > Apps.' - + #13#10#13#10 - + 'The log is at ' + ExpandConstant('{log}'); - end - else - begin - // Recorded so the Start Menu shortcut and the console open the address this - // install actually serves, rather than a compile-time guess. - SaveStringToFile(ExpandConstant('{app}\.installed-url'), FinalUrl, False); - MsgBox('ShopDB-Flask is installed.' + #13#10#13#10 - + 'Open ' + FinalUrl - + #13#10#13#10 - + 'With no user in the database that page offers to create the first ' - + 'administrator and then runs the setup wizard.' - + #13#10#13#10 - + 'Day-to-day: use the ShopDB-Flask Console in the Start Menu, or read ' - + ExpandConstant('{app}\docs\OPERATE-WINDOWS.md'), - mbInformation, MB_OK); - end; - end; -end; - -[UninstallRun] -; Delegate to the same tested script rather than duplicating removal logic here. -; It removes the site, app pool, firewall rule and application directory, and -; deliberately does NOT drop the database or uninstall MySQL. -; -; Sysnative, for the same reason as the [Run] entry above: the uninstaller is a -; 32-bit process, so a bare "powershell.exe" is resolved through WOW64 to the -; 32-bit PowerShell, which cannot see the IIS provider at all. Removal then -; skipped the site, application pool and application - leaving them pointing at -; a directory it HAD deleted - while Windows reported a clean uninstall. -Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-install.ps1"" -Stage uninstall -BundleRoot ""{app}"" -AppRoot ""{app}"" -OnFailure never"; \ - RunOnceId: "ShopDBFlaskUninstall"; Flags: waituntilterminated runhidden diff --git a/deploy/windows/installer/build-installer.ps1 b/deploy/windows/installer/build-installer.ps1 deleted file mode 100644 index bba59a24..00000000 --- a/deploy/windows/installer/build-installer.ps1 +++ /dev/null @@ -1,301 +0,0 @@ -<# -.SYNOPSIS - Stage a lean per-site installer bundle on Windows, and verify its third-party - payload against bundle-lock.json. - -.DESCRIPTION - The Windows equivalent of build-installer.sh, for a work PC with no Bash. It - does the whole job natively - it does NOT shell out to build-site.sh - so the - only tools needed are Python, Node and (to compile) Inno Setup. - - What it does, in order: - - 1. Resolves the site's plugin closure from its profile. - 2. Builds the SPA twice, because Vite compiles the base path in and it - therefore cannot be chosen at install time: once for / (subpath - deployment) and once for / (its own site). - 3. Stages the backend tree: core, the chosen plugins only, and the runtime - files a deployable tree needs. - 4. Writes plugins.iss so the wizard's plugin page matches the payload. - 5. Copies the installer scripts from THIS directory. - 6. Verifies wheels\, python\ and the MSIs against bundle-lock.json, and - FAILS if the bundle is not exactly what the lock describes. - - Step 6 is the point of the script. A bundle that does not match its lock is - not shipped, and "the wheelhouse was missing" is caught here rather than by - an operator halfway through installing on a server with no network. - -.PARAMETER Profile - Path to the site profile (see deploy\site-profile.example.json). - -.PARAMETER RepoRoot - The repository. Defaults to four levels up from this script, which is correct - for a normal checkout. - -.PARAMETER SkipFrontend - Reuse the SPA builds already staged in the bundle. For iterating on the - installer itself, where two Vite builds per run is most of the wall clock. - Never use it for a bundle you intend to ship. - -.PARAMETER AllowUnlocked - Stage the bundle and report payload problems WITHOUT failing. For assembling - a bundle before its lock exists. A bundle built this way must not be shipped; - run refresh-bundle-lock.ps1, commit the lock, then build again without this. - -.EXAMPLE - .\build-installer.ps1 -Profile ..\..\site-profile.example.json - .\build-installer.ps1 -Profile C:\sites\wjf.json -SkipFrontend -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] [string] $Profile, - [string] $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path, - [string] $BundleRoot = (Join-Path $PSScriptRoot 'bundle'), - [string] $SubpathAlias = 'shopdb', - [switch] $SkipFrontend, - [switch] $AllowUnlocked -) - -$ErrorActionPreference = 'Stop' -. (Join-Path $PSScriptRoot 'bundle-lock.ps1') - -function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c } -function Step { param($m) Write-Host ''; Write-Host "==> $m" -ForegroundColor Cyan } -function Die { param($m, $fix = '') Write-Host ''; Write-Host " $m" -ForegroundColor Red - if ($fix) { Write-Host " $fix" -ForegroundColor Yellow }; exit 1 } - -function Invoke-Tool { - # Runs a build tool and stops on a non-zero exit. npm writes progress to - # stderr on SUCCESS, so stderr alone must never be treated as failure. - param([string] $Exe, [string[]] $Arguments, [string] $WorkDir, [string] $What) - Push-Location $WorkDir - try { - $prev = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - & $Exe @Arguments 2>&1 | ForEach-Object { Say " $_" 'DarkGray' } - $code = $LASTEXITCODE - $ErrorActionPreference = $prev - if ($code -ne 0) { Die "$What failed (exit $code)" } - } finally { Pop-Location } -} - -if (-not (Test-Path $Profile)) { Die "profile not found: $Profile" } -if (-not (Test-Path $RepoRoot)) { Die "repo not found: $RepoRoot" } -$Profile = (Resolve-Path $Profile).Path -$RepoRoot = (Resolve-Path $RepoRoot).Path -$AppOut = Join-Path $BundleRoot 'app' - -$python = (Get-Command python -ErrorAction SilentlyContinue) -if (-not $python) { $python = Get-Command py -ErrorAction SilentlyContinue } -if (-not $python) { Die 'Python not found on PATH' 'Install Python 3.14 and re-open the shell.' } -$npm = Get-Command npm.cmd -ErrorAction SilentlyContinue -if (-not $npm -and -not $SkipFrontend) { Die 'npm not found on PATH' 'Install Node, or pass -SkipFrontend to reuse the staged SPA.' } - -Say '' -Say " repo : $RepoRoot" -Say " profile : $Profile" -Say " bundle : $BundleRoot" - -# --- 1. plugin closure ------------------------------------------------------ -# Same resolver the Linux builder uses, so both produce the same set from one -# profile instead of two implementations of the closure rules. -Step 'Resolving plugin closure' -$closure = (& $python.Source (Join-Path $RepoRoot 'scripts\resolve_plugin_closure.py') $Profile $RepoRoot) -if ($LASTEXITCODE -ne 0 -or -not $closure) { Die 'could not resolve the plugin closure from the profile' } -$closure = $closure.Trim() -Say " $closure" 'White' - -# --- 2. frontend ------------------------------------------------------------ -$frontend = Join-Path $RepoRoot 'frontend' -$subStaged = Join-Path $BundleRoot 'spa-subpath' -$rootStaged= Join-Path $BundleRoot 'spa-root' - -if ($SkipFrontend) { - if (-not (Test-Path $subStaged) -or -not (Test-Path $rootStaged)) { - Die '-SkipFrontend was passed but no SPA build is staged' 'Run once without it.' - } - Say '' - Say ' Reusing the staged SPA builds (-SkipFrontend). NOT shippable if the frontend changed.' 'Yellow' -} else { - Step "Building the SPA for /$SubpathAlias/" - $env:SITE_PLUGINS = $closure - $env:VITE_BASE_PATH = "/$SubpathAlias/" - try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'subpath frontend build' } - finally { Remove-Item Env:\VITE_BASE_PATH -ErrorAction SilentlyContinue } - Remove-Item $subStaged -Recurse -Force -ErrorAction SilentlyContinue - Copy-Item (Join-Path $frontend 'dist') $subStaged -Recurse -Force - # The alias is fixed at BUILD time; the installer reads this and refuses to - # publish under a different one rather than serving a page that cannot load - # its own assets. - Set-Content -Path (Join-Path $subStaged '.alias') -Value $SubpathAlias -Encoding ASCII - - # Root build LAST, so frontend\dist is left in the state a developer expects. - Step 'Building the SPA for /' - try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'root frontend build' } - finally { Remove-Item Env:\SITE_PLUGINS -ErrorAction SilentlyContinue } - Remove-Item $rootStaged -Recurse -Force -ErrorAction SilentlyContinue - Copy-Item (Join-Path $frontend 'dist') $rootStaged -Recurse -Force -} - -# --- 3. backend tree -------------------------------------------------------- -Step "Staging the application tree" -Remove-Item $AppOut -Recurse -Force -ErrorAction SilentlyContinue -New-Item -ItemType Directory -Path (Join-Path $AppOut 'plugins') -Force | Out-Null - -Copy-Item (Join-Path $RepoRoot 'shopdb') $AppOut -Recurse -Force -foreach ($name in $closure.Split(',')) { - $src = Join-Path $RepoRoot ('plugins\' + $name.Trim()) - if (-not (Test-Path $src)) { Die "plugin in the closure is not on disk: $name" } - Copy-Item $src (Join-Path $AppOut 'plugins') -Recurse -Force -} - -# Runtime files a deployable tree needs beyond the Python packages. Without -# these the tree imports but cannot be run or migrated. -foreach ($f in @('wsgi.py', 'requirements.txt')) { - Copy-Item (Join-Path $RepoRoot $f) $AppOut -Force -} -Copy-Item (Join-Path $RepoRoot 'migrations') $AppOut -Recurse -Force -# ONLY web.config, not all of deploy\. The bundle is staged INSIDE deploy\, so -# copying the whole tree would recurse into its own output, and the rest of -# deploy\ is installer source that has no business on an application server. -# shopdb-install.ps1 reads it from exactly this path. -$cfgSrc = Join-Path $RepoRoot 'deploy\windows\web.config' -if (Test-Path $cfgSrc) { - New-Item -ItemType Directory -Path (Join-Path $AppOut 'deploy\windows') -Force | Out-Null - Copy-Item $cfgSrc (Join-Path $AppOut 'deploy\windows') -Force -} - -# A CycloneDX SBOM of everything this tree depends on, Python and npm together. -# Staged INTO the tree so it installs onto the server with the application: an -# air-gapped site cannot be scanned remotely, so the only way to answer "are we -# exposed to this CVE, and where" is for the answer to be sitting on the box. -Step 'Generating SBOM' -& $python.Source (Join-Path $RepoRoot 'scripts\generate_sbom.py') $RepoRoot ` - -o (Join-Path $AppOut 'sbom.cdx.json') | ForEach-Object { Say " $_" 'White' } -if ($LASTEXITCODE -ne 0) { Die 'SBOM generation failed' } - -# Docs the running site serves, plus the runbooks an air-gapped server has no -# other way to reach. Without openapi.json and llms.txt the self-hosted /api/docs -# page is broken on every installed server. -Step 'Staging docs' -$docsOut = Join-Path $AppOut 'docs' -New-Item -ItemType Directory -Path $docsOut -Force | Out-Null -foreach ($doc in @('openapi.json', 'llms.txt', 'api-inventory.json', - 'INSTALL-WINDOWS.md', 'OPERATE-WINDOWS.md', - 'BACKUP-RESTORE.md', 'UPGRADE.md')) { - $src = Join-Path $RepoRoot ('docs\' + $doc) - if (Test-Path $src) { Copy-Item $src $docsOut -Force; Say " $doc" } -} - -# Stage the profile INTO the tree: `flask plugin apply-profile` at provisioning -# reads the same profile the tree was staged from, so the installed plugin set -# and the shipped plugin code cannot drift. -Copy-Item $Profile (Join-Path $AppOut 'site-profile.json') -Force - -# The installer expects frontend\dist and frontend\dist-subpath. -$feOut = Join-Path $AppOut 'frontend' -New-Item -ItemType Directory -Path $feOut -Force | Out-Null -Copy-Item $rootStaged (Join-Path $feOut 'dist') -Recurse -Force -Copy-Item $subStaged (Join-Path $feOut 'dist-subpath') -Recurse -Force - -Get-ChildItem $AppOut -Recurse -Directory -Filter '__pycache__' -ErrorAction SilentlyContinue | - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -Get-ChildItem $AppOut -Recurse -File -Filter '*.pyc' -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - -# --- 4. plugins.iss --------------------------------------------------------- -Step 'Writing plugins.iss' -$shipped = (Get-ChildItem (Join-Path $AppOut 'plugins') -Directory | Select-Object -ExpandProperty Name) -join ',' -$aliasBuilt = '' -$aliasFile = Join-Path $feOut 'dist-subpath\.alias' -if (Test-Path $aliasFile) { $aliasBuilt = (Get-Content $aliasFile -TotalCount 1).Trim() } -@" -; GENERATED by build-installer.ps1 - do not edit. -; The plugins present in bundle\app\plugins at build time. -#define AvailablePlugins "$shipped" -; The alias the subpath SPA was built for, or empty if this bundle has no -; subpath build - in which case the wizard must not offer that option. -#define SubpathAlias "$aliasBuilt" -"@ | Set-Content -Path (Join-Path $PSScriptRoot 'plugins.iss') -Encoding ASCII -Say " $shipped" 'White' - -# The product version, read from the code rather than restated in the .iss. -Step 'Writing version.iss' -$initText = Get-Content (Join-Path $RepoRoot 'shopdb\__init__.py') -Raw -if ($initText -notmatch "(?m)^__version__\s*=\s*'([^']+)'") { Die 'could not read __version__ from shopdb\__init__.py' } -$appVersion = $Matches[1] -@" -; GENERATED by build-installer.ps1 from shopdb/__init__.py - do not edit. -#define AppVersion "$appVersion" -"@ | Set-Content -Path (Join-Path $PSScriptRoot 'version.iss') -Encoding ASCII -Say " $appVersion" 'White' - -# --- 5. installer scripts --------------------------------------------------- -# From THIS directory, which is the reviewed copy under version control. They -# used to be copied from a downloads folder, so the logic that shipped was not -# the logic that was committed and the build only worked on one machine. -Step 'Copying installer scripts' -foreach ($f in @('shopdb-install.ps1', 'shopdb-preflight.ps1', 'bundle-lock.ps1')) { - $src = Join-Path $PSScriptRoot $f - if (-not (Test-Path $src)) { Die "installer script missing from the repo: $f" } - Copy-Item $src $BundleRoot -Force - Say " $f" -} - -# --- 6. payload verification ------------------------------------------------ -Step 'Verifying the third-party payload against bundle-lock.json' -$lockPath = Join-Path $PSScriptRoot 'bundle-lock.json' -$lock = Read-BundleLock $lockPath -if (-not $lock) { - $msg = "no bundle-lock.json at $lockPath" - if ($AllowUnlocked) { Say " $msg - continuing because -AllowUnlocked was passed" 'Yellow' } - else { - Die $msg @' -Add the wheels and installers to the bundle, then: - .\refresh-bundle-lock.ps1 (review the list) - .\refresh-bundle-lock.ps1 -Yes (write it) -and commit bundle-lock.json. Pass -AllowUnlocked to stage a bundle without one - -it must not be shipped. -'@ - } -} else { - $problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock - if ($problems.Count -eq 0) { - Say (" payload matches the lock ({0}, {1})" -f ` - (Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'Green' - # Ships WITH the bundle: the installer re-checks the payload on the - # target server before running any of it, so tampering between build and - # install is caught too. - Copy-Item $lockPath $BundleRoot -Force - } else { - Say '' - foreach ($p in $problems) { Say " $p" 'Red' } - Say '' - if ($AllowUnlocked) { - Say ' -AllowUnlocked: continuing anyway. DO NOT SHIP this bundle.' 'Yellow' - } else { - Die ("{0} payload problem(s) - the bundle is not what the lock describes" -f $problems.Count) @' -Either the payload is wrong (fix the bundle) or it changed on purpose (run -refresh-bundle-lock.ps1, read the diff, and commit the new lock). -'@ - } - } -} - -# --- summary ---------------------------------------------------------------- -Write-Host '' -Say " Bundle staged at: $BundleRoot" 'White' -foreach ($d in @('app', 'wheels', 'python', 'httpplatformhandler', 'urlrewrite', 'vcredist', 'mysqlclient', 'mysql')) { - $p = Join-Path $BundleRoot $d - if (Test-Path $p) { - $mb = ((Get-ChildItem $p -Recurse -File | Measure-Object Length -Sum).Sum / 1MB) - Say (" {0,-22} {1,8:N1} MB" -f $d, $mb) - } else { - Say (" {0,-22} {1}" -f $d, 'absent') 'DarkGray' - } -} -Write-Host '' -Say ' Compile with: iscc ShopDBFlask.iss' 'White' -Say ' (Inno Setup 6.6.0 or newer - the wizard uses the windows11 custom style.)' 'DarkGray' -Write-Host '' diff --git a/deploy/windows/installer/build-installer.sh b/deploy/windows/installer/build-installer.sh deleted file mode 100755 index 3f527e5d..00000000 --- a/deploy/windows/installer/build-installer.sh +++ /dev/null @@ -1,141 +0,0 @@ -#!/bin/bash -# Stage a lean per-site bundle next to ShopDBFlask.iss, ready for Inno Setup. -# -# The bundle is built FOR ONE SITE from its plugin profile (ADR-013): plugins the -# site did not choose are absent from the payload entirely. Build one installer -# per site, not one universal installer. -# -# Usage: build-installer.sh [repo-path] -# -# The wheelhouse cannot be built here. Wheels are cp314 win_amd64 and must be -# produced ON Windows with the matching Python: -# pip download -r requirements.txt -d wheels --only-binary=:all: -# Copy that wheels\ directory in before compiling. -set -euo pipefail - -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROFILE="${1:?usage: build-installer.sh [repo-path]}" -REPO="${2:-$HOME/projects/shopdb-flask}" -BUNDLE="$HERE/bundle" - -[ -f "$PROFILE" ] || { echo "profile not found: $PROFILE"; exit 1; } -[ -d "$REPO" ] || { echo "repo not found: $REPO"; exit 1; } - -echo "==> Staging lean app tree from $PROFILE" -rm -rf "$BUNDLE/app" -bash "$REPO/scripts/build-site.sh" "$PROFILE" "$BUNDLE/app" - -# build-site.sh emits the SPA as frontend-dist; the installer's web.config and -# static route expect frontend\dist. -if [ -d "$BUNDLE/app/frontend-dist" ]; then - mkdir -p "$BUNDLE/app/frontend" - rm -rf "$BUNDLE/app/frontend/dist" - mv "$BUNDLE/app/frontend-dist" "$BUNDLE/app/frontend/dist" -fi -# The /shopdb-based build, used when the operator picks the subpath deployment. -if [ -d "$BUNDLE/app/frontend-dist-subpath" ]; then - rm -rf "$BUNDLE/app/frontend/dist-subpath" - mv "$BUNDLE/app/frontend-dist-subpath" "$BUNDLE/app/frontend/dist-subpath" -fi - -# Tell the .iss which plugins this bundle actually carries, so the wizard's -# plugin page always matches the payload instead of a hand-maintained list. -echo "==> Writing plugins.iss" -PLUGINS=$(ls "$BUNDLE/app/plugins" 2>/dev/null | tr '\n' ',' | sed 's/,$//') -# The subpath SPA is built with its base path compiled in, so whether the wizard -# can OFFER a subpath install is a property of the bundle, not a runtime choice. -SUBPATH_ALIAS_BUILT="" -if [ -f "$BUNDLE/app/frontend/dist-subpath/.alias" ]; then - SUBPATH_ALIAS_BUILT="$(cat "$BUNDLE/app/frontend/dist-subpath/.alias")" -fi - -cat > "$HERE/plugins.iss" < Writing version.iss" -APPVERSION=$(sed -n "s/^__version__ = '\\(.*\\)'/\\1/p" "$REPO/shopdb/__init__.py" | head -1) -[ -n "$APPVERSION" ] || { echo "could not read __version__ from shopdb/__init__.py"; exit 1; } -cat > "$HERE/version.iss" < Copying installer scripts" -mkdir -p "$BUNDLE" -for f in shopdb-install.ps1 shopdb-preflight.ps1 bundle-lock.ps1; do - [ -f "$HERE/$f" ] || { echo "installer script missing from the repo: $f"; exit 1; } - cp "$HERE/$f" "$BUNDLE/" -done - -echo "" -echo "Bundle staged at: $BUNDLE" -for d in app wheels python httpplatformhandler urlrewrite vcredist mysqlclient mysql; do - if [ -d "$BUNDLE/$d" ]; then - printf ' %-20s %s\n' "$d" "$(du -sh "$BUNDLE/$d" | cut -f1)" - else - printf ' %-20s absent\n' "$d" - fi -done -echo "" -echo " plugins shipped: $(ls "$BUNDLE/app/plugins" 2>/dev/null | tr '\n' ' ')" - -# --- payload verification --------------------------------------------------- -# The third-party payload is the part git does not record: the wheels, the Python -# installer and the MSIs that run as SYSTEM on the target server. It must be -# EXACTLY what bundle-lock.json describes - no missing file, no stale extra wheel -# left over from a previous build, no changed content - or this is not a bundle -# anyone reviewed. Previously a missing wheelhouse printed MISSING and the script -# still exited 0, so an empty bundle compiled into a shippable installer and the -# failure surfaced on an air-gapped server with no way to fix it. -# -# ALLOW_UNLOCKED=1 downgrades this to a warning, for assembling a bundle before -# its lock exists. A bundle built that way must not be shipped. -echo "" -echo "==> Verifying the third-party payload against bundle-lock.json" -if python3 "$HERE/verify_bundle_lock.py" "$BUNDLE" "$HERE/bundle-lock.json"; then - echo " payload matches the lock" - # Ships WITH the bundle: the installer re-checks the payload on the target - # server before running any of it, so tampering between build and install is - # caught too. - cp "$HERE/bundle-lock.json" "$BUNDLE/" -elif [ "${ALLOW_UNLOCKED:-0}" = "1" ]; then - echo "" - echo " ALLOW_UNLOCKED=1: continuing anyway. DO NOT SHIP this bundle." -else - echo "" - echo " The bundle is not what the lock describes." - echo "" - echo " Add the missing pieces by hand:" - echo " wheels/ pip download -r requirements.txt --only-binary=:all: \\" - echo " --platform win_amd64 --python-version 314 \\" - echo " --implementation cp --abi cp314 -d wheels" - echo " python/ python-3.14.x-amd64.exe" - echo " httpplatformhandler/ httpPlatformHandler_amd64.msi" - echo " urlrewrite/ rewrite_amd64.msi (client-IP rule; see README)" - echo " vcredist/ VC_redist.x64.exe (MySQL requires it)" - echo " mysql/ mysql-8.4.x-winx64.msi (bundled-database option only)" - echo "" - echo " If the payload changed ON PURPOSE, regenerate and COMMIT the lock:" - echo " pwsh ./refresh-bundle-lock.ps1 # review the diff" - echo " pwsh ./refresh-bundle-lock.ps1 -Yes # write it" - echo "" - echo " To stage a bundle before its lock exists: ALLOW_UNLOCKED=1 $0 ..." - exit 1 -fi - -echo "" -echo "Then compile on Windows: iscc ShopDBFlask.iss" -echo "(Inno Setup 6.6.0 or newer - the wizard uses the windows11 custom style.)" diff --git a/deploy/windows/installer/bundle-lock.json b/deploy/windows/installer/bundle-lock.json deleted file mode 100644 index 34bdb313..00000000 --- a/deploy/windows/installer/bundle-lock.json +++ /dev/null @@ -1,240 +0,0 @@ -{ - "schema": 1, - "generated": "2026-08-04T23:11:22Z", - "pythontag": "cp314", - "platform": "win_amd64", - "note": "Exact third-party payload of the installer bundle. Regenerate with refresh-bundle-lock.ps1 and COMMIT the change as a reviewed dependency bump.", - "payloads": { - "httpplatformhandler": { - "files": { - "httpPlatformHandler_amd64.msi": { - "sha256": "90f8d4905a0ab4f2c95223b3c79e2807a0b74507747d240e43c4302e8db4b5ef", - "size": 557056 - } - }, - "required": true - }, - "python": { - "files": { - "python-3.14.6-amd64.exe": { - "sha256": "14b3e9a710a3fcf0bd9b55ab6b60412bd91227563f813fc49040cabc0209e0bd", - "size": 30774112 - } - }, - "required": true - }, - "vcredist": { - "files": { - "VC_redist.x64.exe": { - "sha256": "cc0ff0eb1dc3f5188ae6300faef32bf5beeba4bdd6e8e445a9184072096b713b", - "size": 25635768 - } - }, - "required": false - }, - "mysql": { - "files": { - "mysql-8.4.6-winx64.msi": { - "sha256": "868885b2e221409f0170729bb30b2b2ce83440a1a4c735d19b3c225b51045762", - "size": 135081984 - } - }, - "required": false - }, - "wheels": { - "files": { - "email_validator-2.3.0-py3-none-any.whl": { - "sha256": "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", - "size": 35604 - }, - "cachelib-0.13.0-py3-none-any.whl": { - "sha256": "8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516", - "size": 20914 - }, - "flask_caching-2.4.0-py3-none-any.whl": { - "sha256": "d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486", - "size": 28727 - }, - "typing_extensions-4.15.0-py3-none-any.whl": { - "sha256": "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", - "size": 44614 - }, - "greenlet-3.5.0-cp314-cp314-win_amd64.whl": { - "sha256": "3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", - "size": 239835 - }, - "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl": { - "sha256": "92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", - "size": 159634 - }, - "colorama-0.4.6-py2.py3-none-any.whl": { - "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", - "size": 25335 - }, - "Flask_Migrate-4.1.0-py3-none-any.whl": { - "sha256": "24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", - "size": 21237 - }, - "markupsafe-3.0.3-cp314-cp314-win_amd64.whl": { - "sha256": "bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", - "size": 15341 - }, - "flask_marshmallow-1.5.0-py3-none-any.whl": { - "sha256": "99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6", - "size": 12161 - }, - "tzdata-2026.3-py2.py3-none-any.whl": { - "sha256": "dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", - "size": 348168 - }, - "python_dotenv-1.2.2-py3-none-any.whl": { - "sha256": "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", - "size": 22101 - }, - "dnspython-2.8.0-py3-none-any.whl": { - "sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", - "size": 331094 - }, - "pymysql-1.1.3-py3-none-any.whl": { - "sha256": "8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289", - "size": 45356 - }, - "packaging-26.3-py3-none-any.whl": { - "sha256": "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", - "size": 129956 - }, - "flask_jwt_extended-4.7.3-py2.py3-none-any.whl": { - "sha256": "905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb", - "size": 22698 - }, - "urllib3-2.7.0-py3-none-any.whl": { - "sha256": "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", - "size": 131087 - }, - "marshmallow-4.3.0-py3-none-any.whl": { - "sha256": "46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46", - "size": 49148 - }, - "waitress-3.0.2-py3-none-any.whl": { - "sha256": "c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", - "size": 56232 - }, - "idna-3.13-py3-none-any.whl": { - "sha256": "892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", - "size": 68629 - }, - "flask-3.1.3-py3-none-any.whl": { - "sha256": "f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", - "size": 103424 - }, - "werkzeug-3.1.8-py3-none-any.whl": { - "sha256": "63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", - "size": 226459 - }, - "flask_sqlalchemy-3.1.1-py3-none-any.whl": { - "sha256": "4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", - "size": 25125 - }, - "marshmallow_sqlalchemy-1.5.0-py3-none-any.whl": { - "sha256": "3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258", - "size": 16582 - }, - "alembic-1.18.4-py3-none-any.whl": { - "sha256": "a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", - "size": 263893 - }, - "tabulate-0.10.0-py3-none-any.whl": { - "sha256": "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", - "size": 39814 - }, - "cffi-2.1.0-cp314-cp314-win_amd64.whl": { - "sha256": "1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", - "size": 187937 - }, - "jinja2-3.1.6-py3-none-any.whl": { - "sha256": "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", - "size": 134899 - }, - "cryptography-50.0.0-cp311-abi3-win_amd64.whl": { - "sha256": "bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", - "size": 3840395 - }, - "certifi-2026.4.22-py3-none-any.whl": { - "sha256": "3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", - "size": 135707 - }, - "sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl": { - "sha256": "77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", - "size": 2144204 - }, - "blinker-1.9.0-py3-none-any.whl": { - "sha256": "ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", - "size": 8458 - }, - "pycparser-3.0-py3-none-any.whl": { - "sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", - "size": 48172 - }, - "flask_cors-6.0.2-py3-none-any.whl": { - "sha256": "e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", - "size": 13257 - }, - "requests-2.33.1-py3-none-any.whl": { - "sha256": "4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", - "size": 64947 - }, - "pyjwt-2.12.1-py3-none-any.whl": { - "sha256": "28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", - "size": 29726 - }, - "click-8.3.3-py3-none-any.whl": { - "sha256": "a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", - "size": 110502 - }, - "mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl": { - "sha256": "5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49", - "size": 18197576 - }, - "mako-1.3.12-py3-none-any.whl": { - "sha256": "8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", - "size": 78521 - }, - "itsdangerous-2.2.0-py3-none-any.whl": { - "sha256": "c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", - "size": 16234 - } - }, - "required": true - }, - "urlrewrite": { - "files": { - "rewrite_amd64_en-US.msi": { - "sha256": "37342ff2f585f263f34f48e9de59eb1051d61015a8e967dbde4075716230a32a", - "size": 6078464 - } - }, - "required": false - }, - "mysqlclient": { - "files": { - "libcrypto-3-x64.dll": { - "sha256": "e84ec61fc1b07a3c899ae39c1dc3f38591cdd310c043a388e7ab40a6828aa297", - "size": 5187216 - }, - "mysqldump.exe": { - "sha256": "329410b7a8ae3d68d38e6f5b781610770854d3f11b975060e231dae62c20dc46", - "size": 7142528 - }, - "mysql.exe": { - "sha256": "bd6880253c1853fc17b1e19129d6e5d9f9c86a2dccf2c7c9e6b878a10baa9770", - "size": 7177336 - }, - "libssl-3-x64.dll": { - "sha256": "e24fcd6c8ec31a14c97f520b5b960ed360c55ddf0935040eff297e511697633e", - "size": 788616 - } - }, - "required": false - } - } -} diff --git a/deploy/windows/installer/bundle-lock.ps1 b/deploy/windows/installer/bundle-lock.ps1 deleted file mode 100644 index 9ba92b30..00000000 --- a/deploy/windows/installer/bundle-lock.ps1 +++ /dev/null @@ -1,261 +0,0 @@ -<# -.SYNOPSIS - Hash manifest for the installer's third-party payload: create it, and check a - bundle against it. - -.DESCRIPTION - Dot-source this. It defines two functions and runs nothing on its own: - - New-BundleLock hash a staged bundle and return the lock object - Test-BundleLock compare a staged bundle against a lock, return problems - - WHAT IT COVERS, and why pip's own hash checking is not enough. - - requirements.txt carries a sha256 for every wheel, so pip refuses an artifact - upstream did not publish. Three gaps remain, and all three are what actually - goes wrong with a hand-assembled offline bundle: - - 1. pip lists EVERY artifact of a pinned version - cffi 2.1.0 alone has 100 - hashes. It proves the wheel is genuine, not that it is the wheel this - bundle was built and tested with. - 2. pip ignores extra files in the wheelhouse. A stale wheel left behind by - a previous build sits there unnoticed until a resolve picks it up. - 3. pip says nothing about the rest of the payload - the Python installer, - the HttpPlatformHandler MSI, URL Rewrite, MySQL. Those are executables - that run as SYSTEM on the target server and were, until this file, the - only unverified thing the installer would run. - - So the lock records an exact file set with a sha256 and a byte size each, and - verification is SET EQUALITY: a missing file, an unexpected extra file, or a - changed file all fail. Nothing is skipped and nothing is "close enough". - - The app tree is deliberately NOT covered. It is built from the repository on - every run and changes with every commit; hashing it would make the lock churn - constantly and train everyone to regenerate it without reading it. Git is the - record for the app tree. This file is the record for everything that comes - from outside the repository. - -.NOTES - Stock Windows PowerShell 5.1, and also runs under pwsh on Linux so the Bash - builder can call the same checker. -#> - -# Payload directories under the bundle root. 'required' means the installer -# cannot work without it; the optional ones are per-deployment choices, and an -# absent optional directory is fine. A PRESENT directory is always checked in -# full, optional or not. -$script:BundlePayloads = @( - @{ Name = 'wheels'; Required = $true; What = 'Python wheels for the offline install' } - @{ Name = 'python'; Required = $true; What = 'the Python installer' } - @{ Name = 'httpplatformhandler'; Required = $true; What = 'the IIS module that launches waitress' } - @{ Name = 'urlrewrite'; Required = $false; What = 'IIS URL Rewrite, for the client-IP rule' } - @{ Name = 'mysqlclient'; Required = $false; What = 'mysql/mysqldump, for backups against a remote database' } - @{ Name = 'vcredist'; Required = $false; What = 'the Visual C++ runtime MySQL requires' } - @{ Name = 'mysql'; Required = $false; What = 'MySQL, for the bundled-database option' } -) - -function Get-JsonProperty { - # shopdb-install.ps1 runs under Set-StrictMode 2.0, where reading a property - # that does not exist on a PSCustomObject THROWS instead of returning $null. - # A truncated or hand-edited bundle-lock.json would therefore blow up with - # "Property 'payloads' cannot be found" rather than saying what is wrong with - # the lock. Every read of parsed JSON goes through here. - param($Object, [string] $Name, $Default = $null) - if ($null -eq $Object) { return $Default } - $prop = $Object.PSObject.Properties[$Name] - if ($null -eq $prop) { return $Default } - return $prop.Value -} - -function Get-FileDigest { - param([string] $Path) - $sha = [System.Security.Cryptography.SHA256]::Create() - $stream = [System.IO.File]::OpenRead($Path) - try { return (-join ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') })) } - finally { $stream.Dispose(); $sha.Dispose() } -} - -function Get-PayloadFiles { - <# - Every file in the directory, keyed by its path RELATIVE to that directory - with forward slashes, so a lock generated on Windows reads the same from - the Bash builder. - - The root comes from Get-Item, NOT Resolve-Path, and the prefix is checked - before it is trimmed. Both matter, and a real install proved it: - - Inno extracts the bundle under C:\Users\ADMINI~1\AppData\Local\Temp\... - - an 8.3 SHORT path. Resolve-Path kept that short form while Get-ChildItem - returned the long one (Administrator), so the root was five characters - shorter than the prefix it was slicing off. Every relative path came out - mangled - 'wheels/heels/flask.whl' - and the verifier reported all 96 files - as simultaneously missing and unexpected. The payload was fine; the - comparison was not. - - Get-Item and Get-ChildItem go through the same provider, so their path - forms agree. The StartsWith guard means that if they ever disagree again - this fails loudly instead of inventing paths. - #> - param([string] $Dir) - $out = @{} - if (-not (Test-Path $Dir)) { return $out } - $rootItem = Get-Item -LiteralPath $Dir - $root = $rootItem.FullName.TrimEnd('\', '/') - foreach ($f in (Get-ChildItem -LiteralPath $rootItem.FullName -Recurse -File)) { - if (-not $f.FullName.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { - throw ("cannot place '{0}' beneath '{1}' - path forms disagree (8.3 short name?)" -f $f.FullName, $root) - } - $rel = $f.FullName.Substring($root.Length).TrimStart('\', '/').Replace('\', '/') - $out[$rel] = @{ sha256 = (Get-FileDigest $f.FullName); size = $f.Length } - } - return $out -} - -function New-BundleLock { - <# - Hash a staged bundle. The caller writes the result to bundle-lock.json; - this returns the object so a caller can diff it against the committed lock - before overwriting anything. - #> - param( - [Parameter(Mandatory = $true)] [string] $BundleRoot, - [string] $PythonTag = 'cp314', - [string] $Platform = 'win_amd64' - ) - $payloads = @{} - foreach ($p in $script:BundlePayloads) { - $dir = Join-Path $BundleRoot $p.Name - if (-not (Test-Path $dir)) { - if ($p.Required) { throw ("payload directory is missing: {0} ({1})" -f $p.Name, $p.What) } - continue - } - $files = Get-PayloadFiles $dir - if ($files.Count -eq 0 -and $p.Required) { - throw ("payload directory is empty: {0} ({1})" -f $p.Name, $p.What) - } - $payloads[$p.Name] = @{ required = $p.Required; files = $files } - } - return [ordered]@{ - schema = 1 - generated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - pythontag = $PythonTag - platform = $Platform - note = 'Exact third-party payload of the installer bundle. Regenerate with refresh-bundle-lock.ps1 and COMMIT the change as a reviewed dependency bump.' - payloads = $payloads - } -} - -function Test-BundleLock { - <# - Compare a staged bundle against a lock. Returns an array of problem - strings - EMPTY means the bundle is exactly what the lock describes. - - Returning problems rather than throwing is deliberate: an operator fixing a - wheelhouse wants the whole list at once, not one failure per rebuild. - #> - param( - [Parameter(Mandatory = $true)] [string] $BundleRoot, - [Parameter(Mandatory = $true)] $Lock - ) - $problems = @() - $payloads = Get-JsonProperty $Lock 'payloads' - # ,@(...) for the same reason as the final return: a bare one-element array - # unrolls to a string, and the caller's .Count then measures the wrong thing. - if ($null -eq $payloads) { return ,@('bundle-lock.json has no "payloads" section') } - - foreach ($p in $script:BundlePayloads) { - $name = $p.Name - $dir = Join-Path $BundleRoot $name - $present = Test-Path $dir - $entry = Get-JsonProperty $payloads $name - $locked = ($null -ne $entry) - - if (-not $locked) { - # Not in the lock at all. An unlocked directory that exists is a - # payload nobody reviewed, which is exactly what this is here to stop. - if ($present) { $problems += "$name/ is present but is not in bundle-lock.json - regenerate the lock" } - elseif ($p.Required) { $problems += "$name/ is required but is in neither the bundle nor the lock" } - continue - } - if (-not $present) { - if ($p.Required -or (Get-JsonProperty $entry 'required' $false)) { - $problems += "$name/ is in the lock but missing from the bundle ($($p.What))" - } - continue - } - - $expected = @{} - $lockedFiles = Get-JsonProperty $entry 'files' - if ($null -ne $lockedFiles) { - foreach ($prop in $lockedFiles.PSObject.Properties) { $expected[$prop.Name] = $prop.Value } - } - $actual = Get-PayloadFiles $dir - - # Sorted so the report reads the same way twice, and so it matches the - # order verify_bundle_lock.py produces. - foreach ($rel in ($expected.Keys | Sort-Object)) { - if (-not $actual.ContainsKey($rel)) { $problems += "$name/$rel is in the lock but missing from the bundle"; continue } - if ($actual[$rel].sha256 -ne $expected[$rel].sha256) { - $problems += "$name/$rel does NOT match the lock (expected sha256 $($expected[$rel].sha256.Substring(0,12))..., got $($actual[$rel].sha256.Substring(0,12))...)" - } elseif ([int64] $actual[$rel].size -ne [int64] $expected[$rel].size) { - # Cannot happen for a matching sha256, so it means the lock itself - # was hand-edited. Say so rather than passing it. - $problems += "$name/$rel size disagrees with the lock - the lock has been edited by hand" - } - } - foreach ($rel in ($actual.Keys | Sort-Object)) { - if (-not $expected.ContainsKey($rel)) { $problems += "$name/$rel is in the bundle but NOT in the lock (unexpected extra file)" } - } - } - $problems += Test-WheelhouseCoversRequirements -BundleRoot $BundleRoot - # The comma keeps this an ARRAY through the return. Without it PowerShell - # unrolls an empty result to $null and a single result to a bare string, and - # every caller that asks for .Count then behaves differently depending on how - # many problems there happen to be. - return ,$problems -} - -function Test-WheelhouseCoversRequirements { - <# - The lock records what IS in the wheelhouse, not what the application NEEDS. - Without this an incomplete wheelhouse gets locked, blessed, and shipped, - and the install fails on an air-gapped server. - - Not hypothetical: assembling the wheelhouse anywhere other than Windows - silently omits colorama, a win32-only dependency of click, because pip - evaluates environment markers against the machine doing the downloading - rather than the machine being targeted. Markers are therefore IGNORED here - - a requirement guarded by sys_platform == 'win32' is precisely the one - that has to be present. - #> - param([Parameter(Mandatory = $true)] [string] $BundleRoot) - $wheels = Join-Path $BundleRoot 'wheels' - $reqs = Join-Path $BundleRoot 'app\requirements.txt' - if (-not (Test-Path $wheels) -or -not (Test-Path $reqs)) { return @() } - - $have = @(Get-ChildItem $wheels -File -ErrorAction SilentlyContinue | ForEach-Object { $_.Name.ToLower() }) - $problems = @() - $pins = @{} - foreach ($line in (Get-Content $reqs)) { - $trimmed = $line.Trim() - if (-not $trimmed -or $trimmed.StartsWith('#')) { continue } - if ($trimmed -match '^([A-Za-z0-9._-]+)==([^\s;\\]+)') { - # PEP 427 wheel filename form: runs of non-alphanumerics become one _. - $pins[([regex]::Replace($Matches[1], '[^A-Za-z0-9.]+', '_')).ToLower()] = $Matches[2] - } - } - foreach ($name in ($pins.Keys | Sort-Object)) { - $prefix = "$name-$($pins[$name])-" - if (-not ($have | Where-Object { $_.StartsWith($prefix) })) { - $problems += ("wheels/ has no wheel for {0}=={1}, which requirements.txt pins " + - "(a marked-out dependency still installs on Windows)") -f $name, $pins[$name] - } - } - return $problems -} - -function Read-BundleLock { - param([Parameter(Mandatory = $true)] [string] $Path) - if (-not (Test-Path $Path)) { return $null } - return (Get-Content $Path -Raw | ConvertFrom-Json) -} diff --git a/deploy/windows/installer/make-branding.py b/deploy/windows/installer/make-branding.py deleted file mode 100644 index e7637a2f..00000000 --- a/deploy/windows/installer/make-branding.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -"""Generate Inno Setup wizard artwork from the app's own brand assets. - -Everything here is derived from frontend/public/*.svg so the installer and the -running application are visibly the same product. Nothing is redrawn by hand. - -Inno stretches artwork to fit and does not resample well, so render at the exact -sizes it asks for and supply the 125%/250% variants for high-DPI displays. - - WizardImageFile 164x314, 192x386, 384x772 - WizardSmallImageFile 55x55, 64x64, 138x138 - SetupIconFile .ico with 16/24/32/48/64/128/256 - -Usage: python3 make-branding.py [output-dir] -""" -import io -import sys -from pathlib import Path - -import cairosvg -from PIL import Image, ImageDraw, ImageFont - -ASSETS = Path.home() / "projects/shopdb-flask/frontend/public" -OUT = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent) - -# Sampled from the application's own palette so the installer does not look like -# a different product wearing the same badge. -NAVY = (10, 34, 74) # deep base -BLUE = (16, 74, 150) # GE blue -CYAN = (0, 158, 224) # accent -WHITE = (255, 255, 255) -MUTED = (176, 197, 226) - - -def render_svg(name, width=None, height=None): - png = cairosvg.svg2png(url=str(ASSETS / name), output_width=width, output_height=height) - return Image.open(io.BytesIO(png)).convert("RGBA") - - -def recolour(img, colour): - """Replace RGB while keeping the alpha mask. Source marks are dark-on-light; - on a dark panel they must be inverted or they disappear.""" - solid = Image.new("RGBA", img.size, colour + (255,)) - solid.putalpha(img.getchannel("A")) - return solid - - -def font(size, bold=False): - for path in ( - f"/usr/share/fonts/truetype/dejavu/DejaVuSans{'-Bold' if bold else ''}.ttf", - f"/usr/share/fonts/truetype/liberation/LiberationSans{'-Bold' if bold else '-Regular'}.ttf", - ): - if Path(path).exists(): - return ImageFont.truetype(path, size) - return ImageFont.load_default() - - -def vertical_gradient(size, top, bottom): - w, h = size - img = Image.new("RGB", size) - draw = ImageDraw.Draw(img) - for y in range(h): - t = y / max(1, h - 1) - # Ease the ramp so the middle does not look flat. - t = t * t * (3 - 2 * t) - draw.line( - [(0, y), (w, y)], - fill=tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)), - ) - return img - - -def banner(w, h): - img = vertical_gradient((w, h), BLUE, NAVY) - draw = ImageDraw.Draw(img) - k = w / 164.0 # scale factor from the 100% design - - # Faint diagonal wash: stops the flat area under the text reading as empty. - glow = Image.new("RGBA", (w, h), (0, 0, 0, 0)) - gd = ImageDraw.Draw(glow) - gd.polygon([(0, int(h * 0.52)), (w, int(h * 0.30)), (w, h), (0, h)], - fill=(255, 255, 255, 10)) - img = Image.alpha_composite(img.convert("RGBA"), glow).convert("RGB") - draw = ImageDraw.Draw(img) - - margin = int(22 * k) - - # GE Aerospace wordmark at the top, above the product name - the corporate - # mark leads, the product sits under it. (Previously the bare monogram was - # here and the wordmark was stranded at the bottom.) - mark_w = w - (margin * 2) - mark = render_svg("ge-aerospace-logo.svg", mark_w, int(mark_w * 32 / 138)) - mark = recolour(mark, WHITE) - img.paste(mark, (margin, int(34 * k)), mark) - - # Product name, directly beneath it. - y = int(34 * k) + mark.height + int(30 * k) - draw.text((margin, y), "ShopDB", font=font(int(26 * k), bold=True), fill=WHITE) - y += int(31 * k) - - # Hairline rule, then the descriptor. Cheap way to look considered. - draw.rectangle([margin, y, margin + int(30 * k), y + max(1, int(2 * k))], fill=CYAN) - y += int(14 * k) - for line in ("Asset management", "for the shop floor"): - draw.text((margin, y), line, font=font(int(10.5 * k)), fill=MUTED) - y += int(15 * k) - - # Accent bar flush to the bottom edge. - bar = max(2, int(4 * k)) - draw.rectangle([0, h - bar, w, h], fill=CYAN) - return img - - -def small(size): - """Header mark on every page after the welcome page. White plate so it sits - correctly on the wizard's own header, in light or dark mode.""" - img = Image.new("RGB", (size, size), WHITE) - m = int(size * 0.80) - mono = recolour(render_svg("ge-monogram.svg", m, m), BLUE) - off = (size - m) // 2 - img.paste(mono, (off, off), mono) - return img - - -def icon(path): - """Installer icon. Rounded navy tile with the monogram, so it reads at 16px - instead of turning into mush.""" - base = 256 - img = Image.new("RGBA", (base, base), (0, 0, 0, 0)) - d = ImageDraw.Draw(img) - d.rounded_rectangle([0, 0, base - 1, base - 1], radius=int(base * 0.22), fill=BLUE + (255,)) - d.rounded_rectangle([0, 0, base - 1, int(base * 0.5)], radius=int(base * 0.22), - fill=(30, 96, 175, 255)) - d.rounded_rectangle([0, int(base * 0.3), base - 1, base - 1], radius=int(base * 0.22), - fill=BLUE + (255,)) - m = int(base * 0.62) - mono = recolour(render_svg("ge-monogram.svg", m, m), WHITE) - img.paste(mono, ((base - m) // 2, (base - m) // 2), mono) - img.save(path, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), - (128, 128), (256, 256)]) - - -def main(): - OUT.mkdir(parents=True, exist_ok=True) - made = [] - for w, h, name in [(164, 314, "wizard-image.bmp"), - (192, 386, "wizard-image@125.bmp"), - (384, 772, "wizard-image@250.bmp")]: - banner(w, h).save(OUT / name, "BMP"); made.append((name, f"{w}x{h}")) - for s, name in [(55, "wizard-small.bmp"), (64, "wizard-small@125.bmp"), - (138, "wizard-small@250.bmp")]: - small(s).save(OUT / name, "BMP"); made.append((name, f"{s}x{s}")) - icon(OUT / "shopdb.ico"); made.append(("shopdb.ico", "multi-size")) - for name, dims in made: - print(f" {name:<26} {dims:<10} {(OUT / name).stat().st_size // 1024} KB") - - -if __name__ == "__main__": - main() diff --git a/deploy/windows/installer/refresh-bundle-lock.ps1 b/deploy/windows/installer/refresh-bundle-lock.ps1 deleted file mode 100644 index 95849c8a..00000000 --- a/deploy/windows/installer/refresh-bundle-lock.ps1 +++ /dev/null @@ -1,110 +0,0 @@ -<# -.SYNOPSIS - Regenerate bundle-lock.json from the staged bundle, after showing what would - change. - -.DESCRIPTION - Run this on the machine that assembled the wheelhouse, once the bundle holds - the payload you intend to ship: - - bundle\wheels\ the wheels, built with the matching Python - bundle\python\ the Python installer - bundle\httpplatformhandler\ the IIS module MSI - bundle\urlrewrite\ URL Rewrite MSI (optional) - bundle\mysql\ MySQL MSI (optional) - - Then COMMIT the resulting bundle-lock.json. That commit is the review: it is - the only place a change to what runs as SYSTEM on a customer's server becomes - visible to a human. A lock regenerated and committed without reading the diff - provides nothing, so this refuses to overwrite an existing lock until you - have seen the change and passed -Yes. - -.EXAMPLE - .\refresh-bundle-lock.ps1 # show the diff, write nothing - .\refresh-bundle-lock.ps1 -Yes # write it - -.NOTES - Building the wheelhouse itself no longer requires Windows. From any machine: - pip download -r requirements.txt -d wheels --only-binary=:all: ` - --platform win_amd64 --python-version 314 --implementation cp --abi cp314 - Do it wherever you like; this script records what came out. -#> - -[CmdletBinding()] -param( - [string] $BundleRoot = (Join-Path $PSScriptRoot 'bundle'), - [string] $LockPath = (Join-Path $PSScriptRoot 'bundle-lock.json'), - [string] $PythonTag = 'cp314', - [string] $Platform = 'win_amd64', - [switch] $Yes -) - -$ErrorActionPreference = 'Stop' -. (Join-Path $PSScriptRoot 'bundle-lock.ps1') - -function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c } - -if (-not (Test-Path $BundleRoot)) { - Say "bundle not found: $BundleRoot" 'Red' - Say 'Stage it first with build-installer.ps1 (or build-installer.sh), then add' 'Yellow' - Say 'the wheels and installers by hand.' 'Yellow' - exit 1 -} - -Say '' -Say " Hashing $BundleRoot" 'Cyan' -$new = New-BundleLock -BundleRoot $BundleRoot -PythonTag $PythonTag -Platform $Platform - -foreach ($name in $new.payloads.Keys) { - Say (" {0,-22} {1,4} files" -f $name, $new.payloads[$name].files.Count) -} - -$old = Read-BundleLock $LockPath -if (-not $old) { - Say '' - Say ' No existing lock - this will be the first one.' 'Yellow' -} else { - # Diff by file, per payload, so the operator sees exactly which artifacts - # changed rather than "the lock is different". - Say '' - Say ' Changes against the committed lock:' 'Cyan' - $changes = 0 - foreach ($name in $new.payloads.Keys) { - $oldFiles = @{} - if ($old.payloads.PSObject.Properties.Name -contains $name) { - foreach ($p in $old.payloads.$name.files.PSObject.Properties) { $oldFiles[$p.Name] = $p.Value.sha256 } - } - $newFiles = $new.payloads[$name].files - foreach ($rel in ($newFiles.Keys | Sort-Object)) { - if (-not $oldFiles.ContainsKey($rel)) { Say " + $name/$rel" 'Green'; $changes++ } - elseif ($oldFiles[$rel] -ne $newFiles[$rel].sha256) { Say " ~ $name/$rel (content changed)" 'Yellow'; $changes++ } - } - foreach ($rel in ($oldFiles.Keys | Sort-Object)) { - if (-not $newFiles.ContainsKey($rel)) { Say " - $name/$rel" 'Red'; $changes++ } - } - } - foreach ($p in $old.payloads.PSObject.Properties.Name) { - if (-not $new.payloads.Contains($p)) { Say " - $p/ (whole payload gone)" 'Red'; $changes++ } - } - if ($changes -eq 0) { - Say ' none - the bundle already matches the lock' 'Green' - exit 0 - } - Say '' - Say (" {0} change(s)." -f $changes) 'White' -} - -if (-not $Yes) { - Say '' - Say ' Nothing written. Read the list above, then re-run with -Yes.' 'Yellow' - Say ' Commit the resulting bundle-lock.json - that commit IS the review.' 'Yellow' - exit 2 -} - -# ConvertTo-Json defaults to a depth of 2, which silently flattens the per-file -# entries into "System.Collections.Hashtable" strings and produces a lock that -# verifies against nothing. -$new | ConvertTo-Json -Depth 8 | Set-Content -Path $LockPath -Encoding UTF8 -Say '' -Say " Written: $LockPath" 'Green' -Say ' Commit it.' 'Green' diff --git a/deploy/windows/installer/shopdb-admin.ps1 b/deploy/windows/installer/shopdb-admin.ps1 deleted file mode 100644 index 389a775b..00000000 --- a/deploy/windows/installer/shopdb-admin.ps1 +++ /dev/null @@ -1,1045 +0,0 @@ -<# -.SYNOPSIS - Day-to-day control of a ShopDB-Flask installation, on the server itself. - -.DESCRIPTION - Installed alongside the application so an operator never has to open IIS - Manager, hunt for a log, or remember an appcmd incantation. - - Run it with no arguments for a menu, or pass a command directly: - - .\shopdb-admin.ps1 status - .\shopdb-admin.ps1 restart - .\shopdb-admin.ps1 backup D:\backups - - Everything here is safe to run at any time EXCEPT backup/restore, which are - called out explicitly. - -.NOTES - Written for stock Windows PowerShell 5.1. No modules to install. -#> - -[CmdletBinding()] -param( - [ValidateSet('menu','status','start','stop','restart','logs','open','backup', - 'check','repair','catalog','sessions','plugins','add-plugin','verify','uninstall')] - [string] $Command = 'menu', - [string] $Path = '', - # Machine-readable output for 'check'. The people running this are expected to - # ask an AI assistant for help, and pasting a screenshot of a console into a - # chat window loses most of what matters. One structured blob they can paste - # gives the assistant real state to reason about instead of guesses. - [switch] $Json, - [string] $AppRoot = 'C:\shopdb-flask', - [string] $SiteName = 'shopdb-flask', - [string] $AppPool = 'shopdbflask', - [int] $SitePort = 8090 -) - -$ErrorActionPreference = 'Continue' -$AppCmd = Join-Path $env:windir 'System32\inetsrv\appcmd.exe' - -# --- bitness --------------------------------------------------------------- -# IIS's management COM objects are 64-BIT ONLY. Under the 32-bit PowerShell, -# Import-Module WebAdministration SUCCEEDS but Get-Website then fails with -# Retrieving the COM class factory ... REGDB_E_CLASSNOTREG -# which this script caught and reported as "cannot read IIS" - indistinguishable -# from IIS being absent. -# -# A 32-bit launcher is easy to end up with: Inno Setup is a 32-bit process, so -# anything it starts gets SysWOW64 powershell through WOW64 redirection. Rather -# than fix every launcher, relaunch under the native PowerShell. 'Sysnative' is -# the alias that lets a 32-bit process reach the real System32, and it exists -# ONLY for 32-bit processes - hence the guard. - -# Every relaunch below must carry the ORIGINAL arguments through. They used to be -# dropped, so a console started from the Start Menu with -AppRoot D:\shopdb -# relaunched itself with the C:\shopdb-flask default and reported a healthy -# install as missing - and the operator had done nothing wrong. -function Get-ForwardedArgs { - $forward = @('-NoProfile', '-ExecutionPolicy', 'Bypass', - '-File', ('"' + $PSCommandPath + '"'), $Command) - if ($Path) { $forward += @('-Path', ('"' + $Path + '"')) } - if ($AppRoot) { $forward += @('-AppRoot', ('"' + $AppRoot + '"')) } - if ($SiteName) { $forward += @('-SiteName', ('"' + $SiteName + '"')) } - if ($AppPool) { $forward += @('-AppPool', ('"' + $AppPool + '"')) } - if ($SitePort) { $forward += @('-SitePort', $SitePort) } - if ($Json) { $forward += '-Json' } - return $forward -} - -if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) { - $native = Join-Path $env:windir 'Sysnative\WindowsPowerShell\v1.0\powershell.exe' - if (Test-Path $native) { - Start-Process -FilePath $native -ArgumentList (Get-ForwardedArgs) -Wait -NoNewWindow - return - } -} - -# --- elevation ------------------------------------------------------------- -# Reading IIS state needs Administrator: the WebAdministration module and the -# IIS: drive both fail without it. Unelevated, this tool used to report -# "IIS not available / application pool: not installed", which reads as "your -# install is broken" when it actually means "I cannot see it". Relaunch elevated -# instead of reporting a false state. -$identity = [Security.Principal.WindowsIdentity]::GetCurrent() -$principal = New-Object Security.Principal.WindowsPrincipal($identity) -# 'open' just launches a browser. Prompting for administrator to do that trains -# people to click through UAC, and the Start Menu shortcut uses this command. -if ($Command -ne 'open' -and - -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - Write-Host '' - Write-Host ' Administrator rights are needed to read IIS state.' -ForegroundColor Yellow - Write-Host ' Re-launching elevated - approve the prompt.' -ForegroundColor Yellow - $argList = @('-NoExit') + (Get-ForwardedArgs) - try { - Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs | Out-Null - } catch { - Write-Host '' - Write-Host ' Elevation was declined.' -ForegroundColor Red - Write-Host ' Right-click the shortcut and choose "Run as administrator".' -ForegroundColor Red - Read-Host ' Press Enter to close' | Out-Null - } - return -} - - -# --- brand header ---------------------------------------------------------- -# Deliberately typographic, NOT ASCII art. The GE monogram is fine cursive -# linework; rendered as block characters at console resolution it reads as noise, -# which looks worse than no mark at all. A clean rule and correct wordmark says -# "considered"; mushy art says the opposite. -# -# Box-drawing characters used here are all in code page 437 (the Windows console -# default), and this file is saved UTF-8 WITH BOM so PowerShell 5.1 reads them -# correctly rather than assuming the ANSI code page. -function Show-Banner { - $rule = ([string][char]0x2500) * 58 - Write-Host '' - Write-Host ' GE AEROSPACE' -ForegroundColor Blue - Write-Host (' ' + $rule) -ForegroundColor DarkGray - Write-Host ' ShopDB-Flask' -ForegroundColor White - Write-Host ' Asset management for the shop floor' -ForegroundColor DarkGray - Write-Host '' -} - -function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c } -function Head { - param($m) - Write-Host '' - Write-Host (' ' + $m) -ForegroundColor Cyan - Write-Host (' ' + (([string][char]0x2500) * $m.Length)) -ForegroundColor DarkGray -} - -function Get-EnvValue { - param([string] $Key) - $envFile = Join-Path $AppRoot '.env' - if (-not (Test-Path $envFile)) { return '' } - $line = Get-Content $envFile | Where-Object { $_ -like "$Key=*" } | Select-Object -First 1 - if ($line) { return $line.Substring($Key.Length + 1) } - return '' -} - -function Get-Deployment { - <# - Which way was this installed? - - Method A: its own IIS site on $SitePort. - Method B: an IIS Application under an existing site, at /, reached - on that site's port. MOUNT_PATH in .env is what distinguishes - them - it is the same value wsgi.py uses to mount the app. - - Without this the console looked for a SITE that method B never creates and - reported "web site: not installed" on a perfectly healthy server, then - probed the wrong port and said it was not responding. - #> - $mount = (Get-EnvValue 'MOUNT_PATH').Trim() - if (-not $mount) { - return @{ Subpath = $false; Alias = ''; BaseUrl = "http://localhost:$SitePort"; Port = $SitePort } - } - $alias = $mount.Trim('/') - $port = 80 - $parent = 'Default Web Site' - try { - Import-Module WebAdministration -ErrorAction Stop - foreach ($site in (Get-Website)) { - $app = Get-WebApplication -Site $site.Name -Name $alias -ErrorAction SilentlyContinue - if ($app) { - $parent = $site.Name - $b = $site.bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 - if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $port = [int]$Matches[1] } - break - } - } - } catch { } - $base = "http://localhost:$port/$alias" - if ($port -eq 80) { $base = "http://localhost/$alias" } - return @{ Subpath = $true; Alias = $alias; Parent = $parent; BaseUrl = $base; Port = $port } -} - -function Get-DbParts { - # Pull host/port/name/user out of DATABASE_URL without printing the password. - $url = Get-EnvValue 'DATABASE_URL' - if ($url -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') { - # The password is PERCENT-ENCODED in DATABASE_URL (the installer applies - # [uri]::EscapeDataString). SQLAlchemy unescapes it; so must we. Without - # this, every command here fails to authenticate whenever the password - # contains a space, @, %, ! or /, and the console reports the database as - # unreachable on a server where the application is running perfectly. - return @{ User = [uri]::UnescapeDataString($Matches[1]) - Pass = [uri]::UnescapeDataString($Matches[2]) - Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] } - } - return $null -} - -function Find-MysqlClient { - $candidates = @( - 'C:\MySQL84\bin\mysql.exe', - 'C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe', - 'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe', - 'C:\mysql56\bin\mysql.exe' - ) + @(Get-ChildItem 'C:\Program Files\MySQL' -Filter mysql.exe -Recurse -EA SilentlyContinue | - Select-Object -ExpandProperty FullName) - foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return $c } } - return '' -} - -# -------------------------------------------------------------------------- -function Show-Status { - Head 'Status' - - $deploy = Get-Deployment - $siteState = 'not installed' - $poolState = 'not installed' - try { - Import-Module WebAdministration -ErrorAction Stop - if ($deploy.Subpath) { - # Method B: there is no site of our own - we are an Application. - $app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue - if ($app) { - $parentSite = Get-Website -Name $deploy.Parent -ErrorAction SilentlyContinue - $siteState = ("/{0} under '{1}' ({2})" -f $deploy.Alias, $deploy.Parent, - $(if ($parentSite) { $parentSite.State } else { 'unknown' })) - } - } else { - $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue - if ($site) { $siteState = $site.State } - } - if (Test-Path "IIS:\AppPools\$AppPool") { $poolState = (Get-Item "IIS:\AppPools\$AppPool").State } - } catch { - # Should be unreachable now that the script self-elevates, but if IIS is - # genuinely absent say THAT, rather than implying ShopDB is missing. - # Name the real cause instead of implying ShopDB is missing. - if (-not (Get-Service W3SVC -ErrorAction SilentlyContinue)) { - $siteState = 'IIS is not installed on this server' - } else { - $siteState = 'could not read IIS - ' + $_.Exception.Message - } - $poolState = $siteState - } - - $colour = if (($siteState -eq 'Started') -or ($siteState -like '*Started*')) { 'Green' } else { 'Yellow' } - Say (" published as : {0}" -f $siteState) $colour - Say (" application pool: {0}" -f $poolState) $colour - Say (" installed at : {0}" -f $(if (Test-Path $AppRoot) { $AppRoot } else { 'not found' })) - - # Does it actually answer? A "Started" pool proves nothing. Under method B - # this must include the mount path, or it tests a URL that never existed. - $url = $deploy.BaseUrl + '/' - try { - $r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 20 - Say (" responding : yes (HTTP {0})" -f $r.StatusCode) 'Green' - } catch { - Say ' responding : NO' 'Red' - Say (" {0}" -f $_.Exception.Message) 'DarkGray' - } - - $db = Get-DbParts - if ($db) { - Say (" database : {0} on {1}:{2} as {3}" -f $db.Name, $db.Host, $db.Port, $db.User) - $mysql = Find-MysqlClient - if ($mysql) { - $q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';" - $n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null - if ($LASTEXITCODE -eq 0) { Say (" tables : {0}" -f $n) 'Green' } - else { Say ' tables : could not connect' 'Red' } - } - } else { Say ' database : no .env found' 'Yellow' } - - # Is anyone set up yet? - try { - $na = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/api/setup/needs-admin') -UseBasicParsing -TimeoutSec 15 - if ($na.Content -match '"needsadmin"\s*:\s*true') { - Say ' first run : NOT SET UP - open the site to create the first administrator' 'Yellow' - } else { Say ' first run : complete' 'Green' } - } catch { } - - Say '' - if ($deploy.Subpath) { - $shown = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias - if ($deploy.Port -ne 80) { $shown = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias } - } else { - $shown = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort - } - Say (" open with : {0}" -f $shown) 'White' -} - -function Start-App { - Head 'Starting' - $deploy = Get-Deployment - & $AppCmd start apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" } - if (-not $deploy.Subpath) { - & $AppCmd start site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" } - } else { - # Method B shares the parent site - starting or stopping THAT would take - # every other application on this server with it. - Say (" (published under '{0}' - only the application pool is ours to control)" -f $deploy.Parent) 'DarkGray' - } - Say ' started' 'Green' -} - -function Stop-App { - Head 'Stopping' - $deploy = Get-Deployment - Say ' ShopDB-Flask will be unavailable until you start it again.' 'Yellow' - if (-not $deploy.Subpath) { - & $AppCmd stop site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" } - } else { - Say (" (leaving site '{0}' running - stopping it would take down everything else on it)" -f $deploy.Parent) 'DarkGray' - } - & $AppCmd stop apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" } - Say ' stopped' 'Yellow' -} - -function Restart-App { - Head 'Restarting' - # A recycle CANNOT start something that is stopped - it is a no-op on a - # stopped pool. Restarting after a failed upgrade, which is exactly when the - # pool is stopped, therefore did nothing and then reported "did not respond" - # in red, as though the application were broken. Start it first when needed. - Import-Module WebAdministration -ErrorAction SilentlyContinue - $started = $true - try { - if (Test-Path "IIS:\AppPools\$AppPool") { - $started = ((Get-Item "IIS:\AppPools\$AppPool").State -eq 'Started') - } - } catch { } - if (-not $started) { - Say ' the application pool is stopped; starting it' 'Yellow' - Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue - Start-Sleep -Seconds 3 - } else { - # Recycle rather than stop/start: it drains existing requests instead of - # cutting them off, and it is what a config change actually needs. - & $AppCmd recycle apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" } - if ($LASTEXITCODE -ne 0) { Say " recycle reported exit $LASTEXITCODE" 'Yellow' } - Start-Sleep -Seconds 2 - } - - # A stopped SITE answers nothing however healthy the pool is. - try { - $siteNow = Get-Website -Name $SiteName -ErrorAction SilentlyContinue - if ($siteNow -and $siteNow.State -ne 'Started') { - Say ' the site is stopped; starting it' 'Yellow' - Start-Website -Name $SiteName -ErrorAction SilentlyContinue - Start-Sleep -Seconds 2 - } - } catch { } - try { - $r = Invoke-WebRequest -Uri ((Get-Deployment).BaseUrl + '/') -UseBasicParsing -TimeoutSec 30 - Say (" back up (HTTP {0})" -f $r.StatusCode) 'Green' - } catch { Say ' did not respond after restart - run: shopdb-admin.ps1 logs' 'Red' } -} - -function Show-Logs { - Head 'Recent logs' - $appLog = Join-Path $AppRoot 'logs' - if (Test-Path $appLog) { - $newest = Get-ChildItem "$appLog\*.log" -EA SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 - if ($newest) { - Say (" application: {0}" -f $newest.FullName) 'White' - Get-Content $newest.FullName -Tail 20 | ForEach-Object { Say " $_" } - } else { Say ' no application log yet' } - } - $inst = 'C:\ProgramData\ShopDB-Flask\logs' - if (Test-Path $inst) { - $newest = Get-ChildItem "$inst\*.log" -EA SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 - if ($newest) { - Say '' - Say (" install: {0}" -f $newest.FullName) 'White' - Get-Content $newest.FullName -Tail 12 | ForEach-Object { Say " $_" } - } - } -} - -function Open-Site { - # The installer records the address it actually published at. Prefer it: this - # command runs unelevated, and .env is ACL'd, so working the address out from - # MOUNT_PATH and IIS may not be possible from here. - $recorded = Join-Path $AppRoot '.installed-url' - if (Test-Path $recorded) { - $u = (Get-Content $recorded -TotalCount 1).Trim() - if ($u) { Start-Process $u; Say " opened $u" 'Green'; return } - } - $deploy = Get-Deployment - if ($deploy.Subpath) { - if ($deploy.Port -eq 80) { $u = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias } - else { $u = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias } - } else { - $u = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort - } - Start-Process $u - Say " opened $u" 'Green' -} - -function Backup-Db { - param([string] $Dest) - Head 'Database backup' - $db = Get-DbParts - if (-not $db) { Say ' no .env found - cannot determine the database' 'Red'; return } - $usingDefault = -not $Dest - if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' } - if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null } - - # A dump holds every row, including the users table and its password hashes. - # A directory created under ProgramData INHERITS Users:RX, so those hashes - # were readable by every authenticated user on the server whenever this - # command created the directory rather than the installer. - # - # Re-applied on every backup, not only on creation, because this may be - # repairing a directory made by an earlier version. - # - # Only for the default location. A path the operator named is theirs, and - # silently rewriting its ACL is not this command's business - say so instead. - if ($usingDefault) { - & icacls.exe $Dest '/inheritance:r' ` - '/grant' 'BUILTIN\Administrators:(OI)(CI)(F)' ` - '/grant' 'NT AUTHORITY\SYSTEM:(OI)(CI)(F)' 2>&1 | Out-Null - } else { - Say ' note: this dump contains password hashes - check who can read that directory' 'Yellow' - } - - $mysql = Find-MysqlClient - if (-not $mysql) { Say ' mysql client not found' 'Red'; return } - $dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe' - if (-not (Test-Path $dump)) { Say ' mysqldump not found' 'Red'; return } - - $file = Join-Path $Dest ("shopdb_flask-{0}.sql" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) - Say (" writing {0}" -f $file) - # Redirect rather than --result-file: keeps it working on 5.6 and 8.0 alike. - # Do NOT pipe mysqldump through the PowerShell pipeline into Out-File. - # PowerShell 5.1 writes a UTF-8 BOM and re-encodes the stream through the - # console code page, producing a dump MySQL refuses to load and mangling any - # non-ASCII data - discovered only when the backup is finally needed. - # Redirect the process's stdout straight to the file instead. - $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", - '--single-transaction', '--routines', '--triggers', $db.Name) - $quoted = $args | ForEach-Object { - if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ } - } - $err = [System.IO.Path]::GetTempFileName() - try { - $proc = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow ` - -RedirectStandardOutput $file -RedirectStandardError $err - if ($proc.ExitCode -ne 0) { - Say (" mysqldump failed (exit {0})" -f $proc.ExitCode) 'Red' - Get-Content $err -Tail 3 -EA SilentlyContinue | ForEach-Object { Say (" " + $_) 'DarkGray' } - return - } - } finally { Remove-Item $err -Force -ErrorAction SilentlyContinue } - - # Verify it is complete rather than merely present. - $tail = @(Get-Content $file -Tail 5 -ErrorAction SilentlyContinue) - if ((Test-Path $file) -and ((Get-Item $file).Length -gt 1024) -and ($tail -match 'Dump completed')) { - Say (" done - {0:N1} MB, verified complete" -f ((Get-Item $file).Length / 1MB)) 'Green' - - # The dump is NOT everything. Uploaded branding, map blueprints and - # generated files live in instance\ on disk, not in the database, so a - # restore from the .sql alone comes back with no floor map. Saying "all of - # your asset data" was true and misleading at the same time. - $instance = Join-Path $AppRoot 'instance' - if (Test-Path $instance) { - $zip = [System.IO.Path]::ChangeExtension($file, $null) + 'instance.zip' - try { - Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue - if (Test-Path $zip) { Remove-Item $zip -Force } - [System.IO.Compression.ZipFile]::CreateFromDirectory($instance, $zip) - Say (" uploaded files: {0:N1} MB -> {1}" -f ((Get-Item $zip).Length / 1MB), (Split-Path $zip -Leaf)) 'Green' - } catch { - Say (" could not archive instance\: {0}" -f $_.Exception.Message) 'Yellow' - Say ' copy it by hand - it holds branding and floor-map images' 'Yellow' - } - } - - Say '' - Say ' Store BOTH files off this server. The .sql holds the records; the' 'Yellow' - Say ' .zip holds uploaded branding and floor-map images, which the' 'Yellow' - Say ' database does not. A restore needs both.' 'Yellow' - Say '' - Say ' Contains user password hashes - treat it as sensitive.' 'Yellow' - } else { - Say ' backup is empty or truncated - do NOT rely on it' 'Red' - Remove-Item $file -Force -ErrorAction SilentlyContinue - } -} - -function Get-CheckState { - <# - Everything an outside reader needs to reason about this server, gathered - without changing anything. Secrets are NEVER included: the database password - lives in DATABASE_URL and this reports the host, port, name and user only. - #> - $deploy = Get-Deployment - $db = Get-DbParts - $state = [ordered]@{ - collected = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - computername = $env:COMPUTERNAME - approot = $AppRoot - installed = (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py')) - version = '' - publishedas = if ($deploy.Subpath) { "subpath /$($deploy.Alias) under '$($deploy.Parent)'" } else { "own site '$SiteName' on port $SitePort" } - baseurl = $deploy.BaseUrl - apppool = 'unknown' - poolstate = 'unknown' - sitestate = 'unknown' - responding = $false - httpstatus = 0 - database = $null - pythonversion = '' - plugins = @() - sbomcomponents = 0 - errors = @() - } - foreach ($pair in @(@('version', '.installed-version'))) { - $f = Join-Path $AppRoot $pair[1] - if (Test-Path $f) { $state[$pair[0]] = (Get-Content $f -TotalCount 1).Trim() } - } - $state.apppool = $AppPool - try { - Import-Module WebAdministration -ErrorAction Stop - if (Test-Path "IIS:\AppPools\$AppPool") { $state.poolstate = (Get-Item "IIS:\AppPools\$AppPool").State.ToString() } - if ($deploy.Subpath) { - $app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue - $state.sitestate = if ($app) { 'application present' } else { 'application MISSING' } - } else { - $s = Get-Website -Name $SiteName -ErrorAction SilentlyContinue - $state.sitestate = if ($s) { $s.State.ToString() } else { 'site MISSING' } - } - } catch { $state.errors += "IIS: $($_.Exception.Message)" } - - try { - $r = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/') -UseBasicParsing -TimeoutSec 20 - $state.responding = $true - $state.httpstatus = [int] $r.StatusCode - } catch { $state.errors += "HTTP: $($_.Exception.Message)" } - - if ($db) { - $state.database = [ordered]@{ host = $db.Host; port = $db.Port; name = $db.Name; user = $db.User; reachable = $false } - $mysql = Find-MysqlClient - if ($mysql) { - $q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';" - $n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null - if ($LASTEXITCODE -eq 0) { $state.database.reachable = $true; $state.database.tables = [int] $n } - } else { $state.errors += 'no mysql client found, database not probed' } - } else { $state.errors += 'no .env found' } - - $py = Join-Path $AppRoot 'venv\Scripts\python.exe' - if (Test-Path $py) { $state.pythonversion = (& $py -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>$null | Select-Object -First 1) } - - $dir = Join-Path $AppRoot 'plugins' - if (Test-Path $dir) { - $state.plugins = @(Get-ChildItem $dir -Directory -EA SilentlyContinue | - Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } | - Select-Object -ExpandProperty Name) - } - $sbom = Join-Path $AppRoot 'sbom.cdx.json' - if (Test-Path $sbom) { - try { $state.sbomcomponents = (Get-Content $sbom -Raw | ConvertFrom-Json).components.Count } catch { } - } - return $state -} - -function Invoke-Check { - if ($Json) { - # ONLY the JSON goes to stdout, so it can be redirected to a file or piped - # without a banner in the middle of the document. - Get-CheckState | ConvertTo-Json -Depth 6 - return - } - - Head 'Health check' - $flask = Join-Path $AppRoot 'venv\Scripts\flask.exe' - if (-not (Test-Path $flask)) { Say ' application not installed' 'Red'; return } - Push-Location $AppRoot - $env:FLASK_APP = 'shopdb' - try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } } - finally { Pop-Location } - - # Surface an unfinished install HERE, where somebody is already looking, - # rather than leaving them to infer it from 500s on unrelated pages. - $state = Test-Provisioned - if ($state.SchemaCurrent -eq $false -or $state.Seeded -eq $false) { - Say '' - Say ' THIS SERVER IS NOT FULLY PROVISIONED' 'Red' - foreach ($detail in $state.Detail) { Say (" - {0}" -f $detail) 'Red' } - Say ' Most pages will answer 500 until this is fixed. Run:' 'Yellow' - Say ' shopdb-admin.ps1 repair' 'White' - } - - Say '' - Say ' For help from an AI assistant, paste the output of:' 'DarkGray' - Say ' shopdb-admin.ps1 check -Json' 'White' -} - -function Test-Provisioned { - """Is the schema current and the reference data seeded?""" - # A server whose migrations or seeds never completed does not fail politely: - # it answers 500 on most pages and 404 on settings that were never created, - # which reads as a broken application rather than an unfinished install. One - # site was stood up that way and spent a morning being debugged as a bug. - $result = @{ SchemaCurrent = $true; Seeded = $true; Detail = @() } - - $out = Invoke-Flask @('db','current') - $current = ($out | Out-String) - $head = (Invoke-Flask @('db','heads') | Out-String) - if ($script:LastFlaskExit -ne 0) { - $result.SchemaCurrent = $false - $result.Detail += 'could not read the schema version' - } elseif ($current -notmatch '\(head\)' -and $head.Trim()) { - # `db current` appends "(head)" when the database is at the newest - # revision. Its absence means migrations are outstanding. - $result.SchemaCurrent = $false - $result.Detail += 'database schema is behind the application' - } - - # Sentinel seeds. permissions, settings and the reference data are each - # created by a `flask seed` command, and their absence is what produced the - # 404s on settings keys at a site whose install never finished. - $seedOut = (Invoke-Flask @('db-utils','seed-state') | Out-String) - if ($seedOut -match 'No such command') { - # An older build with no seed-state. Report UNKNOWN rather than healthy: - # claiming a clean bill of health from a check that did not run is how a - # broken server passes inspection. - $result.Seeded = $null - $result.Detail += 'seed state could not be checked (older build)' - } elseif ($script:LastFlaskExit -ne 0 -or $seedOut -match 'MISSING') { - $result.Seeded = $false - $result.Detail += 'seed data is missing (permissions, settings or reference data)' - } - return $result -} - -function Invoke-Repair { - Head 'Repair provisioning' - Say ' Brings the database up to the application: migrations, plugin' - Say ' migrations, and the seed data. Every step is idempotent, so running' - Say ' this on a healthy server changes nothing.' - Say '' - - $steps = @( - @{ Name = 'core schema'; Args = @('db','upgrade') }, - @{ Name = 'plugin schemas'; Args = @('plugin','upgrade-all') }, - @{ Name = 'permissions'; Args = @('seed','permissions') }, - @{ Name = 'settings'; Args = @('seed','settings') }, - @{ Name = 'reference data'; Args = @('seed','reference-data') } - ) - $failed = @() - foreach ($step in $steps) { - Say (" {0} ..." -f $step.Name) 'White' - $out = Invoke-Flask $step.Args - if ($script:LastFlaskExit -ne 0) { - $failed += $step.Name - Say (" FAILED (exit {0})" -f $script:LastFlaskExit) 'Red' - $out | Select-Object -Last 6 | ForEach-Object { Say " $_" 'Red' } - } else { - Say ' done' 'Green' - } - } - - if ($failed.Count -gt 0) { - Say '' - Say (" {0} step(s) failed: {1}" -f $failed.Count, ($failed -join ', ')) 'Red' - Say ' Nothing further was skipped - each step ran independently.' 'Red' - Say ' Send the output above, or run: shopdb-admin.ps1 check -Json' 'Red' - return - } - - Say '' - Say ' Provisioning complete. Restarting the application.' 'Green' - Restart-App -} - -function Invoke-Catalog { - Head 'Load the shared equipment catalog' - Say ' Vendors, model numbers, printer supply part numbers and the type' - Say ' lists, as built up across the other sites. It only ADDS - nothing' - Say ' already here is changed or removed - so it is safe to run at any' - Say ' time, including on a site that has been running for years.' - Say '' - Say ' Nothing site-specific is included: no assets, locations or people.' - Say '' - - # Dry run FIRST, always. Somebody running this on a populated site deserves - # to see what it would touch before it touches anything. - Say ' Checking what would be added...' 'White' - $preview = Invoke-Flask @('seed','catalog','--dry-run') - if ($script:LastFlaskExit -ne 0) { - Say ' could not read the catalog' 'Red' - $preview | Select-Object -Last 8 | ForEach-Object { Say " $_" 'Red' } - Say '' - Say ' An older build may not have this command. Update first.' 'Yellow' - return - } - $preview | ForEach-Object { Say " $_" } - - if (($preview | Out-String) -match 'nothing to add') { - Say '' - Say ' Already loaded. Nothing to do.' 'Green' - return - } - - Say '' - $answer = Read-Host ' Load it? (yes/no)' - if ($answer -ne 'yes') { Say ' Left alone.' 'Yellow'; return } - - $out = Invoke-Flask @('seed','catalog') - $out | ForEach-Object { Say " $_" } - if ($script:LastFlaskExit -ne 0) { - Say '' - Say ' Catalog load FAILED. Nothing else was changed.' 'Red' - return - } - Say '' - Say ' Catalog loaded.' 'Green' -} - -function Show-Sessions { - Head 'Worker processes' - $w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue - if (-not $w) { Say ' no IIS worker running (the site starts one on first request)' } - else { - foreach ($p in $w) { - Say (" pid {0} {1:N0} MB started {2}" -f $p.ProcessId, - ($p.WorkingSetSize/1MB), $p.CreationDate) - } - } - $py = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -EA SilentlyContinue | - Where-Object { $_.CommandLine -like "*$AppRoot*" } - if ($py) { foreach ($p in $py) { Say (" python pid {0} {1:N0} MB" -f $p.ProcessId, ($p.WorkingSetSize/1MB)) } } -} - - -# Set by every Invoke-Flask call. Callers MUST test this rather than -# $LASTEXITCODE: when flask.exe is missing no native command runs at all, so -# $LASTEXITCODE keeps whatever value it had from something earlier - which reads -# as success and made a command that did nothing report that it had worked. -$script:LastFlaskExit = 0 - -function Invoke-Flask { - param([string[]] $Arguments) - $flask = Join-Path $AppRoot 'venv\Scripts\flask.exe' - if (-not (Test-Path $flask)) { - Say ' application not installed' 'Red' - $script:LastFlaskExit = 127 - return $null - } - Push-Location $AppRoot - $env:FLASK_APP = 'shopdb' - # The app logs plugin startup to STDERR even on success; with EAP=Stop that - # becomes a terminating error and a healthy command looks like a failure. - $prev = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { - & $flask @Arguments 2>&1 - $script:LastFlaskExit = $LASTEXITCODE - } - finally { $ErrorActionPreference = $prev; Pop-Location } -} - -function Show-Plugins { - Head 'Plugins' - Invoke-Flask @('plugin','list') | ForEach-Object { Say " $_" } - - # What is on disk but NOT installed can still be added here. What is absent - # from disk cannot - see the note below. - $dir = Join-Path $AppRoot 'plugins' - if (Test-Path $dir) { - $onDisk = (Get-ChildItem $dir -Directory -EA SilentlyContinue | - Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } | - Select-Object -ExpandProperty Name) -join ', ' - Say '' - Say (" shipped in this build : {0}" -f $onDisk) 'White' - } - Say '' - Say ' Add one that is shipped : shopdb-admin.ps1 add-plugin -Path ' 'White' - Say '' - Say ' A plugin NOT listed above is not on this server at all. This build was' 'DarkGray' - Say ' made for your site''s chosen plugin set, so its code was never shipped.' 'DarkGray' - Say ' Adding one means a new installer built from an updated site profile.' 'DarkGray' -} - -function Add-Plugin { - param([string] $Name) - Head 'Add a plugin' - if (-not $Name) { Say ' usage: shopdb-admin.ps1 add-plugin -Path ' 'Yellow'; return } - - $dir = Join-Path $AppRoot ('plugins\' + $Name) - if (-not (Test-Path (Join-Path $dir 'manifest.json'))) { - Say (" '{0}' is not present on this server." -f $Name) 'Red' - Say '' - Say ' This build ships only the plugins your site chose. Adding a new one' 'Yellow' - Say ' requires a new installer built from an updated site profile - the' 'Yellow' - Say ' code is not here to install.' 'Yellow' - Say '' - Say ' Run "shopdb-admin.ps1 plugins" to see what IS available.' 'White' - return - } - - # apply-profile, not plugin install. Five plugins ship default_enabled=false, - # so `install` alone left them installed-but-disabled: the command printed a - # green success line and the feature did not appear anywhere in the UI. - # apply-profile installs AND enables, and pulls in the dependency closure. - Say (" adding {0}..." -f $Name) - $profilePath = Join-Path $AppRoot 'site-profile.json' - $applied = $false - if (Test-Path $profilePath) { - try { - $profile = Get-Content $profilePath -Raw | ConvertFrom-Json - $wanted = @($profile.plugins) - if ($wanted -notcontains $Name) { $wanted += $Name } - $profile.plugins = $wanted - $profile | ConvertTo-Json -Depth 6 | Set-Content -Path $profilePath -Encoding UTF8 - Invoke-Flask @('plugin','apply-profile',$profilePath) | ForEach-Object { Say " $_" } - $applied = ($script:LastFlaskExit -eq 0) - } catch { Say (" could not update site-profile.json: {0}" -f $_.Exception.Message) 'Yellow' } - } - if (-not $applied) { - # No profile on disk, or apply-profile failed: fall back, but enable - # explicitly so the outcome is the same either way. - Invoke-Flask @('plugin','install',$Name) | ForEach-Object { Say " $_" } - $installed = ($script:LastFlaskExit -eq 0) - Invoke-Flask @('plugin','enable',$Name) | ForEach-Object { Say " $_" } - $applied = $installed -and ($script:LastFlaskExit -eq 0) - } - if (-not $applied) { - # Do NOT print the green line on a failure. It used to be unconditional, - # so a failed add reported success and the operator went looking for a - # feature that was never enabled. - Say (" {0} was NOT added - see the output above" -f $Name) 'Red' - return - } - - Say ' applying its database migrations...' - Invoke-Flask @('plugin','upgrade-all') | ForEach-Object { Say " $_" } - if ($script:LastFlaskExit -ne 0) { - Say ' migrations FAILED - the feature is installed but its tables are not' 'Red' - Say ' do not use it until this is resolved; restore a backup if needed' 'Red' - return - } - Say ' restarting so its routes register...' - Restart-App - Say (" {0} added" -f $Name) 'Green' -} - -function Invoke-Verify { - <# - Prove the installed dependencies are still the ones that shipped. - - Two independent records, both written at install time: - requirements.txt a sha256 for every wheel, checked by pip on install - bundle-lock.json the exact third-party payload of the bundle - - This re-checks what can still be checked on a live server. It reports; it - changes nothing. - #> - Head 'Verify' - - $lock = Join-Path $AppRoot 'bundle-lock.json' - if (Test-Path $lock) { - try { - $l = Get-Content $lock -Raw | ConvertFrom-Json - Say (" installed from : {0} bundle ({1}, {2})" -f $l.generated, $l.pythontag, $l.platform) - } catch { Say ' bundle-lock.json is present but unreadable' 'Yellow' } - } else { - Say ' no bundle-lock.json recorded (installed before payload locking, or by hand)' 'Yellow' - } - - # The SBOM travels with the application, because a server on a vaulted - # network cannot be scanned from anywhere else. When a CVE lands, this is - # what answers "is that component here, and at what version" without needing - # the build box, the internet, or anyone's memory. - $sbom = Join-Path $AppRoot 'sbom.cdx.json' - if (Test-Path $sbom) { - try { - $b = Get-Content $sbom -Raw | ConvertFrom-Json - $shipped = @($b.components | Where-Object { $_.scope -eq 'required' }).Count - Say (" components : {0} ({1} shipped), CycloneDX {2}" -f ` - $b.components.Count, $shipped, $b.specVersion) - Say (" bill of materials: {0}" -f $sbom) 'DarkGray' - Say ' search it with : shopdb-admin.ps1 verify -Path ' 'DarkGray' - } catch { Say ' sbom.cdx.json is present but unreadable' 'Yellow' } - - # A named component turns this into the actual question being asked. - # Guarded on $b: an unreadable SBOM leaves it unset, and querying it then - # would report 'none', which reads as "you are not affected". - if ($Path -and $b) { - Say '' - Say (" matches for '{0}':" -f $Path) 'Cyan' - $hits = @($b.components | Where-Object { $_.name -like ('*' + $Path + '*') }) - if (-not $hits) { Say ' none - this server does not carry it' 'Green' } - foreach ($h in $hits) { - $tag = if ($h.scope -eq 'required') { 'SHIPPED' } else { 'build only' } - Say (" {0,-40} {1,-14} {2}" -f $h.name, $h.version, $tag) ` - $(if ($h.scope -eq 'required') { 'Yellow' } else { 'DarkGray' }) - } - } - } else { - Say ' no SBOM recorded (installed before SBOMs shipped, or by hand)' 'Yellow' - } - - # pip's own audit. It re-reads the metadata of what is actually installed and - # reports anything missing or version-inconsistent, which is the part that - # can still drift after install - a hand-run `pip install` on the server. - $py = Join-Path $AppRoot 'venv\Scripts\python.exe' - if (-not (Test-Path $py)) { Say ' no venv - application not installed' 'Red'; return } - - Say '' - Say ' checking installed packages against requirements.txt...' - $req = Join-Path $AppRoot 'requirements.txt' - if (-not (Test-Path $req)) { Say ' requirements.txt is missing from the install' 'Red'; return } - - $wanted = @{} - foreach ($line in (Get-Content $req)) { - if ($line -match '^([A-Za-z0-9._-]+)==([^ \\]+)') { $wanted[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] } - } - $frozen = @{} - foreach ($line in (& $py -m pip freeze 2>$null)) { - if ($line -match '^([A-Za-z0-9._-]+)==(.+)$') { $frozen[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] } - } - - $bad = 0 - foreach ($name in ($wanted.Keys | Sort-Object)) { - if (-not $frozen.ContainsKey($name)) { - Say (" MISSING {0} {1}" -f $name, $wanted[$name]) 'Red'; $bad++ - } elseif ($frozen[$name] -ne $wanted[$name]) { - Say (" DIFFERS {0} {1} installed, {2} expected" -f $name, $frozen[$name], $wanted[$name]) 'Red'; $bad++ - } - } - if ($bad -eq 0) { - Say (" all {0} packages match" -f $wanted.Count) 'Green' - } else { - Say '' - Say (" {0} package(s) do not match what shipped." -f $bad) 'Red' - Say ' Something was installed or upgraded on this server by hand. Re-run the' 'Yellow' - Say ' installer to put the shipped set back.' 'Yellow' - } - - & $py -m pip check 2>&1 | ForEach-Object { Say " $_" 'DarkGray' } -} - -function Show-Uninstall { - Head 'Uninstall' - Say ' Use Settings > Apps > ShopDB-Flask, or Add/Remove Programs.' - Say '' - Say ' That removes the web site, application pool, firewall rule and files.' - Say ' It does NOT drop the database and does NOT uninstall MySQL.' 'Yellow' - Say ' Take a backup first: shopdb-admin.ps1 backup' 'Yellow' -} - -# Cached for the session. Test-Provisioned shells out to flask twice, which is -# far too slow to repeat on every redraw of the menu - and the answer does not -# change while somebody reads the screen. Cleared after a repair so the warning -# disappears once it is actually fixed. -$script:Attention = $null - -function Show-Attention { - <# - Say what is wrong, and which key fixes it. - - A server whose seeds never ran answers 500 on most pages, and until now the - console reported all of that as ordinary status lines that read as normal - unless you knew what to look for. The one thing an operator needs is the - next action. - #> - if ($null -eq $script:Attention) { - $state = Test-Provisioned - $script:Attention = @{ - Provisioned = -not (($state.SchemaCurrent -eq $false) -or ($state.Seeded -eq $false)) - Detail = $state.Detail - } - } - if (-not $script:Attention.Provisioned) { - Write-Host '' - Say ' ! This server is not fully provisioned - most pages will fail.' 'Red' - foreach ($detail in $script:Attention.Detail) { Say (" {0}" -f $detail) 'Red' } - Say ' Press F to fix it.' 'Yellow' - } -} - -function Show-Menu { - $first = $true - while ($true) { - if ($first) { Show-Banner; $first = $false } - Show-Status - Show-Attention - Write-Host '' - Write-Host ' APPLICATION DATA DIAGNOSE' -ForegroundColor DarkGray - Write-Host ' R Restart B Back up H Health check' -ForegroundColor White - Write-Host ' S Stop C Load catalog L Recent logs' -ForegroundColor White - Write-Host ' T Start P Plugins W Processes' -ForegroundColor White - Write-Host ' O Open in browser F Repair V Verify install' -ForegroundColor White - Write-Host '' - Write-Host ' Q Quit' -ForegroundColor White - Write-Host '' - $c = Read-Host ' Choose' - # The old numbers still work. Somebody who has used this for months - # should not be stopped by a menu that was rearranged under them. - switch ($c.ToUpper()) { - 'R' { Restart-App } '1' { Restart-App } - 'S' { Stop-App } '2' { Stop-App } - 'T' { Start-App } '3' { Start-App } - 'L' { Show-Logs } '4' { Show-Logs } - 'H' { Invoke-Check } '5' { Invoke-Check } - 'B' { Backup-Db $Path } '6' { Backup-Db $Path } - 'W' { Show-Sessions } '7' { Show-Sessions } - 'O' { Open-Site } '8' { Open-Site } - 'V' { Invoke-Verify } - 'F' { Invoke-Repair; $script:Attention = $null } - 'C' { Invoke-Catalog } - 'P' { Show-Plugins - $add = Read-Host ' Name of a shipped plugin to add (Enter to skip)' - if ($add) { Add-Plugin $add } } - '9' { Show-Plugins - $add = Read-Host ' Name of a shipped plugin to add (Enter to skip)' - if ($add) { Add-Plugin $add } } - 'Q' { return } '0' { return } - default { Say ' not a choice' 'Yellow' } - } - Write-Host '' - Read-Host ' Press Enter to continue' | Out-Null - Clear-Host - Show-Banner - } -} - -switch ($Command) { - 'status' { Show-Banner; Show-Status } - 'start' { Start-App } - 'stop' { Stop-App } - 'restart' { Restart-App } - 'logs' { Show-Logs } - 'open' { Open-Site } - 'backup' { Backup-Db $Path } - 'check' { Invoke-Check } - 'repair' { Invoke-Repair } - 'catalog' { Invoke-Catalog } - 'sessions' { Show-Sessions } - 'plugins' { Show-Plugins } - 'add-plugin'{ Add-Plugin $Path } - 'verify' { Invoke-Verify } - 'uninstall' { Show-Uninstall } - default { Show-Menu } -} diff --git a/deploy/windows/installer/shopdb-install.ps1 b/deploy/windows/installer/shopdb-install.ps1 deleted file mode 100644 index afe9e052..00000000 --- a/deploy/windows/installer/shopdb-install.ps1 +++ /dev/null @@ -1,2805 +0,0 @@ -<# -.SYNOPSIS - ShopDB-Flask offline installer for Windows (IIS + HttpPlatformHandler + waitress). - -.DESCRIPTION - Automates docs/INSTALL-WINDOWS-IIS.md. Fully offline: every prerequisite comes - from the bundle directory. Never contacts the network. - - Stages (each can be run alone with -Stage): - 1 preflight - read-only discovery (shopdb-preflight.ps1) - 2 runtime - Python, venv, wheels, .env - 3 data - schema, seeds, plugins - 4 iis - app pool, ACLs, site, firewall - 5 verify - smoke test + handoff - all - 2,3,4,5 in order (default) - - Written for stock Windows PowerShell 5.1. No pwsh-only syntax, no modules, - no network. - -.PARAMETER BundleRoot - Directory holding the offline bundle. Expected layout: - python\python-3.14.x-amd64.exe - wheels\*.whl - app\ (release tree: wsgi.py, shopdb\, plugins\, frontend\dist\, ...) - httpplatformhandler\httpPlatformHandler_amd64.msi - urlrewrite\ (optional, -ClientIpSource direct) - mysql\ (optional, bundled-database option) - bundle-lock.json + bundle-lock.ps1 - - The payload is checked against bundle-lock.json before anything runs. A - bundle whose wheels or MSIs do not match the lock exactly - missing, extra or - altered - is refused, because every one of them executes as SYSTEM here. - -.PARAMETER DbHost - Existing-MySQL option: the server hostname. The PASSWORD IS NEVER A PARAMETER - - it is prompted for as a SecureString, because Windows command lines are - readable by any user (Win32_Process) and are captured in PowerShell - transcripts and history. If a valid .env already exists it is reused and - nothing is prompted. - -.EXAMPLE - .\shopdb-install.ps1 -BundleRoot D:\shopdb-bundle -SiteHost shopdb.plant.local - .\shopdb-install.ps1 -Stage 4 -BundleRoot D:\shopdb-bundle - -.NOTES - Idempotent. Re-running preserves an existing .env (regenerating JWT_SECRET_KEY - would invalidate every issued session). -#> - -[CmdletBinding()] -param( - # Stage 0 installs the bundled MySQL 8.4 LTS (greenfield sites only). It is NOT in - # 'all': a site with an existing MySQL must never have a second server dropped - # on top of it, and that is the common case (docs/DEPLOY-WINDOWS-IIS.md: the - # target box already runs the classic ASP shopdb against MySQL 5.6). Run it - # explicitly, and only when preflight reports no MySQL on 3306. - [ValidateSet('0','1','2','3','4','5','all','uninstall')] [string] $Stage = 'all', - # Bundled-MySQL (stage 0) settings. The root and app passwords are GENERATED, - # never supplied: see New-Secret. Root is shown once and not persisted. - # SPACE-FREE, deliberately - same reason as the Python target below. msiexec - # takes public properties as PROPERTY=value on the command line, and a value - # containing spaces has to be quoted as INSTALLDIR="C:\..." - quoting the - # whole PROPERTY=value token instead makes msiexec reject the command line - # and pop its usage dialog, which is what 'the bundled MySQL database could - # not be installed' looked like from the outside. - [string] $MysqlRoot = 'C:\MySQL84', - [string] $MysqlDataDir = 'C:\ProgramData\MySQL\data', - [string] $MysqlIni = 'C:\ProgramData\MySQL\my.ini', - [string] $MysqlService = 'MySQL84', - [Parameter(Mandatory=$true)] [string] $BundleRoot, - [string] $AppRoot = 'C:\shopdb-flask', - [int] $SitePort = 8090, - [string] $SiteName = 'shopdb-flask', - [string] $AppPool = 'shopdbflask', - [string] $SiteHost = '', # for CORS_ORIGINS; defaults to this machine - # Deliberately NOT a password parameter. Command lines are readable by any - # user via Win32_Process and are captured in PowerShell transcripts. - # Supply host/user/db here; the password is prompted for. - [string] $DbHost = '', - [int] $DbPort = 3306, - [string] $DbName = 'shopdb_flask', - [string] $DbUser = 'shopdb', - # Unattended installs only. Path to a file whose FIRST LINE is the DB password. - # The installer reads it, overwrites it and deletes it. ACL the file to - # SYSTEM + Administrators before writing it. Omit this for an interactive run - # and the password is prompted for instead. It is never a parameter value: - # command lines are world-readable via Win32_Process and are captured in - # PowerShell transcripts and ConsoleHost_history.txt. - [string] $DbPasswordFile = '', - # The site's plugin set is DECLARED in a profile (ADR-013), not listed here. - # The bundle is built lean for that same profile by scripts/build-site.sh, so - # a plugin the site did not choose is absent from the backend tree entirely. - # That absence is what makes core's `try: from plugins.X.models import ... - # except ImportError` guards correct: the import genuinely fails, no - # relationship backref is created, and no query joins the missing table. - # - # Do NOT go back to installing a fixed list against a full tree. That was the - # cause of the 500s on /api/assets and /api/dashboard/summary - # ("Table 'shopdb_flask.measuringtools' doesn't exist"): the plugin's CODE - # shipped regardless, so the import succeeded, SQLAlchemy created the - # Asset.measuringtool backref and LEFT OUTER JOINed a table that only exists - # if the plugin was installed. - [string] $SiteProfile = '', - # Comma-separated plugin names chosen at INSTALL time (the wizard's plugin - # page). When supplied this REWRITES site-profile.json before apply-profile - # runs, so the operator's choice wins over whatever the bundle was built with. - # Omit it and the profile shipped in the bundle is used unchanged. - [string] $SitePlugins = '', - # Subpath deployment (docs/INSTALL-WINDOWS-IIS.md method B). Empty = the app - # gets its own IIS site on -SitePort (method A, the default). Set to an alias - # such as 'shopdb' and it becomes an IIS Application under -ParentSite, i.e. - # http:///shopdb/ - no new DNS record, no port in the URL. - # The alias MUST match the one the bundle's subpath SPA was built with; the - # installer checks that and refuses if they disagree. - [string] $MountAlias = '', - [string] $ParentSite = 'Default Web Site', - # How this server learns a request's real client IP. - # - # direct IIS is exposed to clients. Install URL Rewrite from the bundle - # and set X-Forwarded-For from REMOTE_ADDR. Overwriting the header - # is what stops a client spoofing it. - # proxy A reverse proxy (ARR, load balancer) sits in front and already - # sets X-Forwarded-For. Leave the rule off - REMOTE_ADDR would be - # the proxy, so applying it would DESTROY the real client IP. - # - # Without one or the other, IIS sends no X-Forwarded-For at all and every - # client reads as 127.0.0.1: the GE-Enforce IP allowlist, the dashboard - # visitor-location lookup and per-host login rate limiting all break quietly. - [ValidateSet('direct','proxy')] [string] $ClientIpSource = 'direct', - # Load the shared vendor/model catalog during stage 3. Offered by the - # wizard rather than assumed: a site that machines nothing does not want - # 21 machine types cluttering its dropdowns. - [switch] $SeedCatalog, - # Required before this installer will alter an installation it did not - # create. Everything here is built for a greenfield server: it makes its own - # Python, its own venv and its own IIS objects, and its upgrade path assumes - # the thing it is upgrading came out of a previous run. - # - # Pointed at a server that was deployed by hand, the IIS reconciliation would - # DELETE the existing application or site as part of switching deployment - # method - silently, because removing the artifact of the method you are not - # using is correct behaviour when the installer owns both. It is not correct - # when someone else built it. So it asks first. - [switch] $AdoptExisting, - [switch] $WhatIfOnly, - # Unattended runs cannot answer a prompt. Choose the failure behaviour up front. - [ValidateSet('ask','always','never')] [string] $OnFailure = 'ask' -) - -Set-StrictMode -Version 2.0 -$ErrorActionPreference = 'Stop' - -# NOT $env:TEMP. When this runs under the Inno wrapper, TEMP points at Setup's -# own extraction directory, which is deleted when Setup exits - so the log the -# operator is told to send to support disappears with it, and a failed install -# leaves nothing to diagnose. ProgramData persists and is admin-writable. -$script:LogDir = Join-Path $env:ProgramData 'ShopDB-Flask\logs' -try { if (-not (Test-Path $script:LogDir)) { New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null } } -catch { $script:LogDir = $env:TEMP } -$script:LogPath = Join-Path $script:LogDir ("shopdb-install-{0}.log" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) -$script:Created = New-Object System.Collections.ArrayList # for rollback -$script:RebootPending = $false -$script:MysqlAlreadyOurs = $false -# Was this server already stamped by a previous run of THIS installer, as observed -# BEFORE stage 2 writes its own stamp? Stage 4 needs the answer to know whether the -# IIS objects it is about to reconcile are its own. $null means nothing has looked -# yet, which is the case when stage 4 is run on its own. -$script:PreexistingInstallStamp = $null - -function Write-Log { - param([string] $Message, [string] $Level = 'INFO') - $line = "{0} [{1}] {2}" -f (Get-Date -Format 'HH:mm:ss'), $Level, $Message - Add-Content -Path $script:LogPath -Value $line - $colour = 'Gray' - if ($Level -eq 'OK') { $colour = 'Green' } - if ($Level -eq 'WARN') { $colour = 'Yellow' } - if ($Level -eq 'FAIL') { $colour = 'Red' } - if ($Level -eq 'STEP') { $colour = 'Cyan' } - Write-Host $line -ForegroundColor $colour -} - -function Fail { - param([string] $Message, [string] $Fix = '') - Write-Log $Message 'FAIL' - if ($Fix) { Write-Log " fix: $Fix" 'FAIL' } - Write-Log "log: $script:LogPath" - throw $Message -} - -# ----------------------------------------------------------------------------- -# DURABLE INSTALL RECORD -# -# "Is this a re-run of MY install?" used to be answered from whatever the machine -# happened to look like: a MySQL service exists, the database has tables, the -# site exists, the venv exists. None of those record WHO created them, so a retry -# after a failed first attempt was repeatedly mistaken for an upgrade of somebody -# else's working system. That single confusion produced several separate dead -# ends, the worst being a failed first install demanding a mandatory backup of -# its own half-written database and then refusing to prune tables it had created -# itself minutes earlier. -# -# This file answers the question instead. It is written when provisioning -# STARTS, not when it finishes, so a run that dies halfway still leaves a record -# the next run can read - which is the whole point, because the run that dies -# halfway is the one whose retry needs to know. -# ----------------------------------------------------------------------------- -$script:StateFile = Join-Path $env:ProgramData 'ShopDB-Flask\install-state.json' -$script:InstallState = $null - -function Get-StateValue { - # Set-StrictMode makes reading a missing property a terminating error, so - # every read of a file that may be older, hand-edited or truncated goes - # through here rather than dotting into it directly. - param($Object, [string] $Name) - if ($null -eq $Object) { return $null } - $prop = $Object.PSObject.Properties[$Name] - if ($null -eq $prop) { return $null } - return $prop.Value -} - -function Save-InstallState { - param($State) - # Never fatal. The record is an improvement on guessing; failing to write it - # only returns the installer to the behaviour it had before, so a permissions - # problem here must not stop an install that is otherwise fine. - try { - $dir = Split-Path $script:StateFile -Parent - if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } - ($State | ConvertTo-Json -Depth 6) | Set-Content -Path $script:StateFile -Encoding UTF8 - Protect-File $script:StateFile - } catch { - Write-Log "could not write the install record: $($_.Exception.Message)" 'WARN' - } -} - -function Get-InstallState { - if (-not (Test-Path $script:StateFile)) { return $null } - try { - $raw = Get-Content $script:StateFile -Raw -ErrorAction Stop - if (-not $raw) { return $null } - $obj = $raw | ConvertFrom-Json -ErrorAction Stop - } catch { - Write-Log 'the install record is unreadable; falling back to inspecting the server' 'WARN' - return $null - } - # A record describing a DIFFERENT directory says nothing about this one. - $recorded = Get-StateValue $obj 'approot' - if ($recorded -and -not (Test-SamePath $recorded $AppRoot)) { return $null } - return $obj -} - -function Initialize-InstallState { - $state = Get-InstallState - if ($null -eq $state) { - # No record at all. Either this is a genuinely new server, or an install - # that predates this file. Distinguish by the version stamp, and when in - # doubt choose the SAFE side: an established install, which keeps the - # mandatory pre-upgrade backup. Guessing "first run" for a server holding - # real data would let prune --force loose on it. - $stamped = Test-Path (Join-Path $AppRoot '.installed-version') - $state = [PSCustomObject]@{ - schema = 1 - approot = $AppRoot - firstrunstarted = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - firstruncompleted = $(if ($stamped) { 'predates-this-record' } else { $null }) - created = [PSCustomObject]@{} - } - if ($stamped) { - Write-Log 'no install record found, but this server carries a version stamp' 'WARN' - Write-Log ' treating it as an established install, so the pre-upgrade backup stays mandatory' 'WARN' - } - Save-InstallState $state - } - $script:InstallState = $state -} - -function Test-FirstProvisioning { - # TRUE only while OUR first provisioning of this directory is unfinished - - # including every retry of it. Whatever is in the database at that point was - # put there by an earlier attempt of this same install, not by a running site. - if ($null -eq $script:InstallState) { return $false } - return ($null -eq (Get-StateValue $script:InstallState 'firstruncompleted')) -} - -function Complete-InstallState { - if ($null -eq $script:InstallState) { return } - if ($null -ne (Get-StateValue $script:InstallState 'firstruncompleted')) { return } - $script:InstallState.firstruncompleted = - (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - Save-InstallState $script:InstallState -} - -function Track { - param([string] $Kind, [string] $Id) - $null = $script:Created.Add(@{Kind=$Kind; Id=$Id}) - # Persist as well. $script:Created lives only in memory, so a run that died - # left nothing behind saying what it had already made, and the next run was - # back to inferring it from the machine. - try { - if ($null -ne $script:InstallState) { - $created = Get-StateValue $script:InstallState 'created' - if ($null -eq $created) { - $created = [PSCustomObject]@{} - $script:InstallState | Add-Member -NotePropertyName 'created' -NotePropertyValue $created -Force - } - $list = @(Get-StateValue $created $Kind | Where-Object { $_ }) - if ($list -notcontains $Id) { - $created | Add-Member -NotePropertyName $Kind -NotePropertyValue (@($list) + $Id) -Force - Save-InstallState $script:InstallState - } - } - } catch { } -} - -function Get-CreatedItems { - # What THIS installer recorded creating, by kind. Empty when there is no - # record, which callers must treat as "unknown", never as "nothing". - # - # The leading comma is load-bearing. Returning a zero-length array from a - # PowerShell function UNROLLS it to $null, so the caller gets $null and - # $null.Count is a terminating error under Set-StrictMode - the exact fault - # that made bundle verification fail on every install earlier. Comma-wrapping - # emits the array itself as a single object, so an empty result stays an - # empty array. - param([string] $Kind) - if ($null -eq $script:InstallState) { return ,@() } - $created = Get-StateValue $script:InstallState 'created' - return ,@(Get-StateValue $created $Kind | Where-Object { $_ }) -} - -function Invoke-Native { - # Runs an external command and fails loudly. - # - # Three PowerShell 5.1 traps are handled here, each found the hard way on a - # real Server 2025 box: - # 1. `& $exe @array` can collapse the array into ONE argument. Start-Process - # -ArgumentList binds arrays correctly. - # 2. Native tools write progress to stderr on SUCCESS, and - # $ErrorActionPreference='Stop' turns that into a terminating error. - # 3. Argument VALUES must never be logged: a DATABASE_URL or password would - # land in the log we invite operators to send to support. - # 4. Some installers succeed with a NON-ZERO code. 3010 and 1641 both mean - # "done, reboot required" - the VC++ redistributable returns 3010 on a - # server with a pending file rename, which is an ordinary state on a - # freshly patched box. Treating it as failure aborts a correct install. - param([string] $Exe, [string[]] $Arguments, [string] $What, [string] $StdinFile = '', - [int] $TimeoutSec = 0, [int[]] $OkExit = @(0)) - Write-Log (" exec {0} ({1} args)" -f (Split-Path $Exe -Leaf), $Arguments.Count) - # Start-Process -ArgumentList joins the array with spaces WITHOUT quoting, so - # any argument containing whitespace gets split into several. That is how - # "IIS AppPool\name:(OI)(CI)RX" became the parameter "IIS" (icacls exit 87), - # and how TargetDir=C:\Program Files\... installed Python into C:\Program\. - $quoted = $Arguments | ForEach-Object { - if ($_ -notmatch '\s' -or $_ -match '^".*"$') { $_ } - # PROPERTY=value (msiexec, msbuild): the VALUE is quoted, never the whole - # token. "INSTALLDIR=C:\Program Files\..." is rejected outright, whereas - # INSTALLDIR="C:\Program Files\..." is what the tool expects. - elseif ($_ -match '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { '{0}="{1}"' -f $Matches[1], $Matches[2] } - else { '"' + $_ + '"' } - } - $so = [System.IO.Path]::GetTempFileName() - $se = [System.IO.Path]::GetTempFileName() - try { - # NO -Wait when a timeout is wanted. -Wait blocks inside Start-Process - # until the child exits, so by the time the timeout check below ran the - # process had ALWAYS exited and the whole block was dead code - every - # -TimeoutSec on every MSI call was doing nothing. Start without -Wait - # and do the waiting here, where it can be bounded. - $common = @{ FilePath = $Exe; ArgumentList = $quoted; PassThru = $true - NoNewWindow = $true - RedirectStandardOutput = $so; RedirectStandardError = $se } - if ($StdinFile) { - # Feeding SQL as a file on stdin, not as -e "source ": the mysql - # client reads that path as a DATABASE NAME and fails with - # ERROR 1049 Unknown database ''. - $common['RedirectStandardInput'] = $StdinFile - } - # -Wait ALWAYS. Dropping it to make -TimeoutSec enforceable left - # $p.ExitCode unreadable: a Python install that had actually worked - # reported "Python install failed (exit )" with an empty code, because - # PowerShell only reliably populates ExitCode on a waited process. - # - # So the trade is deliberate. Exit codes are load-bearing here - 1639 vs - # 1603 vs 3010 is the whole diagnosis - and a bounded wait is not worth - # losing them for. -TimeoutSec is therefore ADVISORY: it is recorded in - # the log so a hang is identifiable, but the wait is unbounded. - $common['Wait'] = $true - if ($TimeoutSec -gt 0) { Write-Log (" (expected to finish within {0}s)" -f $TimeoutSec) } - $p = Start-Process @common - - # NOTE: msiexec can deadlock when another Windows Installer transaction - # holds the global mutex - two msiexec processes once sat for 40 minutes - # with no output and no error. That is why the expected duration is - # logged above: an operator seeing no progress well past it should check - # for stray msiexec processes (Get-Process msiexec) and end them. - $out = @() - if (Test-Path $so) { $out += Get-Content $so -ErrorAction SilentlyContinue } - if (Test-Path $se) { $out += Get-Content $se -ErrorAction SilentlyContinue } - $code = $null - try { $code = $p.ExitCode } catch { } - if ($null -eq $code) { - # Never silently treat "cannot tell" as success. - $out | Select-Object -Last 8 | ForEach-Object { Write-Log " $_" 'FAIL' } - Fail "$What finished but its exit code could not be read" ` - 'Check the log above and the tool''s own log before re-running.' - } - if ($OkExit -notcontains $code) { - $out | Select-Object -Last 8 | ForEach-Object { Write-Log " $_" 'FAIL' } - Fail "$What failed (exit $code)" - } - if ($code -ne 0) { - # Accepted, but say so: 3010/1641 mean the server needs a reboot to - # finish, and an operator who never sees that wonders later why - # something only half works. - Write-Log ("$What returned {0} - completed, but this server needs a reboot to finish" -f $code) 'WARN' - $script:RebootPending = $true - } - return $out - } - finally { Remove-Item $so, $se -Force -ErrorAction SilentlyContinue } -} - -function Protect-File { - # Owner-only ACL: Administrators + SYSTEM, inheritance broken. - # - # -Directory additionally makes the two grants INHERITABLE. Without (OI)(CI) - # the grants apply to the directory alone, so files written into it later - - # backups taken by the console, not by this script - are not covered. - param([string] $Path, [switch] $Directory) - $admins = 'BUILTIN\Administrators:(F)' - $system = 'NT AUTHORITY\SYSTEM:(F)' - if ($Directory) { - $admins = 'BUILTIN\Administrators:(OI)(CI)(F)' - $system = 'NT AUTHORITY\SYSTEM:(OI)(CI)(F)' - } - Invoke-Native 'icacls.exe' @($Path,'/inheritance:r','/grant',$admins,'/grant',$system) 'ACL' -} - -function Protect-InstallRoot { - # A directory created directly under C:\ INHERITS the root's DACL, and the - # default C:\ DACL carries an inherit-only Modify grant for Authenticated - # Users. So C:\shopdb-flask, C:\Python314 and C:\MySQL84 are writable by - # every logged-on user until inheritance is broken. - # - # That is a local privilege escalation here specifically, because this - # installer puts two things a non-admin could then replace inside those - # roots: shopdb-admin.ps1, which self-elevates with -Verb RunAs, and - # instance\config.py, which the app loads unconditionally through - # from_pyfile. Overwrite either, wait for an administrator or the app pool - # to run it, and the code runs as them. - # - # RE-APPLIED EVERY TIME, not only on creation - same reasoning as the - # ProgramData backups directory. An upgrade over an installation that - # predates this must repair the ACL, or the hole survives the fix. - # - # The app pool's own RX/Modify grants are applied in stage 4, AFTER this, - # so breaking inheritance here does not lock the site out of its own tree. - param([string] $Path) - if (-not (Test-Path $Path)) { return } - Write-Log "hardening $Path (breaking inheritance from its drive root)" - Protect-File $Path -Directory -} - -function Test-UserWritableRoot { - # Whether a non-admin can write to a path. Used ONLY on roots this - # installer did not create: hardening a Python another application already - # depends on would break that application, so a pre-existing root is - # reported rather than seized. Silence would be worse - the app would be - # running on an interpreter any user can replace. - param([string] $Path) - if (-not (Test-Path $Path)) { return $false } - $acl = & icacls.exe $Path 2>$null - return [bool]($acl -match '(Authenticated Users|BUILTIN\\Users|Everyone):\([^)]*\)?\(?(M|F|W)\)') -} - -function New-Secret { - param([int] $Bytes = 48) - $b = New-Object byte[] $Bytes - [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b) - # URL-safe, no padding: avoids characters that need escaping in .env or URLs. - return [Convert]::ToBase64String($b).Replace('+','-').Replace('/','_').TrimEnd('=') -} - -function Resolve-IisPath { - # IIS stores physicalPath as it was typed, so it can carry %SystemDrive%-style - # variables and a trailing backslash. A raw string compare therefore misses - # paths that ARE ours - on a hand-built deployment that means stage 4 fails to - # recognise its own application, and the uninstaller fails to recognise what - # it may remove. - param([string] $Path) - if (-not $Path) { return '' } - return [System.Environment]::ExpandEnvironmentVariables($Path).TrimEnd('\').ToLowerInvariant() -} - -function Test-SamePath { - param([string] $A, [string] $B) - return (Resolve-IisPath $A) -eq (Resolve-IisPath $B) -} - -# Paths derived once. -# Default so a stage that runs on its own still knows where the interpreter is; -# the Python stage overwrites it with what it actually installed. -$script:PyRoot = 'C:\Python314' -$Py = Join-Path $AppRoot 'venv\Scripts\python.exe' -$Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe' -$Pip = Join-Path $AppRoot 'venv\Scripts\pip.exe' -$EnvFile = Join-Path $AppRoot '.env' -$WheelDir = Join-Path $BundleRoot 'wheels' -$AppSource = Join-Path $BundleRoot 'app' - -# Dot-sourced HERE, at script scope, not inside Assert-BundleIntegrity. Dot- -# sourcing inside a function loads into that FUNCTION's scope, so every helper -# it defines - Get-JsonProperty, Read-BundleLock, Test-BundleLock - disappears -# the moment the function returns. Assert-BundleIntegrity itself worked; the -# next caller, Get-WheelhousePythonTag, died with "The term 'Get-JsonProperty' -# is not recognized" on any run where a venv already existed, which is every -# retry after a part-completed install. -# -# Absent on a stage that runs without the bundle (uninstall), so this is a -# guarded load and Assert-BundleIntegrity still fails loudly if it is missing. -$BundleLockLib = Join-Path $BundleRoot 'bundle-lock.ps1' -if (Test-Path $BundleLockLib) { . $BundleLockLib } - - -# ============================================================================= -# Version awareness and database safety (upgrades) -# ============================================================================= -# An upgrade is just this installer run against an existing install. That is -# only safe if three things are true, and none of them were before: -# - it knows whether it is going forwards or backwards; -# - the database is backed up BEFORE migrations touch it; -# - a failed migration puts the backup back rather than leaving a half-state. - -function Assert-BundleIntegrity { - <# - Refuse to run against a payload that is not exactly what was built and - reviewed. - - This runs BEFORE anything is installed, because everything downstream is an - executable that runs as SYSTEM on this server: the Python installer, the - HttpPlatformHandler and URL Rewrite MSIs, and ~40 wheels. requirements.txt - hashes cover the wheels once pip gets to them; nothing covered the rest, - and nothing noticed a stale extra wheel sitting in the wheelhouse. - - There is deliberately no override. A bundle that fails here was altered - after it was built, and the fix is to get a correct bundle rather than to - wave this one through on a server nobody can see. - #> - $checker = Join-Path $BundleRoot 'bundle-lock.ps1' - $lockFile = Join-Path $BundleRoot 'bundle-lock.json' - if (-not (Test-Path $checker)) { - Fail 'bundle-lock.ps1 is missing from the bundle' ` - 'This bundle was not assembled by build-installer.ps1/.sh. Rebuild it.' - } - if (-not (Test-Path $lockFile)) { - Fail 'bundle-lock.json is missing from the bundle' ` - 'This bundle was not assembled by build-installer.ps1/.sh. Rebuild it.' - } - $lock = Read-BundleLock $lockFile - # NO @() here. Test-BundleLock returns ,$problems, which already hands the - # array back intact - wrapping it again nests it, and .Count becomes 1 no - # matter how many problems there are. That turns a byte-perfect bundle into - # "1 problem(s)" on every install. Applying the same guard at both ends was - # worse than applying it at neither. - $problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock - if ($problems.Count -gt 0) { - foreach ($p in $problems) { Write-Log " $p" 'FAIL' } - Fail ("the bundle does not match bundle-lock.json ({0} problem(s))" -f $problems.Count) @' -The payload was changed after this installer was built. Do not install from it. -Obtain a bundle whose payload matches its lock, or rebuild one and re-compile. -'@ - } - Write-Log ("bundle payload verified against bundle-lock.json ({0}, {1})" -f ` - (Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'OK' -} - -function Get-WheelhousePythonTag { - # Which Python the wheelhouse was built for. The lock is authoritative; a - # bundle predating it is read from the wheel filenames instead. - $lockFile = Join-Path $BundleRoot 'bundle-lock.json' - if (Test-Path $lockFile) { - $tag = Get-JsonProperty (Get-Content $lockFile -Raw | ConvertFrom-Json) 'pythontag' - if ($tag -and ($tag -match '^cp(\d)(\d+)$')) { return ('{0}.{1}' -f $Matches[1], $Matches[2]) } - } - $wheel = Get-ChildItem $WheelDir -Filter '*.whl' -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '-cp(\d)(\d+)-' } | Select-Object -First 1 - if ($wheel -and ($wheel.Name -match '-cp(\d)(\d+)-')) { return ('{0}.{1}' -f $Matches[1], $Matches[2]) } - return '' -} - -function Assert-VenvMatchesWheelhouse { - <# - An existing venv is REUSED rather than rebuilt, which is right for a repair - or an upgrade of an install this made. It is wrong when the venv belongs to - a different Python: the wheelhouse is tagged for one minor version, so pip - finds no candidate for cffi, cryptography, greenlet or mysql-connector and - dies partway through - after Python has already been installed and the app - tree already replaced. - - Fail before any of that, and say which two versions disagree. - #> - if (-not (Test-Path $Py)) { return } # greenfield: nothing to disagree with - $wanted = Get-WheelhousePythonTag - if (-not $wanted) { return } # unknowable; the pip failure will have to do - - $found = '' - try { - $found = (& $Py -c "import sys; print('%d.%d' % sys.version_info[:2])" 2>$null | Select-Object -First 1) - if ($found) { $found = $found.Trim() } - } catch { return } - if (-not $found -or ($found -eq $wanted)) { return } - - Fail ("the existing venv is Python {0}, but this bundle's wheelhouse is for {1}" -f $found, $wanted) @' -The wheelhouse is locked to one Python minor version. Installing it into a venv -built by a different one fails at the first compiled package, halfway through. - -On a server this installer built: delete APP_ROOT\venv and re-run. The venv is -rebuilt from the bundle and holds nothing of yours. - -On a server someone else built: this is not an upgrade, it is a runtime change. -Take a database backup first, and expect to reconcile web.config by hand. -'@ -} - -function Get-BundleVersion { - # The version being installed, read from the payload itself so it can never - # disagree with the code that is about to be copied. - $init = Join-Path $AppSource 'shopdb\__init__.py' - if (-not (Test-Path $init)) { return '' } - $m = Select-String -Path $init -Pattern "^__version__\s*=\s*'([^']+)'" | Select-Object -First 1 - if ($m) { return $m.Matches[0].Groups[1].Value } - return '' -} - -function Get-InstalledVersion { - $f = Join-Path $AppRoot '.installed-version' - if (Test-Path $f) { return (Get-Content $f -TotalCount 1).Trim() } - # An install predating version stamping still has an app tree. - if (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py')) { return 'unknown' } - return '' -} - -function Compare-Version { - # -1 / 0 / 1, comparing dotted numeric versions. 'unknown' sorts as older so - # a stamped bundle over an unstamped install is treated as an upgrade. - param([string] $A, [string] $B) - if ($A -eq $B) { return 0 } - if ($A -eq 'unknown' -or $A -eq '') { return -1 } - if ($B -eq 'unknown' -or $B -eq '') { return 1 } - $x = @($A -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') }) - $y = @($B -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') }) - for ($i = 0; $i -lt [Math]::Max($x.Count, $y.Count); $i++) { - $xi = if ($i -lt $x.Count) { $x[$i] } else { 0 } - $yi = if ($i -lt $y.Count) { $y[$i] } else { 0 } - if ($xi -gt $yi) { return 1 } - if ($xi -lt $yi) { return -1 } - } - return 0 -} - -function Find-MysqlTool { - param([string] $Name) # mysql.exe or mysqldump.exe - # The BUNDLE first, then the install directory, then the local server. - # - # A site whose MySQL lives on another host has no client installed here at - # all, so mysqldump was never found - which meant every upgrade skipped the - # pre-upgrade backup, and it did so AFTER stage 2 had stopped the pool and - # replaced the application tree. Shipping the client makes the backup work - # on a remote-database site, which is the case that needs it most. - $bundled = @( - (Join-Path $BundleRoot ('mysqlclient\' + $Name)), - (Join-Path $AppRoot ('mysqlclient\' + $Name)) - ) - foreach ($b in $bundled) { if (Test-Path $b) { return $b } } - - # C:\MySQL84 first: that is where the bundled server installs (space-free, - # see $MysqlRoot). The Program Files paths cover a server MySQL put there - # by hand or by an older build. - $roots = @('C:\MySQL84', 'C:\Program Files\MySQL', 'C:\mysql56\bin', 'C:\Program Files (x86)\MySQL') - foreach ($r in $roots) { - if (Test-Path $r) { - $hit = Get-ChildItem $r -Filter $Name -Recurse -ErrorAction SilentlyContinue | - Select-Object -First 1 - if ($hit) { return $hit.FullName } - } - } - return '' -} - -function Get-DbFromEnv { - $line = Get-Content $EnvFile -ErrorAction SilentlyContinue | - Where-Object { $_ -like 'DATABASE_URL=*' } | Select-Object -First 1 - if ($line -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') { - return @{ User = $Matches[1]; Pass = [uri]::UnescapeDataString($Matches[2]) - Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] } - } - return $null -} - - -function Test-DatabaseEmpty { - <# - Is the TARGET DATABASE empty right now? - - This - not the presence of C:\shopdb-flask - is what decides whether it is - safe to force-drop tables. A rebuilt server pointed at a site's existing, - populated database has no app directory, so filesystem-based detection - called it a "fresh install" and ran prune-schema --yes --force, destroying - the rows of every unselected plugin with no backup taken. - - Returns $true ONLY when we positively confirm zero tables. Anything we - cannot determine returns $false, because the failure mode of guessing - "empty" is irreversible data loss and the failure mode of guessing - "populated" is merely a redundant backup. - #> - $probe = Join-Path $env:TEMP ("shopdb-dbprobe-{0}.py" -f (Get-Date -Format 'HHmmssfff')) - @' -import os, sys -try: - from sqlalchemy import create_engine, inspect - url = None - for line in open(".env", encoding="utf-8", errors="replace"): - if line.startswith("DATABASE_URL="): - url = line.split("=", 1)[1].strip() - if not url: - print("UNKNOWN"); sys.exit(0) - print("EMPTY" if len(inspect(create_engine(url)).get_table_names()) == 0 else "POPULATED") -except Exception: - print("UNKNOWN") -'@ | Set-Content -Path $probe -Encoding ASCII - try { - Push-Location $AppRoot - $prev = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { $out = & $Py $probe 2>&1 } finally { $ErrorActionPreference = $prev; Pop-Location } - $verdict = ($out | Where-Object { $_ -match '^(EMPTY|POPULATED|UNKNOWN)$' } | Select-Object -Last 1) - Write-Log "database probe: $verdict" - return ($verdict -eq 'EMPTY') - } - finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue } -} - -function Backup-Database { - # Returns the backup path, or '' if it could not be taken. - param([string] $Reason = 'pre-upgrade') - $db = Get-DbFromEnv - if (-not $db) { Write-Log 'cannot read DATABASE_URL; skipping backup' 'WARN'; return '' } - $dump = Find-MysqlTool 'mysqldump.exe' - if (-not $dump) { Write-Log 'mysqldump not found; skipping backup' 'WARN'; return '' } - - # A dump contains every row, including the users table and its password - # hashes. ProgramData is readable by every user on the box by default, so the - # directory is locked to Administrators and SYSTEM the moment it is created. - $dir = Join-Path $env:ProgramData 'ShopDB-Flask\backups' - if (-not (Test-Path $dir)) { - New-Item -ItemType Directory -Path $dir -Force | Out-Null - } - # RE-APPLIED EVERY TIME, not only on creation. The console creates this - # directory too (shopdb-admin.ps1 backup), and one created there inherits - # ProgramData's Users:RX - so dumps containing the users table and its - # password hashes became readable by every authenticated user on the server. - # The old create-only guard meant the installer could never repair that. - Protect-File $dir -Directory - $file = Join-Path $dir ("{0}-{1}-{2}.sql" -f $db.Name, $Reason, (Get-Date -Format 'yyyyMMdd-HHmmss')) - - Write-Log "backing up $($db.Name) before migrating" - # --single-transaction keeps it consistent without locking the whole server. - # Password goes in the argument array, never interpolated into a log line. - $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", - '--single-transaction', '--routines', '--triggers', $db.Name) - $so = [System.IO.Path]::GetTempFileName() - $se = [System.IO.Path]::GetTempFileName() - try { - # Start-Process -ArgumentList does NOT quote, so a password containing a - # space splits into several arguments and the dump fails. Quote anything - # with whitespace, exactly as Invoke-Native does. - $quoted = $args | ForEach-Object { - if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ } - } - Write-Log (" exec mysqldump.exe ({0} args)" -f $args.Count) - $p = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow ` - -RedirectStandardOutput $so -RedirectStandardError $se - if ($p.ExitCode -ne 0) { - Write-Log "mysqldump exited $($p.ExitCode); no usable backup" 'WARN' - return '' - } - - # VALIDATE before anything relies on this file. mysqldump can exit 0 and - # still leave a truncated dump - a full disk, or a table it could not - # read part-way through. A backup that is trusted but unusable is worse - # than no backup at all, because migrations then proceed on its strength. - if (-not (Test-Path $so)) { Write-Log 'mysqldump produced no file' 'WARN'; return '' } - $len = (Get-Item $so).Length - if ($len -lt 512) { - Write-Log "backup is only $len bytes; refusing to treat it as valid" 'WARN'; return '' - } - $tail = @(Get-Content $so -Tail 5 -ErrorAction SilentlyContinue) - if (-not ($tail -match 'Dump completed')) { - Write-Log 'backup lacks the mysqldump completion marker - it is truncated' 'WARN' - return '' - } - - Move-Item $so $file -Force - $mb = [math]::Round((Get-Item $file).Length / 1MB, 1) - Write-Log "backup written and verified complete: $file ($mb MB)" 'OK' - return $file - } - finally { Remove-Item $so, $se -Force -ErrorAction SilentlyContinue } -} - -function Restore-Database { - param([string] $BackupFile) - if (-not $BackupFile -or -not (Test-Path $BackupFile)) { - Write-Log 'no backup available to restore from' 'FAIL'; return $false - } - $db = Get-DbFromEnv - $mysql = Find-MysqlTool 'mysql.exe' - if (-not $db -or -not $mysql) { Write-Log 'cannot restore: client or .env missing' 'FAIL'; return $false } - Write-Log "restoring $($db.Name) from $BackupFile" 'WARN' - $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", $db.Name) - try { - # mysqldump emits DROP TABLE IF EXISTS before each CREATE, so replaying - # genuinely restores every table the dump contains. - Invoke-Native $mysql $args 'database restore' -StdinFile $BackupFile | Out-Null - } catch { Write-Log "restore failed: $($_.Exception.Message)" 'FAIL'; return $false } - - # What the replay CANNOT undo: tables the failed migration created that the - # dump knows nothing about. MySQL DDL is not transactional, so those CREATEs - # are already committed and no dump-replay removes them. - # - # We report them rather than dropping them. Dropping would mean deciding, on - # a machine holding a site's only copy of its data, that a table is safe to - # destroy - and the installer deliberately does not claim that authority. - # (Restoring into a side database instead is not available either: the app - # user has rights on this database only, not CREATE DATABASE.) - $dumped = @() - foreach ($line in (Get-Content $BackupFile -ErrorAction SilentlyContinue)) { - if ($line -match '^CREATE TABLE `([^`]+)`') { $dumped += $Matches[1] } - } - - $probe = Join-Path $env:TEMP ("shopdb-tables-{0}.py" -f (Get-Date -Format 'HHmmssfff')) - @' -import sys -try: - from sqlalchemy import create_engine, inspect - url = None - for line in open(".env", encoding="utf-8", errors="replace"): - if line.startswith("DATABASE_URL="): - url = line.split("=", 1)[1].strip() - for t in sorted(inspect(create_engine(url)).get_table_names()): - print("TABLE " + t) -except Exception: - pass -'@ | Set-Content -Path $probe -Encoding ASCII - $current = @() - try { - Push-Location $AppRoot - $prev = $ErrorActionPreference; $ErrorActionPreference = 'Continue' - try { $current = @(& $Py $probe 2>&1 | Where-Object { $_ -match '^TABLE ' } | - ForEach-Object { $_.Substring(6).Trim() }) } - finally { $ErrorActionPreference = $prev; Pop-Location } - } finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue } - - $residue = @($current | Where-Object { $dumped -notcontains $_ }) - - if ($residue.Count -eq 0) { - Write-Log 'data restored from backup; no leftover tables from the failed migration' 'OK' - } else { - # Say exactly what is true. The previous wording claimed the database was - # "restored to its previous state", which is a false assurance when the - # schema still carries changes the migration committed before it failed. - Write-Log 'data restored from backup, but the schema is NOT identical to before' 'WARN' - Write-Log (" {0} table(s) created by the failed migration remain:" -f $residue.Count) 'WARN' - $residue | ForEach-Object { Write-Log " $_" 'WARN' } - Write-Log ' they hold no data and are harmless to the running application,' 'WARN' - Write-Log ' but a DBA should drop them before the next upgrade attempt.' 'WARN' - } - Write-Log " backup used: $BackupFile" - return $true -} - -# ============================================================================= -# STAGE 0 - bundled MySQL 8.4 LTS (greenfield sites only) -# ============================================================================= -# The MSI installs BINARIES ONLY: no service, no data directory, no config. The -# full sequence is msiexec -> write my.ini -> --initialize-insecure -> --install -# -> Start-Service -> bootstrap SQL. Every step below has bitten us once. -function Invoke-Stage0 { - Write-Log 'STAGE 0: bundled MySQL 8.4 LTS' 'STEP' - - $msi = Get-ChildItem (Join-Path $BundleRoot 'mysql') -Filter '*.msi' -ErrorAction SilentlyContinue | - Select-Object -First 1 - if (-not $msi) { - Fail "no MySQL MSI in $BundleRoot\mysql" @' -Stage 0 installs the BUNDLED database. Add mysql\mysql-8.4.x-winx64.msi to the -bundle, or skip stage 0 and use the site's existing server (the common case). -'@ - } - - # Refuse to stack a second server on an existing one. Two servers fighting - # over 3306, or a silent takeover of the classic ASP app's database, is far - # worse than stopping here. - # OUR service is not "someone else's MySQL". This refused to run when the - # only service present was the MySQL84 that a previous stage 0 registered - - # so every retry of a part-completed bundled install dead-ended, while the - # wizard told the operator that re-running was safe. - $existing = @(Get-Service -Name 'MySQL*' -ErrorAction SilentlyContinue) - $foreign = @($existing | Where-Object { $_.Name -ne $MysqlService }) - if ($foreign.Count -gt 0) { - Fail ("MySQL service already present: {0}" -f (($foreign | ForEach-Object Name) -join ', ')) @' -This box already runs MySQL. Do NOT install the bundled server on top of it: -it would collide on port 3306 and could disrupt the existing application. -Skip stage 0 and point the installer at the existing server with -DbHost. -'@ - } - $ours = @($existing | Where-Object { $_.Name -eq $MysqlService }) - if ($ours.Count -gt 0) { - # Ours, from an earlier run. Bring it up if it is down and let the rest of - # the stage run: the bootstrap SQL is idempotent (CREATE ... IF NOT EXISTS - # plus ALTER USER), so it re-asserts the password and the grants and - # leaves .dbpass agreeing with the server. - Write-Log "$MysqlService is already registered by a previous run" 'OK' - if ($ours[0].Status -ne 'Running') { - Write-Log "starting $MysqlService" - Start-Service -Name $MysqlService -ErrorAction SilentlyContinue - Start-Sleep -Seconds 3 - } - $script:MysqlAlreadyOurs = $true - } - $inUse = Get-NetTCPConnection -LocalPort $DbPort -State Listen -ErrorAction SilentlyContinue - if ($inUse) { Fail "port $DbPort is already in use" 'Free the port, or use the existing server.' } - - if ($WhatIfOnly) { Write-Log 'WhatIf: skipping MySQL install' 'WARN'; return } - - # --- binaries ----------------------------------------------------------- - $mysqld = Join-Path $MysqlRoot 'bin\mysqld.exe' - if (Test-Path $mysqld) { - # Re-running stage 0 after a partial failure must not re-run the MSI. - # Reinstalling an already-registered product is a no-op that STILL needs - # the Windows Installer service, and if msiserver is stopped the client - # blocks forever at 0% CPU with no transaction ever opening and no error. - # The binaries are what this step exists to produce, so if they are here, - # skip straight to configuration. Also makes the stage idempotent (T-005). - Write-Log "MySQL binaries already present at $MysqlRoot; skipping MSI" 'OK' - } - else { - # The Windows Installer service is Manual-start and is supposed to start - # on demand. When it does not, msiexec waits on it indefinitely - so start - # it explicitly rather than trusting on-demand activation. - $msiSvc = Get-Service msiserver -ErrorAction SilentlyContinue - if ($msiSvc -and $msiSvc.Status -ne 'Running') { - Write-Log 'starting Windows Installer service (msiserver)' - try { Start-Service msiserver -ErrorAction Stop } catch { - Write-Log "could not start msiserver: $($_.Exception.Message)" 'WARN' - } - } - # The Visual C++ runtime, BEFORE MySQL. MySQL's MSI carries a launch - # condition requiring it, and a bare Windows Server does not ship it - - # so on a fresh box the MSI aborts in about a second with the generic - # exit 1603 and no indication of what was missing. That is what a real - # Server 2019 install did. - $vcMarker = Join-Path $env:windir 'system32\vcruntime140.dll' - if (Test-Path $vcMarker) { - Write-Log 'Visual C++ runtime already present' 'OK' - } else { - $vc = Get-ChildItem (Join-Path $BundleRoot 'vcredist') -Filter '*.exe' -ErrorAction SilentlyContinue | - Select-Object -First 1 - if (-not $vc) { - Fail 'the Visual C++ runtime is missing and no redistributable is in the bundle' @' -MySQL will not install without it - its MSI fails with a bare exit 1603. -Add vcredist\VC_redist.x64.exe to the bundle and rebuild, or install the -Microsoft Visual C++ 2015-2022 Redistributable (x64) on this server by hand. -'@ - } - Write-Log "installing $($vc.Name) (MySQL requires it)" - if (-not $WhatIfOnly) { - Invoke-Native $vc.FullName @('/install','/quiet','/norestart') ` - 'Visual C++ runtime' -TimeoutSec 600 -OkExit 0,3010,1641 - Write-Log 'Visual C++ runtime installed' 'OK' - } - } - - # Quiet MSI. INSTALLDIR must have no trailing backslash or the MSI mis-parses it. - Write-Log "installing $($msi.Name) (125MB, takes a minute)" - # /l*v: without the MSI's own log, exit 1603 says only "something went - # wrong" - it names neither the action nor the reason, and it is the most - # common MySQL failure code. Costs nothing and turns the next failure - # into something readable. - $msiLog = Join-Path $script:LogDir ('mysql-msi-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) - Write-Log "MSI log: $msiLog" - Invoke-Native 'msiexec.exe' @('/i', $msi.FullName, '/quiet', '/norestart', - '/l*v', $msiLog, - "INSTALLDIR=$MysqlRoot") 'MySQL MSI' -TimeoutSec 900 -OkExit 0,3010 - } - # An installer exit code of 0 does NOT mean it did what you asked - the Python - # bootstrapper returned 0 while installing to the wrong directory. Verify. - if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" } - Track 'mysql-files' $MysqlRoot - # The service is registered with `mysqld --install` and no obj=, so it runs - # as LocalSystem - the SYSTEM grant covers it. Nothing else needs to write - # here; a user who can replace mysqld.exe owns the service account. - Protect-InstallRoot $MysqlRoot - Write-Log 'MySQL binaries installed' 'OK' - - # --- config ------------------------------------------------------------- - # Space-free paths on purpose: every quoting bug in this script came from a - # path containing a space being split into two arguments. - New-Item -ItemType Directory -Force -Path (Split-Path $MysqlIni -Parent) | Out-Null - $iniText = @" -[mysqld] -basedir=$MysqlRoot -datadir=$MysqlDataDir -port=$DbPort -bind-address=127.0.0.1 -character-set-server=utf8mb4 -collation-server=utf8mb4_unicode_ci -default-storage-engine=INNODB -innodb_file_per_table=1 -max_connections=200 -log-error=$MysqlDataDir\mysql-error.log -"@ - Set-Content -Path $MysqlIni -Value $iniText -Encoding ASCII - Write-Log "wrote $MysqlIni" - Track 'mysql-files' $MysqlIni - - # Everything from here to the bootstrap creates the server. Skip it wholesale - # when the service is already ours: the datadir check would refuse a - # populated directory, --initialize-insecure would refuse it too, and - # --install would fail on an existing service name. - if ($script:MysqlAlreadyOurs) { - Write-Log 'server already exists from a previous run; skipping create' 'OK' - } else { - - # --- data directory ----------------------------------------------------- - # --initialize-insecure REFUSES a non-empty datadir, so a previous failed - # attempt must be cleared before retrying. - if (Test-Path $MysqlDataDir) { - $items = Get-ChildItem $MysqlDataDir -Force -ErrorAction SilentlyContinue - if ($items) { - Fail "$MysqlDataDir already exists and is not empty" @' -mysqld --initialize-insecure refuses a non-empty data directory. If this is a -failed previous run, remove the directory and re-run stage 0. If it holds real -data, STOP: you are about to destroy a database. -'@ - } - } - Write-Log 'initializing data directory' - Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize' - Track 'mysql-datadir' $MysqlDataDir - # The data directory holds the users table and its password hashes, and it - # sits under ProgramData, which grants Users read by inheritance - the same - # exposure the backup directory already had to be locked against. mysqld - # runs as LocalSystem, so Administrators + SYSTEM is sufficient. - Protect-File $MysqlDataDir -Directory - - # --- service ------------------------------------------------------------ - # Argument ORDER MATTERS: --install BEFORE --defaults-file. Verified - # empirically - reversed, mysqld exits 1 and the service is not registered. - Write-Log "registering service $MysqlService" - Invoke-Native $mysqld @('--install', $MysqlService, "--defaults-file=$MysqlIni") 'MySQL service register' - Track 'service' $MysqlService - Start-Service -Name $MysqlService - $svc = Get-Service -Name $MysqlService - if ($svc.Status -ne 'Running') { Fail "service $MysqlService did not start" "Check $MysqlDataDir\mysql-error.log" } - Set-Service -Name $MysqlService -StartupType Automatic - Write-Log "service $MysqlService running" 'OK' - - } # end of the create-the-server block - - # --- bootstrap ---------------------------------------------------------- - # --initialize-insecure leaves root with an EMPTY password, so this must run - # immediately. Generated, never supplied; root is shown once and not stored. - # - # On a server this stage already built, root HAS a password, so the - # --skip-password connection below cannot authenticate. The database, the - # user and the handoff all exist from that run, so there is nothing to do - - # skip rather than fail. Without the handoff there is no safe automatic - # recovery, so say what to do instead of guessing. - if ($script:MysqlAlreadyOurs) { - if (Test-Path (Join-Path $AppRoot '.dbpass')) { - Write-Log 'database, user and password handoff already exist; skipping bootstrap' 'OK' - Write-Log 'stage 0 complete' 'OK' - return - } - Fail 'MySQL is installed by a previous run but its password handoff is gone' @' -The bundled server exists and root already has a generated password, so this -stage cannot re-create the application user, and .dbpass is not there to reuse. - -Either point the installer at this server with -DbHost 127.0.0.1 and the -password from C:\shopdb-flask\.env, or remove the MySQL84 service and its data -directory to install cleanly. -'@ - } - $rootPass = New-Secret -Bytes 24 - $appPass = New-Secret -Bytes 24 - # Written OUTSIDE %TEMP% and ACL'd BEFORE the secrets go in - a file created - # with default ACLs and populated afterwards is readable in the gap. - $sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql' - New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null - # Before the file goes in: this may be the first thing that creates AppRoot, - # and the file below holds the root and app database passwords. - Protect-InstallRoot $AppRoot - New-Item -ItemType File -Force -Path $sqlPath | Out-Null - Protect-File $sqlPath - $sql = @" -CREATE DATABASE IF NOT EXISTS $DbName CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -CREATE USER IF NOT EXISTS '$DbUser'@'localhost' IDENTIFIED BY '$appPass'; -CREATE USER IF NOT EXISTS '$DbUser'@'127.0.0.1' IDENTIFIED BY '$appPass'; --- ::1 as well. On current Windows 'localhost' resolves to the IPv6 loopback --- FIRST, so an operator who types localhost instead of 127.0.0.1 arrives as --- '$DbUser'@'::1' - an account that did not exist, and MySQL answers "Access --- denied" naming a host the operator never typed. -CREATE USER IF NOT EXISTS '$DbUser'@'::1' IDENTIFIED BY '$appPass'; --- ALTER after CREATE, because CREATE USER IF NOT EXISTS is a NO-OP on an --- existing user and does NOT change its password. This stage generates a fresh --- password every run and overwrites .dbpass with it, so on any re-run after a --- partial failure the file held a password the server had never been told - --- "Access denied" while the operator was looking straight at the right file. --- ALTER makes the stored password and the handoff agree, every time. -ALTER USER '$DbUser'@'localhost' IDENTIFIED BY '$appPass'; -ALTER USER '$DbUser'@'127.0.0.1' IDENTIFIED BY '$appPass'; -ALTER USER '$DbUser'@'::1' IDENTIFIED BY '$appPass'; -GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'localhost'; -GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'127.0.0.1'; -GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'::1'; -ALTER USER 'root'@'localhost' IDENTIFIED BY '$rootPass'; -FLUSH PRIVILEGES; -"@ - Set-Content -Path $sqlPath -Value $sql -Encoding ASCII - try { - $mysqlExe = Join-Path $MysqlRoot 'bin\mysql.exe' - # -e "source " fails with ERROR 1049 Unknown database '': the - # client reads the path as a database name. Redirect stdin instead. That - # also stops PowerShell eating the backticks MySQL uses to quote identifiers. - Invoke-Native $mysqlExe @('-uroot', '--skip-password', '--protocol=TCP', - "--port=$DbPort") 'MySQL bootstrap' -StdinFile $sqlPath - } - finally { - # Unconditional: this file holds both passwords. - Set-Content -Path $sqlPath -Value ('0' * 512) -Encoding ASCII -ErrorAction SilentlyContinue - Remove-Item $sqlPath -Force -ErrorAction SilentlyContinue - } - - # Hand the app password to stage 2 through an ACL'd FILE, never stdout - - # stdout lands in the log that FR-161 invites operators to send to support. - $handoff = Join-Path $AppRoot '.dbpass' - New-Item -ItemType File -Force -Path $handoff | Out-Null - Protect-File $handoff - Set-Content -Path $handoff -Value $appPass -Encoding UTF8 - Write-Log "database $DbName and user $DbUser created" 'OK' - Write-Log "app password written to $handoff (stage 2 consumes and deletes it)" - - # NOT Write-Host. Under the wizard this process runs hidden, so nobody ever - # saw this - and every line of stdout is forwarded into the setup log that - # operators are told to send to support. The generated root password was - # therefore invisible to the person who needed it and permanently recorded - # for everyone who did not. - # - # Write it to a file only Administrators and SYSTEM can read, and let the - # wizard tell the operator where it is. - $rootFile = Join-Path $env:ProgramData 'ShopDB-Flask\mysql-root-password.txt' - $rootDir = Split-Path $rootFile -Parent - if (-not (Test-Path $rootDir)) { New-Item -ItemType Directory -Path $rootDir -Force | Out-Null } - New-Item -ItemType File -Force -Path $rootFile | Out-Null - Protect-File $rootFile - Set-Content -Path $rootFile -Value @( - 'MySQL root password for this server, generated during installation.', - 'It is not recorded anywhere else and cannot be recovered.', - 'Move it into your password manager, then delete this file.', - '', - $rootPass - ) -Encoding UTF8 - Write-Log "MySQL root password written to $rootFile (Administrators and SYSTEM only)" 'OK' - Write-Log 'stage 0 complete' 'OK' -} - -# ============================================================================= -# STAGE 2 - runtime: Python, venv, wheels, .env -# ============================================================================= -function Invoke-Stage2 { - Write-Log 'STAGE 2: runtime and configuration' 'STEP' - - if (-not (Test-Path $BundleRoot)) { Fail "bundle not found: $BundleRoot" } - if (-not (Test-Path $WheelDir)) { Fail "wheelhouse not found: $WheelDir" 'Rebuild the bundle with deploy\windows\installer\build-installer.ps1 (or .sh); it refuses to produce a bundle without one.' } - if (-not (Test-Path $AppSource)) { Fail "application payload not found: $AppSource" } - Assert-BundleIntegrity - Assert-VenvMatchesWheelhouse - # Captured HERE, before the stamp below is written. Stage 4's guard against - # touching an installation this did not create tested the stamp directly, and - # stage 2 always runs first in a '-Stage all' install - so the stamp it had - # just written made every server look like one of ours and the guard never - # fired on the case it exists for. - $script:PreexistingInstallStamp = Test-Path (Join-Path $AppRoot '.installed-version') - - # --- Python, all users -------------------------------------------------- - # A per-user install lands in %LOCALAPPDATA%, which the IIS app-pool identity - # cannot read. That produces a 500 with an empty HttpPlatform log. - $pyInstaller = Get-ChildItem (Join-Path $BundleRoot 'python') -Filter '*.exe' -ErrorAction SilentlyContinue | - Select-Object -First 1 - # SPACE-FREE deliberately. `TargetDir=C:\Program Files\Python314` splits at - # the space when passed to the bootstrapper and installs into C:\Program\. - # Same class of failure as MySQL's --defaults-file. Never put a space in a - # path this installer controls. - $pyTarget = 'C:\Python314' - # Stage 4 needs it to grant the app pool read access; a venv's python.exe is - # a copy, but python314.dll and the stdlib still come from here. - $script:PyRoot = $pyTarget - if (Test-Path (Join-Path $pyTarget 'python.exe')) { - Write-Log "Python already present at $pyTarget" 'OK' - # NOT hardened: something else installed it and may depend on the ACL it - # has. Say so instead - the app would otherwise run on an interpreter - # any logged-on user can replace. - if (Test-UserWritableRoot $pyTarget) { - Write-Log "$pyTarget is writable by non-administrators; this installer did not create it, so it is left alone. Harden it or reinstall Python under an ACL only administrators can write." 'WARN' - } - } elseif ($null -eq $pyInstaller) { - Fail "no Python installer in $BundleRoot\python" - } else { - Write-Log "installing Python (all users) from $($pyInstaller.Name)" - if (-not $WhatIfOnly) { - # /norestart matters: without it the bootstrapper is free to reboot - # the server on its own, mid-install, with the wizard still running. - Invoke-Native $pyInstaller.FullName @( - '/quiet','/norestart','InstallAllUsers=1','PrependPath=0','Include_launcher=1', - 'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget" - ) 'Python install' -TimeoutSec 900 -OkExit 0,3010,1641 - Track 'python' $pyTarget - # Ours, so ours to lock down. InstallAllUsers=1 puts it at C:\Python314 - # with the drive root's inherited Authenticated Users Modify. - Protect-InstallRoot $pyTarget - } - } - $sysPy = Join-Path $pyTarget 'python.exe' - if (-not $WhatIfOnly -and -not (Test-Path $sysPy)) { Fail "Python not at $sysPy after install" } - - # --- application payload ------------------------------------------------ - if (-not (Test-Path $AppRoot)) { - New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null - Track 'dir' $AppRoot - } - Protect-InstallRoot $AppRoot - # --- upgrade detection --------------------------------------------------- - $script:BundleVersion = Get-BundleVersion - $script:InstalledVersion = Get-InstalledVersion - $script:IsUpgrade = ($script:InstalledVersion -ne '') - $script:DbWasEmpty = $false # established for real in stage 3 - - if ($script:IsUpgrade) { - Write-Log ("upgrading: installed {0} -> bundle {1}" -f $script:InstalledVersion, $script:BundleVersion) 'STEP' - $cmp = Compare-Version $script:BundleVersion $script:InstalledVersion - if ($cmp -lt 0) { - # Migrations only go forwards. Running older code against a newer - # schema fails in ways that are hard to unpick, so refuse outright. - Fail ("this bundle ({0}) is OLDER than what is installed ({1})" -f $script:BundleVersion, $script:InstalledVersion) @' -Downgrading is not supported: the database schema has already been migrated -forwards, and older code cannot read it. Install a build at least as new as -what is on this server, or restore a backup taken before the upgrade. -'@ - } - if ($cmp -eq 0) { - Write-Log 'same version already installed; re-running is harmless (repair)' 'WARN' - } - } else { - Write-Log ("installing version {0}" -f $script:BundleVersion) - } - - # UPGRADE PATH: on an existing install the app pool holds python.exe and the - # site files open, so copying over them fails with "file in use" partway - # through - leaving a half-replaced application. Stop the pool first and note - # that we did, so stage 4 knows to start it again. - $script:PoolWasStopped = $false - if (-not $WhatIfOnly) { - try { - Import-Module WebAdministration -ErrorAction Stop - if ((Test-Path "IIS:\AppPools\$AppPool") -and - ((Get-Item "IIS:\AppPools\$AppPool").State -eq 'Started')) { - Write-Log "stopping app pool $AppPool so its files can be replaced" 'WARN' - Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue - # The worker does not exit instantly; copying too soon still fails. - for ($i = 0; $i -lt 15; $i++) { - Start-Sleep -Seconds 1 - if (-not (Get-Process w3wp -ErrorAction SilentlyContinue)) { break } - } - $script:PoolWasStopped = $true - } - } catch { } - } - - # Plugin directories present on the server but NOT in the new bundle are - # removed BEFORE the copy. The copy merges rather than replaces, so a plugin - # dropped from a site's profile kept its code on disk forever - which defeats - # the point of a lean build (ADR-013) and, worse, leaves the core's - # `try: from plugins.X.models import ...` guards succeeding for a plugin this - # site no longer has, so SQLAlchemy still builds the relationship and queries - # still join a table that may have been pruned. - if (-not $WhatIfOnly) { - $newPlugins = @() - $srcPlugins = Join-Path $AppSource 'plugins' - if (Test-Path $srcPlugins) { - $newPlugins = @(Get-ChildItem $srcPlugins -Directory | Select-Object -ExpandProperty Name) - } - $livePlugins = Join-Path $AppRoot 'plugins' - if ($newPlugins.Count -gt 0 -and (Test-Path $livePlugins)) { - foreach ($dir in (Get-ChildItem $livePlugins -Directory)) { - if ($newPlugins -notcontains $dir.Name) { - Write-Log "removing '$($dir.Name)': not in this build" 'WARN' - # Uninstall FIRST, while the code is still importable - the - # registry entry outlives the directory otherwise, and the app - # then fails to load a plugin it still believes is installed. - $deregistered = $false - try { - if (Test-Path $Flask) { - Push-Location $AppRoot - try { - $env:FLASK_APP = 'shopdb' - # --yes is REQUIRED: the command carries a click - # confirmation_option, which aborts with exit 1 - # when there is no console to answer the prompt. - # Without it this deregistration could never once - # have succeeded. - # - # EAP bracket for the reason documented on - # Invoke-Native: '2>&1' under EAP 'Stop' turns any - # stderr line into a terminating error, which the - # catch below then downgraded to a WARN while the - # code was deleted regardless. - $prevEap = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { - & $Flask plugin uninstall $dir.Name --yes 2>&1 | - ForEach-Object { Write-Log " $_" } - $deregistered = ($LASTEXITCODE -eq 0) - } finally { $ErrorActionPreference = $prevEap } - } finally { Pop-Location } - } - } catch { Write-Log " could not deregister $($dir.Name): $($_.Exception.Message)" 'WARN' } - # Delete the code ONLY once the registry entry is really gone. - # Deleting it anyway left the application loading a plugin - # whose directory no longer existed - the exact failure the - # uninstall-first ordering above exists to avoid. - if ($deregistered) { - Remove-Item $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue - } else { - Write-Log " left $($dir.Name) on disk: deregistration did not succeed" 'WARN' - } - } - } - } - } - - Write-Log "copying application payload to $AppRoot" - if (-not $WhatIfOnly) { - Copy-Item (Join-Path $AppSource '*') -Destination $AppRoot -Recurse -Force - } - # Pick the SPA build that matches the deployment method. Vite compiles the - # base path in, so the wrong one loads a page that then fetches its assets - # from the wrong prefix and renders nothing. - if (-not $WhatIfOnly) { - $rootDist = Join-Path $AppRoot 'frontend\dist' - $subDist = Join-Path $AppRoot 'frontend\dist-subpath' - if ($MountAlias) { - if (-not (Test-Path $subDist)) { - Fail 'this bundle has no subpath frontend build' @' -The bundle was built without the / SPA, so a subpath install would serve -a page that cannot load its own assets. Rebuild with scripts/build-site.sh -(which produces both), or install without -MountAlias. -'@ - } - # The alias is fixed at BUILD time; refuse rather than mis-serve. - $built = '' - $aliasFile = Join-Path $subDist '.alias' - if (Test-Path $aliasFile) { $built = (Get-Content $aliasFile -TotalCount 1).Trim() } - if ($built -and ($built -ne $MountAlias.Trim('/'))) { - Fail ("this bundle's subpath build is for '/{0}', not '/{1}'" -f $built, $MountAlias.Trim('/')) ` - 'Rebuild the bundle with SUBPATH_ALIAS set to the alias you want.' - } - Remove-Item $rootDist -Recurse -Force -ErrorAction SilentlyContinue - Move-Item $subDist $rootDist -Force - Write-Log ("using the /{0} frontend build" -f $MountAlias.Trim('/')) 'OK' - } else { - # Not needed for a root install; leave nothing confusing behind. - Remove-Item $subDist -Recurse -Force -ErrorAction SilentlyContinue - } - } - - if (-not $WhatIfOnly -and $script:BundleVersion) { - # Stamped AFTER the copy: if the copy dies, the stamp still reflects what - # is actually on disk rather than what we hoped to put there. - Set-Content -Path (Join-Path $AppRoot '.installed-version') ` - -Value $script:BundleVersion -Encoding ASCII - } - # Keep the lock with the install so `shopdb-admin.ps1 verify` can say WHICH - # bundle this server was built from, months later and offline. - if (-not $WhatIfOnly) { - $bundledLock = Join-Path $BundleRoot 'bundle-lock.json' - if (Test-Path $bundledLock) { Copy-Item $bundledLock $AppRoot -Force } - # The MySQL client stays on the server. The bundle is extracted to a temp - # directory and deleted when Setup exits, so a copy that lived only there - # would leave `shopdb-admin backup` with nothing to run on a site whose - # database is on another host. - $bundledClient = Join-Path $BundleRoot 'mysqlclient' - if (Test-Path $bundledClient) { - $target = Join-Path $AppRoot 'mysqlclient' - if (-not (Test-Path $target)) { New-Item -ItemType Directory -Path $target -Force | Out-Null } - Copy-Item (Join-Path $bundledClient '*') $target -Recurse -Force - Write-Log 'MySQL client staged for backups' 'OK' - } - } - foreach ($sub in @('logs','instance')) { - $p = Join-Path $AppRoot $sub - if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } - } - - # --- venv --------------------------------------------------------------- - if (Test-Path $Py) { - Write-Log 'venv already exists' 'OK' - } else { - Write-Log 'creating venv' - if (-not $WhatIfOnly) { - Invoke-Native $sysPy @('-m','venv',(Join-Path $AppRoot 'venv')) 'venv creation' - Track 'dir' (Join-Path $AppRoot 'venv') - } - } - - # --- offline dependency install ---------------------------------------- - # PIP_NO_INDEX makes a network attempt impossible rather than merely - # unnecessary. On an air-gapped box pip otherwise hangs on DNS timeouts. - # - # --require-hashes puts pip in hash-checking mode: every wheel must match a - # sha256 listed in requirements.txt or the install ABORTS. Without it pip - # took whatever file in the wheelhouse satisfied the version pin, so a - # swapped or hand-dropped wheel installed silently. The flag is passed - # explicitly rather than relying on pip inferring it from the presence of - # hashes, so shipping an unhashed requirements.txt fails loudly here instead - # of quietly dropping the check. - # - # --only-binary=:all: refuses to fall back to building from an sdist. On a - # server with no compiler and no network that fallback cannot succeed; it - # just turns a clear "no wheel for this platform" into a confusing build - # error deep in someone's setup.py. - Write-Log 'installing dependencies from the wheelhouse (offline, hash-checked)' - if (-not $WhatIfOnly) { - $env:PIP_NO_INDEX = '1' - $env:PIP_FIND_LINKS = $WheelDir - $env:PIP_DISABLE_PIP_VERSION_CHECK = '1' - try { - Invoke-Native $Py @('-m','pip','install','--no-index',"--find-links=$WheelDir", - '--require-hashes','--only-binary=:all:', - '-r',(Join-Path $AppRoot 'requirements.txt')) 'dependency install' - } finally { - Remove-Item Env:\PIP_NO_INDEX, Env:\PIP_FIND_LINKS -ErrorAction SilentlyContinue - } - # waitress and tzdata are load-bearing on Windows; verify rather than assume. - foreach ($mod in @('waitress','tzdata')) { - & $Py -c "import $mod" 2>$null - if ($LASTEXITCODE -ne 0) { - Fail "$mod did not install" "The wheelhouse is incomplete. $mod is required (waitress is the WSGI server; tzdata provides the IANA timezone database Windows lacks)." - } - } - Write-Log 'waitress and tzdata present' 'OK' - } - - # --- .env --------------------------------------------------------------- - # The stage-0 handoff is checked BEFORE the .env branch, not inside its else. - # It used to sit in the else, so it was consulted only when .env was absent - - # and on a part-finished install .env DOES exist, holding whatever password - # stage 2 last wrote. If stage 0 has since regenerated the credential, .env - # is stale, .dbpass is correct, and the installer preferred the stale one: - # "Access denied", with the right password sitting unread on disk. - # - # .dbpass only exists between stage 0 and stage 5 - stage 5 shreds it once - # the install is proven - so its presence means "this install is unfinished - # and THIS is the current password". Applied only when .env points at the - # local server, because the handoff describes the bundled database and must - # never redirect a site whose database lives elsewhere. - $handoff = Join-Path $AppRoot '.dbpass' - if (-not $DbPasswordFile -and (Test-Path $handoff)) { - $envIsLocal = $true - if (Test-Path $EnvFile) { - $envIsLocal = $false - $urlLine = Get-Content $EnvFile -ErrorAction SilentlyContinue | - Where-Object { $_ -like 'DATABASE_URL=*' } | Select-Object -First 1 - if ($urlLine -and ($urlLine -match '@(127\.0\.0\.1|localhost|\[::1\]):')) { $envIsLocal = $true } - } - if ($envIsLocal) { - $DbPasswordFile = $handoff - if (-not $DbHost) { $DbHost = '127.0.0.1' } - Write-Log 'using the stage-0 password handoff (an unfinished install; .env may be stale)' - } - } - - if (Test-Path $EnvFile) { - # Preserving .env is right for a plain re-run: regenerating JWT_SECRET_KEY - # would invalidate every issued session. - # - # But if the caller EXPLICITLY supplied connection details - a wizard where - # someone just typed a password, or -DbHost/-DbPasswordFile on the command - # line - then silently keeping the old DATABASE_URL ignores what they asked - # for and fails later with a bare "Access denied", pointing at the database - # instead of at the stale file that actually caused it. - # So: keep the secrets, refresh the connection string. - # Rewrite the connection string ONLY when a password was actually - # supplied. Previously any -DbHost (which the wizard always passes, from - # a page whose defaults were 127.0.0.1/shopdb_flask) rewrote DATABASE_URL - # - so a site whose database lives on another server had its real - # connection string silently replaced with defaults during an upgrade, - # and .env was the only record of it. - if ($DbPasswordFile) { - Write-Log '.env exists and new database details were supplied; updating DATABASE_URL' 'WARN' - $dbPass = '' - if ($DbPasswordFile) { - if (-not (Test-Path $DbPasswordFile)) { Fail "password file not found: $DbPasswordFile" } - $dbPass = (Get-Content $DbPasswordFile -TotalCount 1 -Encoding UTF8) - if ($dbPass) { $dbPass = $dbPass.Trim() } - if ($DbPasswordFile -ne (Join-Path $AppRoot '.dbpass')) { - Set-Content -Path $DbPasswordFile -Value ('0' * 256) -Encoding UTF8 -Force -ErrorAction SilentlyContinue - Remove-Item $DbPasswordFile -Force -ErrorAction SilentlyContinue - } - } - if (-not $dbPass) { Fail 'no database password supplied to update .env' } - if (-not $DbHost) { $DbHost = '127.0.0.1' } - $escUser = [uri]::EscapeDataString($DbUser) - $escPass = [uri]::EscapeDataString($dbPass) - $newUrl = "DATABASE_URL=mysql+pymysql://${escUser}:${escPass}@${DbHost}:${DbPort}/${DbName}?charset=utf8mb4" - # Keep a copy before touching it. .env holds the DB password in - # plaintext by design, so the backup gets the same owner-only ACL and - # is the operator's way back if the new details turn out wrong. - $envBak = "$EnvFile.bak-{0}" -f (Get-Date -Format 'yyyyMMdd-HHmmss') - Copy-Item $EnvFile $envBak -Force -ErrorAction SilentlyContinue - if (Test-Path $envBak) { - Protect-File $envBak - Write-Log "previous .env kept at $envBak" - } - $kept = Get-Content $EnvFile | Where-Object { $_ -notmatch '^\s*DATABASE_URL=' } - Set-Content -Path $EnvFile -Value ($kept + $newUrl) -Encoding ASCII - Protect-File $EnvFile - Write-Log 'DATABASE_URL updated; SECRET_KEY and JWT_SECRET_KEY kept' 'OK' - } else { - Write-Log '.env exists and no new password was supplied; keeping it unchanged' 'OK' - Write-Log ' (its database settings and secret keys are left exactly as they are)' - } - - # MOUNT_PATH must ALWAYS track the chosen deployment method, whether or - # not the connection string changed. Switching from a subpath install - # back to its own site otherwise leaves MOUNT_PATH=/shopdb behind, and - # wsgi.py keeps mounting the app at /shopdb - so the new site answers - # 404 on every path and the smoke test fails on a correct install. - # CORS_ORIGINS is tied to the deployment method too: switching between - # methods changes the port people arrive on, and a stale value fails every - # browser request while curl (which does not send Origin) still works. - $wantOrigin = '' - if ($MountAlias) { - $parentPort = 80 - try { - Import-Module WebAdministration -ErrorAction SilentlyContinue - $b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection | - Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 - if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] } - } catch { } - if (-not $SiteHost) { $SiteHost = $env:COMPUTERNAME } - if ($parentPort -eq 80) { $wantOrigin = "http://{0}" -f $SiteHost } - else { $wantOrigin = "http://{0}:{1}" -f $SiteHost, $parentPort } - } elseif ($SiteHost) { - $wantOrigin = "http://{0}:{1}" -f $SiteHost, $SitePort - } - - $envLines = @(Get-Content $EnvFile | Where-Object { $_ -notmatch '^\s*MOUNT_PATH=' }) - if ($wantOrigin) { - $envLines = @($envLines | Where-Object { $_ -notmatch '^\s*CORS_ORIGINS=' }) - $envLines += ("CORS_ORIGINS={0}" -f $wantOrigin) - Write-Log "CORS_ORIGINS set to $wantOrigin" 'OK' - } - if ($MountAlias) { - $envLines += ("MOUNT_PATH=/{0}" -f $MountAlias.Trim('/')) - Write-Log ("MOUNT_PATH set to /{0}" -f $MountAlias.Trim('/')) 'OK' - } else { - Write-Log 'MOUNT_PATH removed - the app serves from the site root' 'OK' - } - Set-Content -Path $EnvFile -Value ($envLines -join "`r`n") -Encoding ASCII - Protect-File $EnvFile - } else { - if (-not $SiteHost) { $SiteHost = $env:COMPUTERNAME } - - # The stage-0 handoff was already applied above, for both branches. - - if (-not $DbHost) { - Fail 'no database host supplied and no existing .env' @' -Pass -DbHost . The password is prompted for, never passed on the -command line. For a bundled-database install run stage 0 first: it creates the -database and leaves an ACL'd handoff file, after which no password is needed. -'@ - } - # Two ways in, both keeping the password off the command line (which any - # user can read via Win32_Process, and which lands in transcripts and - # ConsoleHost_history.txt). - if ($DbPasswordFile) { - # Unattended path. The caller writes an ACL'd file; we consume and - # shred it so it does not outlive the install. - if (-not (Test-Path $DbPasswordFile)) { Fail "password file not found: $DbPasswordFile" } - $dbPass = (Get-Content $DbPasswordFile -TotalCount 1 -Encoding UTF8) - if ($dbPass) { $dbPass = $dbPass.Trim() } - Write-Log "read DB password from $DbPasswordFile" - if ($DbPasswordFile -eq $handoff) { - # Do NOT shred the stage-0 handoff here. It holds the ONLY copy of a - # GENERATED password: .env is the sole other copy, and rollback - # deletes .env. Shredding both on a failed install leaves an - # unrecoverable box - the database exists, the app user exists, and - # nobody alive knows the password. Stage 5 shreds it once the - # install is proven working. - Write-Log 'keeping the stage-0 handoff until stage 5 confirms the install' - } else { - try { - # Operator-supplied file: they know the password, so consume it - # immediately. Overwrite before delete - a plain Remove-Item - # leaves the bytes on disk. - Set-Content -Path $DbPasswordFile -Value ('0' * 256) -Encoding UTF8 -Force - Remove-Item $DbPasswordFile -Force - Write-Log 'password file overwritten and removed' - } catch { Write-Log "could not remove $DbPasswordFile - delete it manually" 'WARN' } - } - } - else { - # Interactive path. Read-Host -AsSecureString reads the CONSOLE, not - # stdin: piping into it does not work, it just blocks forever with no - # error and no timeout. So refuse up front rather than hang a remote or - # scheduled run. - if (-not [Environment]::UserInteractive) { - Fail 'no interactive console for the password prompt' @' -This session has no console, so the password prompt would hang indefinitely -(Read-Host -AsSecureString reads the console directly and ignores piped stdin). -Re-run with -DbPasswordFile pointing at a file whose first line is the -password. The installer reads it, overwrites it and deletes it. -'@ - } - $sec = Read-Host -Prompt ("Password for MySQL user '{0}' on {1}" -f $DbUser, $DbHost) -AsSecureString - $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec) - try { $dbPass = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } - finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } - } - if (-not $dbPass) { Fail 'no password entered' } - # Percent-encode: passwords routinely contain characters that break a URL. - $escUser = [uri]::EscapeDataString($DbUser) - $escPass = [uri]::EscapeDataString($dbPass) - $DatabaseUrl = "mysql+pymysql://${escUser}:${escPass}@${DbHost}:${DbPort}/${DbName}?charset=utf8mb4" - # CORS_ORIGINS must be the ORIGIN PEOPLE ACTUALLY USE, and an explicit one - - # a wildcard makes the app refuse to boot. Under a subpath install the app - # is reached on the PARENT site's port (usually 80), so pinning the - # stand-alone port here would make every browser request fail CORS on a - # correctly installed server. Origin is scheme+host+port only - never the path. - if ($MountAlias) { - $parentPort = 80 - try { - Import-Module WebAdministration -ErrorAction SilentlyContinue - $b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection | - Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 - if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] } - } catch { } - if ($parentPort -eq 80) { $origin = "http://{0}" -f $SiteHost } - else { $origin = "http://{0}:{1}" -f $SiteHost, $parentPort } - } else { - $origin = "http://{0}:{1}" -f $SiteHost, $SitePort - } - $content = @( - 'FLASK_ENV=production' - ("SECRET_KEY={0}" -f (New-Secret)) - ("JWT_SECRET_KEY={0}" -f (New-Secret)) - ("DATABASE_URL={0}" -f $DatabaseUrl) - ("CORS_ORIGINS={0}" -f $origin) - 'LOG_LEVEL=INFO' - ) - if ($MountAlias) { - # wsgi.py reads MOUNT_PATH and moves the prefix from PATH_INFO to - # SCRIPT_NAME, so Flask both routes and GENERATES urls under it. - # Added to the ARRAY, before the join - appending to the joined string - # glues it onto the previous line ("LOG_LEVEL=INFOMOUNT_PATH=/shopdb"). - $content += ("MOUNT_PATH=/{0}" -f $MountAlias.Trim('/')) - } - $content = $content -join "`r`n" - Write-Log "writing .env (CORS_ORIGINS=$origin)" - if (-not $WhatIfOnly) { - Set-Content -Path $EnvFile -Value $content -Encoding ASCII - Track 'file' $EnvFile - # .env holds the database password in plaintext BY DESIGN - the app - # reads it at boot. Filesystem permissions are the only control. - # Administrators and SYSTEM only; the app-pool identity gets Read in stage 4. - Protect-File $EnvFile - # Clear the plaintext copy from this session's memory. - $dbPass = $null; $content = $null - [GC]::Collect() - } - } - Write-Log 'stage 2 complete' 'OK' -} - -# ============================================================================= -# STAGE 3 - data: schema, seeds, plugins -# ============================================================================= -function Invoke-Stage3 { - Write-Log 'STAGE 3: schema, seed data, plugins' 'STEP' - if (-not (Test-Path $Flask)) { Fail 'venv not found; run stage 2 first' } - - # Resolve the site profile. An explicit -SiteProfile wins; otherwise use the - # one the bundle was built from, which scripts/build-site.sh stages alongside - # the app tree so the profile and the lean tree can never drift apart. - $ProfilePath = $SiteProfile - if (-not $ProfilePath) { $ProfilePath = Join-Path $AppRoot 'site-profile.json' } - if (-not (Test-Path $ProfilePath)) { - Fail "site profile not found: $ProfilePath" @' -The installer no longer carries a hardcoded plugin list - the site's set is -declared in a profile and the bundle is built lean for it. -Either pass -SiteProfile , or rebuild the bundle with -scripts/build-site.sh , which stages the profile into the app tree. -See deploy/site-profile.example.json for the format. -'@ - } - # Report the chosen set up front: it decides both what gets installed and what - # prune-schema drops, so it belongs in the log FR-161 sends to support. - try { - $declared = (Get-Content $ProfilePath -Raw | ConvertFrom-Json).plugins -join ', ' - Write-Log "site profile: $ProfilePath" - Write-Log "declared plugins: $declared" - } catch { Fail "site profile is not valid JSON: $ProfilePath" } - - $env:FLASK_APP = 'shopdb' - Push-Location $AppRoot - try { - # The app ships its own preflight. Use it rather than reimplementing: - # it checks Python, required env, DB connectivity and the MySQL 5.6 flags. - Write-Log 'running flask db-utils preflight' - # TRAP (documented in Invoke-Native, and hit again here): the app logs - # plugin initialisation to STDERR even on success. With 2>&1 and - # $ErrorActionPreference='Stop' PowerShell turns that into a terminating - # NativeCommandError, so a healthy preflight aborts the install with a - # plugin INFO line as the "error". Judge by the OUTPUT, not by stderr. - $prevEAP = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { $pf = & $Flask db-utils preflight 2>&1 } - finally { $ErrorActionPreference = $prevEAP } - $pf | ForEach-Object { Write-Log " $_" } - # Match the preflight's own status column (" FAIL "), not any line - # that merely contains the word - plugin log lines can too. - $pfFailed = @($pf | Where-Object { $_ -match '^\s*FAIL\s' }).Count -gt 0 - if ($LASTEXITCODE -ne 0 -or $pfFailed) { - Fail 'application preflight reported failures' 'Fix the FAIL items above, then re-run stage 3.' - } - - if ($WhatIfOnly) { Write-Log 'WhatIf: skipping migrations and seeds' 'WARN'; return } - - # Decide fresh-vs-existing from the DATABASE, and do it HERE rather than - # relying on a variable set in stage 2 - stage 3 is documented as - # separately runnable ("re-run stage 3"), and under Set-StrictMode - # reading an unset $script:IsUpgrade is a terminating error. - $script:DbWasEmpty = Test-DatabaseEmpty - # Tables present does NOT mean upgrade. It meant that for a long time, - # and it is why the retry after a failed first install dead-ended: stage 3 - # had already created the schema before dying, so the retry found tables, - # called itself an upgrade of a live site, and then demanded a mandatory - # backup and refused to prune - of a database it had written itself. - # The install record distinguishes the two, which nothing on the machine - # can. - $firstRun = Test-FirstProvisioning - if (-not $script:DbWasEmpty) { - if ($firstRun) { - Write-Log 'the database already has tables, but the install record shows first provisioning is still unfinished' 'WARN' - Write-Log ' they were created by an earlier attempt of THIS install, so this is not an upgrade' 'WARN' - } else { - $script:IsUpgrade = $true - Write-Log 'existing data found in the target database - treating this as an upgrade' 'WARN' - } - } - - # Back up BEFORE Alembic touches anything. Skipped only when the database - # is provably empty; on anything else it is the only thing standing - # between a failed migration and a half-migrated database. - $script:PreUpgradeBackup = '' - if (-not $script:DbWasEmpty) { - $script:PreUpgradeBackup = Backup-Database 'pre-upgrade' - if (-not $script:PreUpgradeBackup) { - if ($firstRun) { - # Nothing of anyone's to lose: every row was written by an - # earlier attempt of this same install. Refusing here turned a - # missing mysqldump into a hard dead end on exactly the retry - # the wizard tells the operator is safe to run. - Write-Log 'no pre-migration backup was taken' 'WARN' - Write-Log ' continuing anyway: this is still first provisioning, so the only data present is what an earlier attempt wrote' 'WARN' - } else { - Fail 'could not back up the database before upgrading' @' -Refusing to migrate without a backup. Fix mysqldump access (or take a backup -manually with shopdb-admin.ps1 backup) and run this again. -'@ - } - } - } - - # Alembic owns the schema. Nothing else may create tables. - Write-Log 'flask db upgrade' - try { - Invoke-Native $Flask @('db','upgrade') 'schema upgrade' - } catch { - if ($script:PreUpgradeBackup) { - Write-Log 'migration failed - putting the database back' 'FAIL' - if (Restore-Database $script:PreUpgradeBackup) { - Fail 'the upgrade failed; your data was restored from the pre-upgrade backup' @' -Your DATA is intact. The schema may still carry tables the failed migration -created before it stopped - the log lists any of them, and they are empty. -The application files on disk are the NEW version, so re-install the PREVIOUS -build to get a matching pair, then send the install log to support. -'@ - } - } - throw - } - - foreach ($seed in @('reference-data','permissions','settings')) { - Write-Log "flask seed $seed" - Invoke-Native $Flask @('seed',$seed) "seed $seed" - } - - # Additive and idempotent, so a re-run adds only what is genuinely new - # and never overwrites a description somebody corrected here. - if ($SeedCatalog) { - Write-Log 'flask seed catalog (shared vendor and model catalog)' - $catalogOut = Invoke-Native $Flask @('seed','catalog') 'seed catalog' -OkExit 0,1 - $catalogOut | Select-Object -Last 12 | ForEach-Object { Write-Log " $_" } - } - - # Plugin registry starts empty on a fresh box. apply-profile resolves the - # declared set's hard-dependency closure and installs + enables it in - # dependency order, idempotently (ADR-013). It fails loudly if the profile - # names a plugin that is not on disk - which on a lean bundle correctly - # catches a profile/bundle mismatch. - # An install-time selection overrides the bundled profile. Written here, - # after the payload copy, so it survives stage 2 replacing the app tree. - if ($SitePlugins) { - $chosen = @($SitePlugins -split ',' | ForEach-Object { $_.Trim() } | - Where-Object { $_ }) - $onDisk = @(Get-ChildItem (Join-Path $AppRoot 'plugins') -Directory -ErrorAction SilentlyContinue | - Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } | - Select-Object -ExpandProperty Name) - $missing = @($chosen | Where-Object { $onDisk -notcontains $_ }) - if ($missing.Count -gt 0) { - Fail ("these plugins are not in this bundle: {0}" -f ($missing -join ', ')) ` - 'Rebuild the installer from a profile that includes them.' - } - $profileJson = @{ site = $SiteHost; plugins = $chosen; locked = @() } | - ConvertTo-Json -Depth 3 - Set-Content -Path $ProfilePath -Value $profileJson -Encoding ASCII - Write-Log ("site profile rewritten from the installer selection: {0}" -f ($chosen -join ', ')) 'OK' - } - - Write-Log "flask plugin apply-profile $ProfilePath" - Invoke-Native $Flask @('plugin','apply-profile',$ProfilePath) 'apply site profile' - - # Per-plugin Alembic chains (ADR-008) run after the plugins are registered. - Write-Log 'flask plugin upgrade-all' - Invoke-Native $Flask @('plugin','upgrade-all') 'plugin migrations' - - # ADR-014 Phase 2. The shared core Alembic baseline creates EVERY plugin's - # tables regardless of this site's choice, so a lean site still carries the - # omitted plugins' (empty) tables. Drop them, leaving core + chosen. - # - # Both flags are required and they mean different things: --yes executes - # (the command is a dry-run preview otherwise), --force permits dropping a - # table that holds rows. --force is needed even on a fresh install because - # core migrations seed a few plugin reference tables (e.g. 7d05 inserts - # default access protocols). Safe here and ONLY here: this runs during - # initial provisioning, before any site data exists. - # NOTE: ADR-014's prose says lean provisioning "uses --force"; that alone - # would silently do nothing but print a preview. It needs both. - # --force drops tables that CONTAIN ROWS. That is required on a fresh - # install, because core migrations seed a few plugin reference tables - # (7d05 inserts default access protocols) and prune would otherwise - # refuse. On an UPGRADE the same flag would silently destroy a site's - # data for any plugin that is not installed - so never force there. - # Without --force, prune-schema refuses non-empty tables and says so, - # which is the behaviour we want when data exists. - # --force is safe during first provisioning for the same reason the backup - # is not required: every row present was written by an earlier attempt of - # this install. Core migration 7d05 seeds access protocols owned by the - # computers plugin, so any profile omitting computers hit the refusal on - # every single retry and could never get past stage 3. - if ((-not $script:DbWasEmpty) -and (-not $firstRun)) { - Write-Log 'flask plugin prune-schema --yes (no --force: refuses to drop tables holding data)' - # -OkExit 0,1: refusing is the DESIGNED outcome here, and the CLI - # signals it with SystemExit(1). Without this, Invoke-Native failed - # the stage the moment prune declined to drop a table holding rows - - # and the reporting below, which exists precisely to explain that, - # could never run. Core migration 7d05 seeds access protocols owned - # by the computers plugin, so any profile omitting computers hit it - # on every retry. - $pruneOut = Invoke-Native $Flask @('plugin','prune-schema','--yes') ` - 'prune not-installed plugin tables' -OkExit 0,1 - $skipped = @($pruneOut | Where-Object { $_ -match 'REFUSING|rows$|not empty' }) - if ($skipped.Count -gt 0) { - Write-Log 'some plugin tables were kept because they hold data:' 'WARN' - $skipped | ForEach-Object { Write-Log " $_" 'WARN' } - Write-Log ' drop them by hand only if you are certain that data is not needed' 'WARN' - } elseif ($pruneOut -match 'not initialized') { - # The other reason this CLI exits 1, and it is a genuine failure. - Fail 'the plugin manager did not initialise' 'Check the application log; the app could not start.' - } - } else { - # Reached when the database was provably empty, OR when the install - # record says first provisioning is still unfinished. Both mean the - # only rows present are ones this install put there - core migrations - # seed a few plugin reference tables, so prune would otherwise refuse. - if ($script:DbWasEmpty) { - Write-Log 'flask plugin prune-schema --yes --force (database was empty at start)' - } else { - Write-Log 'flask plugin prune-schema --yes --force (still first provisioning: these tables are ours)' - } - Invoke-Native $Flask @('plugin','prune-schema','--yes','--force') 'prune not-installed plugin tables' - } - } - finally { Pop-Location } - Write-Log 'stage 3 complete' 'OK' -} - -# ============================================================================= -# STAGE 4 - IIS -# ============================================================================= -function Invoke-Stage4 { - Write-Log 'STAGE 4: IIS site' 'STEP' - Import-Module WebAdministration -ErrorAction Stop - $appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe' - - # HttpPlatformHandler is what actually launches waitress; IIS cannot serve - # this application without it. The MSI ships in the bundle but nothing ever - # installed it, so a clean server ran all the way to the stage 5 smoke test - - # Python, venv, schema, plugins and IIS all mutated - and only then failed. - # It must also come BEFORE the section unlock below: system.webServer/ - # httpPlatform does not exist until the module is registered. - $hphDll = Join-Path $env:windir 'system32\inetsrv\httpplatformhandler.dll' - if (Test-Path $hphDll) { - Write-Log 'HttpPlatformHandler already installed' 'OK' - } else { - $hphMsi = Get-ChildItem (Join-Path $BundleRoot 'httpplatformhandler') -Filter '*.msi' -ErrorAction SilentlyContinue | - Select-Object -First 1 - if (-not $hphMsi) { - Fail 'HttpPlatformHandler is not installed and no MSI is in the bundle' @' -IIS cannot run this application without the HttpPlatformHandler module. -Add httpplatformhandler\httpPlatformHandler_amd64.msi to the bundle, or install -the module by hand, then re-run stage 4. -'@ - } - Write-Log "installing $($hphMsi.Name)" - if (-not $WhatIfOnly) { - Invoke-Native 'msiexec.exe' @('/i', $hphMsi.FullName, '/quiet', '/norestart') ` - 'HttpPlatformHandler MSI' -TimeoutSec 600 -OkExit 0,3010 - if (-not (Test-Path $hphDll)) { - Fail 'the HttpPlatformHandler MSI reported success but the module is missing' ` - 'An installer exit code of 0 does not prove it did what was asked.' - } - Write-Log 'HttpPlatformHandler installed' 'OK' - } - } - - # --- client IP ----------------------------------------------------------- - # URL Rewrite is what lets the XFF rule below exist. Install it BEFORE - # writing a web.config that references , or IIS answers every - # request with 500.19 until someone works out which module is missing. - if ($ClientIpSource -eq 'direct') { - $rewriteDll = Join-Path $env:windir 'system32\inetsrv\rewrite.dll' - if (Test-Path $rewriteDll) { - Write-Log 'URL Rewrite already installed' 'OK' - } else { - $rwMsi = Get-ChildItem (Join-Path $BundleRoot 'urlrewrite') -Filter '*.msi' -ErrorAction SilentlyContinue | - Select-Object -First 1 - if (-not $rwMsi) { - Fail 'URL Rewrite is not installed and no MSI is in the bundle' @' --ClientIpSource direct needs the IIS URL Rewrite module to set X-Forwarded-For. -Add urlrewrite\rewrite_amd64.msi to the bundle and rebuild, or re-run with --ClientIpSource proxy if something in front of IIS already sets the header. -Installing it later by hand also works: this server has no network, so the MSI -has to come from the bundle either way. -'@ - } - Write-Log "installing $($rwMsi.Name)" - if (-not $WhatIfOnly) { - Invoke-Native 'msiexec.exe' @('/i', $rwMsi.FullName, '/quiet', '/norestart') ` - 'URL Rewrite MSI' -TimeoutSec 600 -OkExit 0,3010 - if (-not (Test-Path $rewriteDll)) { - Fail 'the URL Rewrite MSI reported success but the module is missing' ` - 'An installer exit code of 0 does not prove it did what was asked.' - } - Write-Log 'URL Rewrite installed' 'OK' - } - } - } else { - Write-Log 'client IP comes from an upstream proxy; not installing URL Rewrite' 'OK' - } - - # web.config ships in the repo; its paths need correcting and the client-IP - # rule needs enabling or leaving off. - # - # An EXISTING web.config is never overwritten. It is the one file on the - # server that legitimately carries hand-edits - a nested application for - # /installers, bindings, a proxy-specific rule - and overwriting it reverts - # them silently. On a server where the XFF rule was enabled by hand, that - # alone would turn the GE-Enforce IP allowlist off without a word in any log. - $srcCfg = Join-Path $AppRoot 'deploy\windows\web.config' - $dstCfg = Join-Path $AppRoot 'web.config' - if (Test-Path $dstCfg) { - Write-Log 'web.config already exists; leaving it alone' 'OK' - $existing = Get-Content $dstCfg -Raw - - # ONE EXCEPTION to leaving it alone. An block is - # fatal by itself: that section is Deny by default, so IIS rejects the - # whole file with 500.52 (0x80070021) before httpPlatformHandler runs. - # The app never starts and its stdout log stays empty, which reads as a - # broken application and is not one. - # - # Earlier builds of THIS installer wrote exactly that block, so honouring - # "leave it alone" without qualification would leave every server already - # installed permanently unable to start, with re-running powerless to fix - # it. Strip just that element and keep every other edit - and only when it - # contains nothing but the variable this installer puts there, because - # anything else is somebody's deliberate change. - if ($existing -match '(?s)(.*?)') { - $inner = $Matches[1] - $names = [regex]::Matches($inner, 'name\s*=\s*"([^"]+)"') | - ForEach-Object { $_.Groups[1].Value } - # @() around the pipeline: a zero-match filter returns $null, and - # $null.Count is a terminating error under Set-StrictMode 2.0. - $foreign = @($names | Where-Object { $_ -ne 'HTTP_X_FORWARDED_FOR' }).Count - if ($foreign -gt 0) { - Write-Log 'web.config declares holding variables this installer did not add' 'WARN' - Write-Log ' IIS returns 500.52 unless that section is unlocked server-wide; left unchanged' 'WARN' - } elseif (-not $WhatIfOnly) { - $backup = "$dstCfg.before-xff-fix" - Copy-Item $dstCfg $backup -Force - $fixed = $existing -replace '(?s)[ \t]*.*?\r?\n?', '' - Set-Content -Path $dstCfg -Value $fixed -Encoding UTF8 - Write-Log 'removed from web.config; it made IIS return 500.52' 'OK' - Write-Log " the previous file is kept at $backup" - $existing = $fixed - } - } - - $hasRule = $existing -match '' -and $existing -notmatch 'SHOPDB-CLIENTIP-BEGIN' - if ($ClientIpSource -eq 'direct' -and -not $hasRule) { - Write-Log 'this web.config does NOT set X-Forwarded-For; client IPs will read as 127.0.0.1' 'WARN' - Write-Log ' enable the SHOPDB-CLIENTIP block by hand, or delete web.config and re-run stage 4' 'WARN' - } - if ($ClientIpSource -eq 'proxy' -and $hasRule) { - Write-Log 'this web.config OVERWRITES X-Forwarded-For, which discards the real client IP behind a proxy' 'WARN' - Write-Log ' remove the block by hand if a proxy in front already sets the header' 'WARN' - } - } elseif (Test-Path $srcCfg) { - Write-Log "installing web.config (client IP: $ClientIpSource)" - if (-not $WhatIfOnly) { - $cfg = Get-Content $srcCfg -Raw - $cfg = $cfg.Replace('C:\shopdb-flask', $AppRoot) - if ($ClientIpSource -eq 'direct') { - # Uncomment by deleting the two marker lines. A string operation - # on explicit markers, not a regex over the surrounding prose: - # editing that prose must never silently disable the rule. - if ($cfg -notmatch 'SHOPDB-CLIENTIP-BEGIN') { - Fail 'the shipped web.config has no SHOPDB-CLIENTIP block' ` - 'It was edited. Restore deploy\windows\web.config from the repository.' - } - $cfg = $cfg.Replace('', '') - Write-Log 'X-Forwarded-For rule enabled' 'OK' - } - Set-Content -Path $dstCfg -Value $cfg -Encoding UTF8 - Track 'file' $dstCfg - } - } else { - Fail "web.config not found at $srcCfg or $dstCfg" - } - - # App pool: No Managed Code. This is a Python app; the CLR must not load. - if (Test-Path "IIS:\AppPools\$AppPool") { - Write-Log "app pool $AppPool already exists" 'OK' - } else { - Write-Log "creating app pool $AppPool" - if (-not $WhatIfOnly) { - New-WebAppPool -Name $AppPool | Out-Null - Set-ItemProperty "IIS:\AppPools\$AppPool" -Name managedRuntimeVersion -Value '' - Track 'apppool' $AppPool - } - } - - # ACLs. RX on the tree; Modify on logs\ and instance\ or plugin toggles and - # uploads fail with "internal error". Read on .env for the pool identity. - if (-not $WhatIfOnly) { - $ident = "IIS AppPool\$AppPool" - Write-Log "granting $ident access" - Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot' - # The Python root no longer inherits from the drive root, so the pool has - # no access to it by default. The venv's python.exe is a copy; the DLL - # and the standard library are still read from the base install, so - # without this grant the site 500s on every request. - if ($script:PyRoot -and (Test-Path $script:PyRoot)) { - Invoke-Native 'icacls.exe' @($script:PyRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on Python root' - } - foreach ($sub in @('logs','instance')) { - Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub" - } - Invoke-Native 'icacls.exe' @($EnvFile,'/grant',"${ident}:(R)") 'ACL on .env' - } - - # Switching method must not leave BOTH deployments in place: two entry points - # to one directory, one of them serving an SPA built for the wrong base path. - # Remove whichever artifact belongs to the method we are NOT using. - # - # GUARDED. These removals are correct when this installer owns both artifacts - # - it is how switching method avoids leaving two entry points to one - # directory, one of them serving an SPA built for the wrong base path. They - # are NOT correct against an installation someone else built: a wrong - # -MountAlias would delete a live mount with no prompt and no error, and the - # first sign would be the site 404ing. - # - # 'Ours' means there was a version stamp BEFORE this run started - only this - # installer writes one. Stage 2 records that observation; testing the file - # here instead would read the stamp stage 2 has already written. - $weBuiltThis = if ($null -ne $script:PreexistingInstallStamp) { - $script:PreexistingInstallStamp - } else { - # Stage 4 run on its own, so stage 2 has not written anything this run. - Test-Path (Join-Path $AppRoot '.installed-version') - } - if (-not $WhatIfOnly) { - $doomed = @() - if ($MountAlias) { - if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { - $doomed += @{ Kind = 'site'; Name = $SiteName; Site = '' } - } - } else { - foreach ($site in (Get-Website)) { - foreach ($app in (Get-WebApplication -Site $site.Name -ErrorAction SilentlyContinue)) { - if (Test-SamePath $app.PhysicalPath $AppRoot) { - $doomed += @{ Kind = 'app'; Name = $app.Path.Trim('/'); Site = $site.Name } - } - } - } - } - - if ($doomed.Count -gt 0 -and -not $weBuiltThis -and -not $AdoptExisting) { - foreach ($d in $doomed) { - if ($d.Kind -eq 'site') { Write-Log (" would remove IIS site '{0}'" -f $d.Name) 'FAIL' } - else { Write-Log (" would remove application '/{0}' under '{1}'" -f $d.Name, $d.Site) 'FAIL' } - } - Fail 'this server has an IIS deployment that this installer did not create' @' -Publishing the way you asked means removing what is listed above, and there is -no version stamp to show this installer put it there. Deleting a mount somebody -else configured is not something to do without being asked. - -If the removal is what you want, re-run with -AdoptExisting. -If not, re-run matching how the application is published today: pass --MountAlias to keep it as an application under an existing site, or omit --MountAlias to keep it as a site of its own. -'@ - } - - foreach ($d in $doomed) { - if ($d.Kind -eq 'site') { - Write-Log "removing the previous stand-alone site '$($d.Name)' (now published as a subpath)" 'WARN' - Remove-Website -Name $d.Name -ErrorAction SilentlyContinue - Get-NetFirewallRule -DisplayName ("{0} {1}" -f $d.Name, $SitePort) -ErrorAction SilentlyContinue | - Remove-NetFirewallRule -ErrorAction SilentlyContinue - } else { - Write-Log "removing the previous subpath application '/$($d.Name)' (now its own site)" 'WARN' - Remove-WebApplication -Site $d.Site -Name $d.Name -ErrorAction SilentlyContinue - } - } - } - - if ($MountAlias) { - # METHOD B: an IIS Application under an existing site, reached at - # http://// - no new DNS name and no port in the URL. - # The app's own handler mappings apply only inside the Application, so the - # parent site's handlers (classic ASP, static files) are untouched. - $alias = $MountAlias.Trim('/') - if (-not (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue)) { - Fail "parent site '$ParentSite' does not exist" ` - 'Pass -ParentSite with the name of an existing IIS site, or install without -MountAlias.' - } - $existing = Get-WebApplication -Site $ParentSite -Name $alias -ErrorAction SilentlyContinue - if ($existing) { - Write-Log "application /$alias already exists under '$ParentSite'" 'OK' - } else { - Write-Log "creating application /$alias under '$ParentSite'" - if (-not $WhatIfOnly) { - New-WebApplication -Site $ParentSite -Name $alias -PhysicalPath $AppRoot ` - -ApplicationPool $AppPool | Out-Null - Track 'webapp' ("{0}/{1}" -f $ParentSite, $alias) - } - } - # No firewall rule: the parent site's port is already reachable, which is - # the whole point of this method. - Write-Log "no firewall change needed - served on '$ParentSite' existing bindings" 'OK' - } - else { - # METHOD A: its own site on $SitePort. - if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { - Write-Log "site $SiteName already exists" 'OK' - # RECONCILE, do not just accept it. "Already exists" used to take the - # site however it was, so re-running with a different port left the - # binding alone while everything downstream - CORS_ORIGINS, the - # firewall rule, the shortcut, the stage 5 smoke test - used the NEW - # port. A working server was then reported as a failed install. - # - # Refuse rather than silently re-bind: moving where people reach the - # site is not something to do because a field defaulted. - if (-not $WhatIfOnly) { - $existingPorts = @() - $existingPath = '' - try { - $siteNow = Get-Website -Name $SiteName - $existingPath = [string] $siteNow.physicalPath - foreach ($b in $siteNow.bindings.Collection) { - if ($b.protocol -eq 'http' -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { - $existingPorts += [int] $Matches[1] - } - } - } catch { } - if (($existingPorts.Count -gt 0) -and ($existingPorts -notcontains [int] $SitePort)) { - Fail ("site '{0}' is bound to port {1}, but this run was told to use {2}" -f ` - $SiteName, ($existingPorts -join ', '), $SitePort) ` - 'Re-run and enter the port the site already uses, or change the binding in IIS Manager first.' - } - if ($existingPath -and (-not (Test-SamePath $existingPath $AppRoot))) { - Fail ("site '{0}' serves {1}, not {2}" -f $SiteName, $existingPath, $AppRoot) ` - 'Something else owns that site name. Install into the directory it serves, or use a different site name.' - } - } - } else { - Write-Log "creating site $SiteName on port $SitePort" - if (-not $WhatIfOnly) { - New-Website -Name $SiteName -Port $SitePort -PhysicalPath $AppRoot -ApplicationPool $AppPool | Out-Null - Track 'site' $SiteName - } - } - - $ruleName = "$SiteName $SitePort" - if (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue) { - Write-Log 'firewall rule already exists' 'OK' - } else { - Write-Log "adding firewall rule for TCP $SitePort" - if (-not $WhatIfOnly) { - New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP ` - -LocalPort $SitePort -Action Allow | Out-Null - Track 'firewall' $ruleName - } - } - - if (-not $WhatIfOnly) { Start-Website -Name $SiteName -ErrorAction SilentlyContinue } - } - - # Handler sections are locked server-wide by default. Without unlocking, - # IIS returns 500.19 the moment it reads the app's web.config. - # - # `appcmd unlock config /section:X` unlocks it for EVERY site on the machine, - # which on a shared server hands every other application the ability to - # define its own handlers. Delegate to our own location instead, and fall - # back to the server-wide unlock only if that is refused - a 500.19 the - # operator cannot diagnose is worse than a wider delegation, but it should be - # the second choice, not the first. - # - # MUST run AFTER the site or application exists. appcmd resolves the location - # against applicationHost.config, so unlocking "Default Web Site/shopdb" - # before New-WebApplication created it failed with 80070003 ("the system - # cannot find the path specified") on every first install - and the fallback - # then handed handler delegation to the whole machine, which is exactly the - # outcome the comment above calls the second choice. A first install could - # never take the scoped path. - $ourLocation = if ($MountAlias) { "$ParentSite/$($MountAlias.Trim('/'))" } else { $SiteName } - foreach ($section in @('system.webServer/handlers','system.webServer/httpPlatform')) { - if ($WhatIfOnly) { Write-Log "would unlock $section for $ourLocation"; continue } - Write-Log "unlocking $section for $ourLocation" - # EAP must drop to Continue around these. appcmd writes to stderr on - # ordinary refusals, and '2>&1' with $ErrorActionPreference = 'Stop' - # turns that into a TERMINATING NativeCommandError - so the exit-code - # test and the server-wide fallback below, which is the entire point of - # the block, were unreachable, and stage 4 aborted after Python, the - # venv, the schema and the ACLs had all been changed. - $prevEap = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { - $scoped = & $appcmd unlock config "$ourLocation" /section:$section 2>&1 - $scoped | ForEach-Object { Write-Log " $_" } - if ($LASTEXITCODE -ne 0) { - Write-Log " scoped unlock refused; unlocking $section server-wide" 'WARN' - & $appcmd unlock config /section:$section 2>&1 | ForEach-Object { Write-Log " $_" } - if ($LASTEXITCODE -ne 0) { - Write-Log " server-wide unlock also refused - IIS may return 500.19" 'WARN' - } - } - } finally { $ErrorActionPreference = $prevEap } - } - - # URL Rewrite: a rule may only set a server variable that is ALLOWED, and - # system.webServer/rewrite/allowedServerVariables ships with - # overrideModeDefault="Deny". An block in the app's - # own web.config is therefore refused outright - IIS answered 500.52 with - # 0x80070021, "this configuration section cannot be used at this path", - # before it ever reached httpPlatformHandler. python was never launched, the - # stdout log stayed empty, and the stage 5 smoke test reported it as a dead - # application when the config had been rejected wholesale. - # - # Allow the ONE variable at server level rather than unlocking the section. - # Unlocking would let every site on this machine declare arbitrary server - # variables; this grants exactly HTTP_X_FORWARDED_FOR. - if (($ClientIpSource -eq 'direct') -and (-not $WhatIfOnly)) { - $varName = 'HTTP_X_FORWARDED_FOR' - $section = 'system.webServer/rewrite/allowedServerVariables' - $prevEap = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { - # Adding a duplicate entry is an error, so look before writing. - $current = (& $appcmd list config /section:$section 2>&1 | Out-String) - if ($current -match [regex]::Escape($varName)) { - Write-Log "$varName is already allowed for URL Rewrite" 'OK' - } else { - Write-Log "allowing $varName for URL Rewrite (server level)" - & $appcmd set config /section:$section "/+[name='$varName']" /commit:apphost 2>&1 | - ForEach-Object { Write-Log " $_" } - if ($LASTEXITCODE -ne 0) { - Write-Log " could not allow $varName - IIS will return 500.52 and the app will not start" 'WARN' - } - } - } finally { $ErrorActionPreference = $prevEap } - } - - # Stage 2 stops the pool on an upgrade so its files can be replaced. Nothing - # else starts it again, so the smoke test would fail against a stopped site - # and report it as a broken install. - try { - Import-Module WebAdministration -ErrorAction SilentlyContinue - if (Test-Path "IIS:\AppPools\$AppPool") { - if ((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started') { - Write-Log "starting app pool $AppPool" - Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue - Start-Sleep -Seconds 2 - } - } - $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue - if ($site -and $site.State -ne 'Started') { - Write-Log "starting site $SiteName" - Start-Website -Name $SiteName -ErrorAction SilentlyContinue - } - } catch { Write-Log "could not start the site: $($_.Exception.Message)" 'WARN' } - - Write-Log 'stage 4 complete' 'OK' -} - -# ============================================================================= -# STAGE 5 - verify and hand off -# ============================================================================= -function Invoke-Stage5 { - Write-Log 'STAGE 5: smoke test' 'STEP' - if ($WhatIfOnly) { Write-Log 'WhatIf: skipping smoke test' 'WARN'; return } - - # Stages can be run one at a time, so do not assume stage 4 just ran. - try { - Import-Module WebAdministration -ErrorAction SilentlyContinue - if ((Test-Path "IIS:\AppPools\$AppPool") -and - ((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started')) { - Write-Log "app pool was stopped; starting it before the smoke test" 'WARN' - Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue - Start-Sleep -Seconds 3 - } - } catch { } - - # FR-150: test the host operators will actually use, not just the loopback. - # localhost alone passes even when the app is unreachable by name and when - # CORS_ORIGINS names a host that does not resolve - so the SPA would fail its - # first XHR on a site the installer just called healthy. - $hostName = if ($SiteHost) { $SiteHost } else { $env:COMPUTERNAME } - # Under method B the app answers on the PARENT site's port, at the alias - - # requesting http://localhost:8090/ would test a site that does not exist. - if ($MountAlias) { - $alias = $MountAlias.Trim('/') - # Prefer an http binding, but fall back to https. A parent site published - # ONLY over https - which is normal, and which the operator did nothing - # wrong to have - left this defaulting to port 80, requesting a URL that - # answers nothing, and failing a working install with a red dialog. - $parentScheme = 'http' - $parentPort = 0 - try { - $bindings = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection - $b = $bindings | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 - if (-not $b) { - $b = $bindings | Where-Object { $_.protocol -eq 'https' } | Select-Object -First 1 - if ($b) { $parentScheme = 'https' } - } - if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] } - } catch { } - if ($parentPort -eq 0) { $parentPort = if ($parentScheme -eq 'https') { 443 } else { 80 } } - - $targets = @("{0}://localhost:{1}/{2}/" -f $parentScheme, $parentPort, $alias) - if ($hostName -and $hostName -ne 'localhost') { - $targets += "{0}://{1}:{2}/{3}/" -f $parentScheme, $hostName, $parentPort, $alias - } - # An https parent almost certainly has a certificate for its real name, - # not for 'localhost', and a certificate complaint is not an application - # fault. Accept any certificate for the duration of the smoke test only. - if ($parentScheme -eq 'https') { - Write-Log 'parent site is https-only; certificate validation is skipped for this check' 'WARN' - try { - Add-Type -TypeDefinition @' -using System.Net; -using System.Security.Cryptography.X509Certificates; -public class ShopdbSmokeTestCertPolicy : ICertificatePolicy { - public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { - return true; - } -} -'@ -ErrorAction SilentlyContinue - [System.Net.ServicePointManager]::CertificatePolicy = New-Object ShopdbSmokeTestCertPolicy - } catch { } - } - } else { - $targets = @("http://localhost:$SitePort/") - if ($hostName -and $hostName -ne 'localhost') { - $targets += "http://{0}:{1}/" -f $hostName, $SitePort - } - } - - # First request boots the app and connects to MySQL: the runbook records ~15s. - Write-Log "requesting $($targets[0]) (first request takes ~15s while the app boots)" - $ok = $false - $lastStatus = 'no response' - $lastDetail = '' - for ($i = 1; $i -le 12; $i++) { - try { - $r = Invoke-WebRequest -Uri $targets[0] -UseBasicParsing -TimeoutSec 20 - $lastStatus = "HTTP $($r.StatusCode)" - if ($r.StatusCode -eq 200) { $ok = $true; break } - # A non-200 that does NOT throw (a redirect, say) is still not success. - # Without this sleep the loop burned all 12 attempts instantly and - # reported a timeout that never waited for anything. - Start-Sleep -Seconds 5 - } catch { - # The status code and the response body ARE the diagnosis, and this - # block used to discard both - leaving a generic "check the logs" for - # a fault IIS had already named. 500.19 = section still locked, - # 502.3/503 = httpPlatform could not start the app, 404 = the - # application or its handler mapping is missing. - $lastStatus = 'no response' - $lastDetail = $_.Exception.Message - try { - $resp = $_.Exception.Response - if ($resp) { - $lastStatus = "HTTP {0} ({1})" -f [int]$resp.StatusCode, $resp.StatusCode - $reader = New-Object System.IO.StreamReader($resp.GetResponseStream()) - $body = $reader.ReadToEnd() - $reader.Close() - if ($body) { - # IIS error pages state the precise cause in their body; - # strip the markup so it fits in the log readably. - $clean = (($body -replace '<[^>]+>', ' ') -replace '\s+', ' ').Trim() - if ($clean) { - if ($clean.Length -gt 500) { $clean = $clean.Substring(0, 500) + ' ...' } - $lastDetail = $clean - } - } - } - } catch { } - Start-Sleep -Seconds 5 - } - } - if (-not $ok) { - Write-Log " last response: $lastStatus" 'FAIL' - if ($lastDetail) { Write-Log " $lastDetail" 'FAIL' } - # The HttpPlatform stdout log is where a Python traceback lands. Reading it - # here saves a round trip: whoever is sending this log back already has the - # cause in it, instead of being told which second file to go and open. - try { - $logDir = Join-Path $AppRoot 'logs' - $newest = Get-ChildItem $logDir -Filter '*.log' -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 - if (-not $newest) { - Write-Log " no HttpPlatform log in $logDir - the pool never launched python" 'FAIL' - } elseif ($newest.Length -eq 0) { - Write-Log " $($newest.Name) is empty - python was launched but wrote nothing" 'FAIL' - } else { - Write-Log " tail of $($newest.Name):" 'FAIL' - Get-Content $newest.FullName -Tail 20 -ErrorAction SilentlyContinue | - ForEach-Object { Write-Log " $_" 'FAIL' } - } - } catch { } - Fail "site did not return 200 at $($targets[0])" ` - "Check $AppRoot\logs. Empty HttpPlatform log usually means the app-pool identity cannot read $AppRoot or run the venv, or .env is missing/invalid." - } - Write-Log 'site responded 200 on localhost' 'OK' - - # The app is up by now, so the configured-host check needs no boot retry. A - # failure here is a name-resolution or firewall problem, not a broken install, - # so warn rather than Fail: rolling back a working install would be worse. - foreach ($t in $targets[1..($targets.Count - 1)]) { - try { - $r = Invoke-WebRequest -Uri $t -UseBasicParsing -TimeoutSec 20 - if ($r.StatusCode -eq 200) { Write-Log "site responded 200 on $t" 'OK' } - else { Write-Log "unexpected status $($r.StatusCode) at $t" 'WARN' } - } catch { - Write-Log "site did NOT respond at $t - CORS_ORIGINS names this host, so the SPA will fail its first request from other machines" 'WARN' - Write-Log " check DNS/hosts for '$hostName' and the firewall rule on TCP $SitePort" 'WARN' - } - } - - # Is the client-IP rule actually in effect? It is the control that makes - # remote_addr trustworthy: without it IIS forwards whatever X-Forwarded-For - # the CALLER sent, waitress trusts it because it arrives from loopback, and - # the GE-Enforce IP allowlist becomes bypassable from anywhere on the network. - # It does not fail closed. Verified here rather than assumed, because the - # failure is silent and looks exactly like a working server. - if ($ClientIpSource -eq 'direct') { - $liveCfg = Join-Path $AppRoot 'web.config' - $cfgText = if (Test-Path $liveCfg) { Get-Content $liveCfg -Raw } else { '' } - $ruleLive = ($cfgText -match '') -and ($cfgText -notmatch 'SHOPDB-CLIENTIP-BEGIN') - $moduleThere = Test-Path (Join-Path $env:windir 'system32\inetsrv\rewrite.dll') - if ($ruleLive -and $moduleThere) { - Write-Log 'client-IP rule is live; remote_addr is trustworthy' 'OK' - } else { - Write-Log 'CLIENT-IP RULE IS NOT IN EFFECT' 'WARN' - if (-not $moduleThere) { Write-Log ' URL Rewrite module is not installed' 'WARN' } - if (-not $ruleLive) { Write-Log ' web.config does not have an active block' 'WARN' } - Write-Log ' every client will be recorded as 127.0.0.1, and the GE-Enforce IP' 'WARN' - Write-Log ' allowlist is BYPASSABLE - a caller can send its own X-Forwarded-For' 'WARN' - Write-Log ' re-run with -ClientIpSource direct, or use token authentication' 'WARN' - } - } - - # The self-hosted API documentation. It is the thing an operator - or the - # assistant an operator is asking - reaches for on a server with no internet, - # and it is served from a file that has to have been staged into the build. - # A missing file makes /api/docs render an empty page, which nobody notices - # until it is needed. Warn, never fail: the application itself is fine. - $base = $targets[0].TrimEnd('/') - try { - $d = Invoke-WebRequest -Uri "$base/api/docs/openapi.json" -UseBasicParsing -TimeoutSec 20 - if ($d.StatusCode -eq 200) { Write-Log 'API documentation is being served at /api/docs' 'OK' } - else { Write-Log "/api/docs/openapi.json returned $($d.StatusCode)" 'WARN' } - } catch { - Write-Log '/api/docs is NOT working - docs/openapi.json was not staged into this build' 'WARN' - Write-Log ' the site runs fine; offline API reference and LLM assistance will not' 'WARN' - } - - # The install is proven working, so the stage-0 handoff is no longer the only - # copy of the generated password (.env has it and the app is running on it). - # Safe to shred now, and only now - see the note in stage 2. - $handoff = Join-Path $AppRoot '.dbpass' - if (Test-Path $handoff) { - try { - Set-Content -Path $handoff -Value ('0' * 256) -Encoding UTF8 -Force - Remove-Item $handoff -Force - Write-Log 'stage-0 password handoff shredded (install verified)' 'OK' - } catch { Write-Log "could not remove $handoff - delete it manually" 'WARN' } - } - - Write-Host '' - Write-Host ' ShopDB-Flask is installed.' -ForegroundColor Green - Write-Host '' - # FR-151: send the operator to /login, not to /. The site root renders the - # anonymous dashboard; the first-run gate lives on the login route, so an - # operator landing on / sees an empty dashboard and no way to discover setup. - if ($MountAlias) { - Write-Host (" Open: http://{0}/{1}/login" -f $hostName, $MountAlias.Trim('/')) - } else { - Write-Host (" Open: http://{0}:{1}/login" -f $hostName, $SitePort) - } - Write-Host '' - Write-Host ' With no user in the database that page offers to create the first' - Write-Host ' administrator, then runs the setup wizard (site details, features,' - Write-Host ' floor map, common vendors).' - Write-Host '' - Write-Host ' Headless alternative:' - Write-Host (" cd {0}" -f $AppRoot) - Write-Host ' venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example' - Write-Host ' (the password is generated and printed once)' - Write-Host '' - Write-Host (" Install log: {0}" -f $script:LogPath) - Write-Host '' -} - -# ============================================================================= -# Rollback -# ============================================================================= -function Invoke-Rollback { - Write-Log 'rolling back what this run created' 'WARN' - # Reverse order. Never touches an external database, and never removes a - # data directory without explicit confirmation. - for ($i = $script:Created.Count - 1; $i -ge 0; $i--) { - $item = $script:Created[$i] - try { - switch ($item.Kind) { - 'site' { Remove-Website -Name $item.Id -ErrorAction SilentlyContinue; Write-Log " removed site $($item.Id)" } - 'webapp' { - $parts = $item.Id -split '/', 2 - Remove-WebApplication -Site $parts[0] -Name $parts[1] -ErrorAction SilentlyContinue - Write-Log " removed application /$($parts[1]) under '$($parts[0])'" - } - 'apppool' { Remove-WebAppPool -Name $item.Id -ErrorAction SilentlyContinue; Write-Log " removed app pool $($item.Id)" } - 'firewall' { Remove-NetFirewallRule -DisplayName $item.Id -ErrorAction SilentlyContinue; Write-Log " removed firewall rule" } - 'file' { - # Overwrite before deleting, same as uninstall does. A rolled - # back install left .env - which holds the database password - # in plaintext by design - recoverable on disk. - try { - if ((Split-Path $item.Id -Leaf) -eq '.env') { - Set-Content -Path $item.Id -Value ('0' * 512) -Encoding UTF8 -Force - } - } catch { } - Remove-Item $item.Id -Force -ErrorAction SilentlyContinue - Write-Log " removed $($item.Id)" - } - 'dir' { Write-Log " left directory $($item.Id) in place (remove manually if wanted)" 'WARN' } - 'python' { Write-Log " left Python install in place at $($item.Id)" 'WARN' } - 'service' { - # Stop and deregister a service THIS run created. Safe because - # stage 0 refuses to run when any MySQL service already exists. - Stop-Service -Name $item.Id -Force -ErrorAction SilentlyContinue - & sc.exe delete $item.Id | Out-Null - Write-Log " removed service $($item.Id)" - } - 'mysql-files' { Write-Log " left MySQL binaries/config at $($item.Id) (remove manually if wanted)" 'WARN' } - # FR-162a: never delete a database directory automatically. Even on - # a failed run the operator may have put data in it, and a wrong - # guess here is unrecoverable. - 'mysql-datadir' { - Write-Log " LEFT MySQL data directory $($item.Id) IN PLACE" 'WARN' - Write-Log ' delete it manually only if you are certain it holds no data' 'WARN' - } - } - } catch { Write-Log " rollback of $($item.Kind) $($item.Id) failed: $($_.Exception.Message)" 'WARN' } - } -} - -# ============================================================================= -# Uninstall -# ============================================================================= -# Deliberately conservative. It removes what the installer creates and NEVER -# touches data without a typed confirmation: no dropped databases, no deleted -# data directory, no uninstalled MySQL that might serve another application. -function Invoke-Uninstall { - Write-Log 'UNINSTALL' 'STEP' - Write-Host '' - Write-Host ' This removes the ShopDB-Flask IIS site, app pool, firewall rule and' -ForegroundColor Yellow - Write-Host (" the application directory {0}." -f $AppRoot) -ForegroundColor Yellow - Write-Host ' It does NOT drop any database and does NOT uninstall MySQL.' -ForegroundColor Yellow - Write-Host '' - if ($OnFailure -ne 'never') { - $answer = Read-Host 'Type UNINSTALL to proceed' - if ($answer -ne 'UNINSTALL') { Write-Log 'aborted by operator'; return } - } - - Import-Module WebAdministration -ErrorAction SilentlyContinue - - # A SUBPATH install has no site of its own - it is an Application under an - # existing one. Removing only the site left the application in place, pointed - # at a directory this is about to delete, so the parent site (at West - # Jefferson, the live classic ASP) served 503 on that path forever and - # Add/Remove Programs reported success. - # - # The alias is whatever MOUNT_PATH says, which is the same value wsgi.py - # mounts on, so it cannot disagree with how the app was actually published. - $mount = '' - $envFile = Join-Path $AppRoot '.env' - if (Test-Path $envFile) { - $line = Get-Content $envFile -ErrorAction SilentlyContinue | - Where-Object { $_ -like 'MOUNT_PATH=*' } | Select-Object -First 1 - if ($line) { $mount = $line.Substring('MOUNT_PATH='.Length).Trim().Trim('/') } - } - if ($mount) { - $removed = $false - foreach ($site in (Get-Website -ErrorAction SilentlyContinue)) { - $app = Get-WebApplication -Site $site.Name -Name $mount -ErrorAction SilentlyContinue - if (-not $app) { continue } - # OWNERSHIP CHECK. Matching on the alias alone removed ANY application - # on the server that happened to share the name - a second instance, - # or an unrelated /shopdb under a different site - and -OnFailure - # never suppresses the typed confirmation, so it happened unattended. - if (-not (Test-SamePath $app.PhysicalPath $AppRoot)) { - Write-Log ("left /{0} under '{1}' alone: it serves {2}, not {3}" -f ` - $mount, $site.Name, $app.PhysicalPath, $AppRoot) 'WARN' - continue - } - Remove-WebApplication -Site $site.Name -Name $mount -ErrorAction SilentlyContinue - Write-Log "removed application /$mount from site '$($site.Name)'" 'OK' - $removed = $true - } - if (-not $removed) { Write-Log "application /$mount not present" } - } - - if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { - Remove-Website -Name $SiteName -ErrorAction SilentlyContinue - Write-Log "removed site $SiteName" 'OK' - } else { Write-Log "site $SiteName not present" } - - if (Test-Path "IIS:\AppPools\$AppPool") { - # Stop first: a running worker holds the app directory open and the - # subsequent Remove-Item then fails with "file in use". - Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue - Start-Sleep -Seconds 3 - Remove-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue - Write-Log "removed app pool $AppPool" 'OK' - } else { Write-Log "app pool $AppPool not present" } - - # Stage 4 creates the rule as "$SiteName $SitePort". This looked for the - # literal "ShopDB-Flask 8090", which is not the same string as the default - # "shopdb-flask 8090" and matches nothing at all on a non-default port, so - # the rule outlived the uninstall. Build the name the same way stage 4 does, - # and sweep any rule left by a differently-ported install of the same site. - $rule = "$SiteName $SitePort" - if (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue) { - Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue - Write-Log "removed firewall rule '$rule'" 'OK' - } else { Write-Log "firewall rule '$rule' not present" } - Get-NetFirewallRule -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -like "$SiteName *" -and $_.DisplayName -ne $rule } | - ForEach-Object { - Remove-NetFirewallRule -DisplayName $_.DisplayName -ErrorAction SilentlyContinue - Write-Log "removed leftover firewall rule '$($_.DisplayName)'" 'OK' - } - - if (Test-Path $AppRoot) { - # .env holds the database password in plaintext by design (the app reads it - # at boot), so shred it rather than just unlinking. - $envPath = Join-Path $AppRoot '.env' - if (Test-Path $envPath) { - Set-Content -Path $envPath -Value ('0' * 1024) -ErrorAction SilentlyContinue - Write-Log 'overwrote .env before deletion (it holds the DB password)' - } - try { - Remove-Item $AppRoot -Recurse -Force -ErrorAction Stop - Write-Log "removed $AppRoot" 'OK' - } catch { - Write-Log "could not fully remove $AppRoot : $($_.Exception.Message)" 'WARN' - Write-Log ' a running worker process usually holds it; try again after iisreset' 'WARN' - } - } else { Write-Log "$AppRoot not present" } - - Write-Host '' - Write-Host ' Uninstalled.' -ForegroundColor Green - Write-Host '' - Write-Host (" The database '{0}' was NOT dropped." -f $DbName) - Write-Host ' MySQL was NOT uninstalled or modified.' - Write-Host ' Remove either manually if this box is being decommissioned.' - Write-Host '' -} - -# ============================================================================= -# Main -# ============================================================================= -Write-Log "ShopDB-Flask installer, stage=$Stage, approot=$AppRoot, bundle=$BundleRoot" 'STEP' -if ($WhatIfOnly) { Write-Log 'WhatIfOnly: no changes will be made' 'WARN' } - -try { - # Before ANY stage, so that a stage which dies still leaves a record saying - # provisioning had started. Skipped for uninstall, which must not create a - # record of an install it is removing. - if ($Stage -ne 'uninstall') { Initialize-InstallState } - - switch ($Stage) { - '0' { Invoke-Stage0 } - '1' { & (Join-Path $PSScriptRoot 'shopdb-preflight.ps1') -SitePort $SitePort -AppRoot $AppRoot } - '2' { Invoke-Stage2 } - '3' { Invoke-Stage3 } - '4' { Invoke-Stage4 } - '5' { Invoke-Stage5 } - # Stage 0 is NOT in 'all' on purpose: most sites already run MySQL, and - # installing a second server on top of an existing one is destructive. - 'all' { Invoke-Stage2; Invoke-Stage3; Invoke-Stage4; Invoke-Stage5 } - 'uninstall' { Invoke-Uninstall } - } - # Provisioning is finished only when a run that INCLUDED the later stages - # completed. A successful stage 2 on its own leaves first-run open, because - # the schema and the site are still ahead of it, and the retry that follows - # must still be treated as part of the first install. - if ($Stage -eq 'all' -or $Stage -eq '5') { Complete-InstallState } - Write-Log "done. log: $script:LogPath" 'OK' - exit 0 -} -catch { - Write-Log $_.Exception.Message 'FAIL' - if ($script:Created.Count -gt 0) { - $doRollback = $false - if ($OnFailure -eq 'always') { $doRollback = $true } - elseif ($OnFailure -eq 'ask') { - Write-Host '' - $answer = Read-Host 'Roll back what this run created? (yes/no)' - $doRollback = ($answer -eq 'yes') - } - if ($doRollback) { Invoke-Rollback } - else { Write-Log 'rollback declined; partial install left in place' 'WARN' } - } - # Stage 2 stops the pool on an upgrade so files can be replaced. If the run - # then failed, a site that was SERVING before this started is now off, and - # saying only "part-configured" left the operator to discover that from users. - # Not restarted automatically: after a failure in or past stage 3 the deployed - # code and the schema may no longer agree, and starting it would put a broken - # application back in front of people. Say it plainly instead. - $poolStopped = $false - try { $poolStopped = [bool] $script:PoolWasStopped } catch { } - if ($poolStopped) { - Write-Log "the application pool '$AppPool' was stopped for this upgrade and is STILL STOPPED" 'FAIL' - Write-Log ' the site was serving before this run and is now offline' 'FAIL' - Write-Log " once the cause above is fixed, re-run this installer, or start it by hand with:" 'FAIL' - Write-Log " Start-WebAppPool -Name '$AppPool'" 'FAIL' - } - Write-Log "log: $script:LogPath" 'FAIL' - exit 1 -} diff --git a/deploy/windows/installer/shopdb-preflight.ps1 b/deploy/windows/installer/shopdb-preflight.ps1 deleted file mode 100644 index 75559da2..00000000 --- a/deploy/windows/installer/shopdb-preflight.ps1 +++ /dev/null @@ -1,562 +0,0 @@ -<# -.SYNOPSIS - ShopDB-Flask installer - Stage 1: read-only preflight. - -.DESCRIPTION - Discovers everything the installer needs to know about this box and reports - it. Makes NO changes: no installs, no config edits, no service restarts. - Safe to run on a production server. - - Written for stock Windows PowerShell 5.1 (Windows Server ships it). No - pwsh-only syntax, no external modules, no network access. - -.PARAMETER SitePort - The port the ShopDB site will listen on. Default 8090 (the runbook's example; - the classic ASP site keeps 8080). - -.PARAMETER AppRoot - Intended install directory. Default C:\shopdb-flask. - -.PARAMETER Json - Emit machine-readable JSON instead of the human report. Later installer - stages consume this. - -.EXAMPLE - powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1 - powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1 -Json > preflight.json - -.NOTES - Exit 0 = no blocking problems. Exit 1 = at least one FAIL. -#> - -[CmdletBinding()] -param( - [int] $SitePort = 8090, - [string] $AppRoot = 'C:\shopdb-flask', - # Needed so the port check can tell OUR site apart from a stranger's. - [string] $SiteName = 'shopdb-flask', - [switch] $Json, - # Machine-readable output for a GUI caller: one record per line, - # STATUS|AREA|CHECK|DETAIL|FIX - # The console rendering below aligns columns with padding spaces, which only - # works in a fixed-width font at console width. A GUI must do its own layout, - # so give it DATA and let it decide - do not make it parse a formatted table. - # (-Json exists too, but Inno's Pascal Script has no JSON parser.) - [switch] $Delimited -) - -Set-StrictMode -Version 2.0 -$ErrorActionPreference = 'Stop' - -# --- result collection ------------------------------------------------------- -# Every check appends one record. Status is PASS / WARN / FAIL / INFO. -$script:Results = New-Object System.Collections.ArrayList -$script:IisPresent = $false - -function Add-Result { - param( - [string] $Area, - [string] $Check, - [ValidateSet('PASS','WARN','FAIL','INFO','SKIP')] [string] $Status, - [string] $Detail, - [string] $Fix = '' - ) - $null = $script:Results.Add([PSCustomObject]@{ - Area = $Area - Check = $Check - Status = $Status - Detail = $Detail - Fix = $Fix - }) -} - -# Wrap a check so one failure cannot abort the whole run. On an unfamiliar box -# an unexpected exception is itself a finding, not a crash. -function Invoke-Check { - param([string] $Area, [string] $Check, [scriptblock] $Body) - try { & $Body } - catch { - Add-Result $Area $Check 'WARN' "check could not run: $($_.Exception.Message)" ` - 'Report this output; the installer needs to handle this box shape.' - } -} - -# ============================================================================= -# 1. Operator context -# ============================================================================= - -Invoke-Check 'System' 'Elevation' { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - $adm = (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator) - if ($adm) { Add-Result 'System' 'Elevation' 'PASS' 'running as Administrator' } - else { - Add-Result 'System' 'Elevation' 'FAIL' 'not elevated' ` - 'Re-run PowerShell as Administrator. IIS and service changes require it.' - } -} - -Invoke-Check 'System' 'Windows version' { - $os = Get-CimInstance Win32_OperatingSystem - $name = $os.Caption - $ver = $os.Version - # ProductType: 1 = workstation, 2 = domain controller, 3 = server - $isServer = ($os.ProductType -ne 1) - $detail = "$name (build $ver), $(if ($isServer) {'Server'} else {'Client'})" - - $supported = $false - if ($isServer -and [version]$ver -ge [version]'10.0.17763') { $supported = $true } # 2019+ - if (-not $isServer -and [version]$ver -ge [version]'10.0.19045') { $supported = $true } # Win10 22H2+ - - if ($supported) { Add-Result 'System' 'Windows version' 'PASS' $detail } - else { - Add-Result 'System' 'Windows version' 'FAIL' $detail ` - 'Supported: Windows Server 2019/2022+, or Windows 10 22H2 / 11 Pro+.' - } - - # Client SKUs must be Pro/Enterprise/Education for IIS. - if (-not $isServer -and $name -match 'Home') { - Add-Result 'System' 'Windows edition' 'FAIL' 'Windows Home edition' ` - 'IIS is not available on Home editions. Pro or higher is required.' - } -} - -Invoke-Check 'System' 'Architecture' { - if ([Environment]::Is64BitOperatingSystem) { - Add-Result 'System' 'Architecture' 'PASS' '64-bit' - } else { - Add-Result 'System' 'Architecture' 'FAIL' '32-bit' ` - 'The bundled Python and wheels are 64-bit (win_amd64) only.' - } -} - -Invoke-Check 'System' 'PowerShell version' { - $v = $PSVersionTable.PSVersion - Add-Result 'System' 'PowerShell version' 'INFO' "$v" - if ($v.Major -lt 5) { - Add-Result 'System' 'PowerShell version' 'FAIL' "$v" ` - 'PowerShell 5.1 or later is required.' - } -} - -# ============================================================================= -# 2. Disk and ports -# ============================================================================= - -Invoke-Check 'Disk' 'Free space' { - $drive = (Split-Path -Qualifier $AppRoot) - $d = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$drive'" - if ($null -eq $d) { - Add-Result 'Disk' 'Free space' 'FAIL' "drive $drive not found" ` - "Choose an -AppRoot on an existing volume." - return - } - $freeGB = [math]::Round($d.FreeSpace / 1GB, 1) - $detail = "$freeGB GB free on $drive" - if ($freeGB -ge 5) { Add-Result 'Disk' 'Free space' 'PASS' $detail } - else { Add-Result 'Disk' 'Free space' 'FAIL' $detail 'At least 5 GB is required.' } -} - -Invoke-Check 'Disk' 'AppRoot' { - if (Test-Path $AppRoot) { - $existing = @(Get-ChildItem $AppRoot -Force -ErrorAction SilentlyContinue) - if ($existing.Count -gt 0) { - $hasEnv = Test-Path (Join-Path $AppRoot '.env') - if ($hasEnv) { - # An existing install is the NORMAL state for an upgrade. Reporting - # it as a warning makes a routine update look like a problem. - $ver = '' - $vf = Join-Path $AppRoot '.installed-version' - if (Test-Path $vf) { $ver = ' version ' + (Get-Content $vf -TotalCount 1).Trim() } - Add-Result 'Disk' 'AppRoot' 'INFO' ` - "existing ShopDB-Flask install found$ver - it will be upgraded in place, and your settings and database are kept" - } else { - Add-Result 'Disk' 'AppRoot' 'WARN' "$AppRoot exists and is not empty" ` - 'Confirm this directory is safe to install into.' - } - } else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot exists and is empty" } - } else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot does not exist yet" } -} - -function Test-PortFree { - param([int] $Port) - # Get-NetTCPConnection is the reliable listener check on Server 2012R2+. - try { - $listening = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) - return ($listening.Count -eq 0) - } catch { - # Fall back to a bind attempt if the cmdlet is unavailable. - try { - $l = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Any, $Port) - $l.Start(); $l.Stop(); return $true - } catch { return $false } - } -} - -Invoke-Check 'Network' 'Site port' { - if (Test-PortFree $SitePort) { - Add-Result 'Network' 'Site port' 'PASS' "TCP $SitePort is free" - } else { - $owner = '' - try { - $c = Get-NetTCPConnection -State Listen -LocalPort $SitePort -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($c) { $owner = " (pid $($c.OwningProcess): $((Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).ProcessName))" } - } catch { } - # Is the listener OUR OWN site? On a reinstall or upgrade the port is held - # by the very application being upgraded, and blocking on that makes the - # installer refuse to update anything it previously installed. - $ours = $false - try { - Import-Module WebAdministration -ErrorAction SilentlyContinue - $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue - if ($site) { - foreach ($b in $site.bindings.Collection) { - # ${} is required: "$SitePort:" parses as a DRIVE-qualified variable. - if ($b.bindingInformation -match ":${SitePort}:") { $ours = $true } - } - } - } catch { } - - if ($ours) { - Add-Result 'Network' 'Site port' 'INFO' ` - "TCP $SitePort is used by the existing $SiteName site - this will be upgraded in place" - } else { - # WARN, not FAIL. This check runs before the operator has reached the - # Address page, so it is testing the DEFAULT port, not necessarily - # the one they intend to use. Blocking here would refuse an install - # over a conflict the very next page lets them resolve. - Add-Result 'Network' 'Site port' 'WARN' "TCP $SitePort is in use$owner" ` - "Pick a different port on the Address page later in this wizard, or stop whatever is holding it." - } - } -} - -# ============================================================================= -# 3. IIS -# ============================================================================= - -Invoke-Check 'IIS' 'Installed' { - $svc = Get-Service -Name W3SVC -ErrorAction SilentlyContinue - $script:IisPresent = ($null -ne $svc) - if ($null -eq $svc) { - Add-Result 'IIS' 'Installed' 'FAIL' 'W3SVC service not found' ` - 'Install IIS. Server: Install-WindowsFeature Web-Server -IncludeManagementTools. Client: enable Internet Information Services in Windows Features.' - return - } - Add-Result 'IIS' 'Installed' 'PASS' "W3SVC present, status $($svc.Status)" - if ($svc.Status -ne 'Running') { - Add-Result 'IIS' 'Running' 'WARN' "W3SVC is $($svc.Status)" 'Start-Service W3SVC' - } -} - -Invoke-Check 'IIS' 'WebAdministration module' { - $m = Get-Module -ListAvailable -Name WebAdministration - if ($m) { Add-Result 'IIS' 'WebAdministration module' 'PASS' 'available' } - else { - Add-Result 'IIS' 'WebAdministration module' 'FAIL' 'not available' ` - 'Install the IIS management tools (Web-Mgmt-Console / IIS Management Scripts and Tools).' - } -} - -Invoke-Check 'IIS' 'HttpPlatformHandler' { - # The handler registers itself as a global module. Check the module list. - $appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe' - if (-not (Test-Path $appcmd)) { - Add-Result 'IIS' 'HttpPlatformHandler' 'SKIP' 'appcmd.exe not present (IIS not installed)' ` - 'Re-run this preflight after installing IIS.' - return - } - $modules = & $appcmd list module 2>$null - if ($modules -match 'httpPlatformHandler') { - Add-Result 'IIS' 'HttpPlatformHandler' 'PASS' 'installed' - } else { - # NOT a blocker: the MSI is in the bundle and stage 4 installs it. This - # was a FAIL, which - once the preflight page started blocking on any - # failure - stopped the wizard dead over something the installer was - # about to do by itself, with no way forward but to go and install it by - # hand. Nothing the installer SUPPLIES may be a blocker. - Add-Result 'IIS' 'HttpPlatformHandler' 'INFO' 'not installed yet' ` - 'The installer installs it from the bundle. No action needed.' - } -} - -Invoke-Check 'IIS' 'Locked config sections' { - # The authoritative source is applicationHost.config. `appcmd list config - # /section:X` prints the section CONTENTS, not its lock state, so grepping - # that output silently reports every section as unlocked. - $cfg = Join-Path $env:windir 'system32\inetsrv\config\applicationHost.config' - if (-not (Test-Path $cfg)) { - Add-Result 'IIS' 'Locked config sections' 'SKIP' 'applicationHost.config not found' - return - } - foreach ($name in @('handlers','httpPlatform')) { - $line = Select-String -Path $cfg -Pattern ('
$null - if ($modules -match 'RewriteModule') { - Add-Result 'IIS' 'URL Rewrite module' 'PASS' 'installed (the X-Forwarded-For rule can be enabled)' - } else { - # Not a FAIL: it is only needed for -ClientIpSource direct, and the - # installer carries the MSI and installs it itself. Worth reporting - # because without the rule IIS sends no X-Forwarded-For at all, so every - # client reads as 127.0.0.1 and the GE-Enforce IP allowlist, the - # visitor-location lookup and per-host login rate limiting go quiet. - Add-Result 'IIS' 'URL Rewrite module' 'INFO' 'not installed' ` - 'The installer installs it from the bundle when -ClientIpSource is direct. Behind a reverse proxy that already sets X-Forwarded-For, use -ClientIpSource proxy and leave it out.' - } -} - -Invoke-Check 'IIS' 'Existing sites' { - if (-not $script:IisPresent) { - Add-Result 'IIS' 'Existing sites' 'SKIP' 'IIS not installed' - return - } - try { - Import-Module WebAdministration -ErrorAction Stop - $sites = @(Get-Website) - if ($sites.Count -eq 0) { Add-Result 'IIS' 'Existing sites' 'INFO' 'none' ; return } - $desc = ($sites | ForEach-Object { - $b = ($_.bindings.Collection | ForEach-Object { $_.bindingInformation }) -join ',' - "$($_.Name) [$($_.State)] $b" - }) -join '; ' - Add-Result 'IIS' 'Existing sites' 'INFO' $desc - # Adoption sites typically run the classic ASP shopdb here already. - if ($desc -match '8080') { - Add-Result 'IIS' 'Classic ASP site' 'INFO' 'a site is bound on 8080 (likely the classic ASP shopdb)' ` - 'Install ShopDB as a separate site on its own port; do not disturb this one.' - } - } catch { - Add-Result 'IIS' 'Existing sites' 'WARN' "could not enumerate: $($_.Exception.Message)" '' - } -} - -# ============================================================================= -# 4. MySQL (detect BEFORE offering bundled vs existing) -# ============================================================================= - -Invoke-Check 'MySQL' 'Service' { - $svcs = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^MySQL' -or $_.DisplayName -match 'MySQL' }) - if ($svcs.Count -eq 0) { - Add-Result 'MySQL' 'Service' 'INFO' 'no MySQL service found' ` - 'Bundled MySQL 8.4 LTS is the appropriate choice on this box.' - return - } - foreach ($s in $svcs) { - Add-Result 'MySQL' 'Service' 'WARN' "$($s.Name) ($($s.DisplayName)) is $($s.Status)" ` - 'MySQL already present. Default to the EXISTING-server option; installing bundled MySQL will collide on port 3306.' - } -} - -Invoke-Check 'MySQL' 'Port 3306' { - if (Test-PortFree 3306) { - Add-Result 'MySQL' 'Port 3306' 'INFO' 'nothing listening on 3306' - } else { - Add-Result 'MySQL' 'Port 3306' 'WARN' 'something is listening on 3306' ` - 'Bundled MySQL cannot use the default port. Use the existing server, or pick another port.' - } -} - -Invoke-Check 'MySQL' 'Backup client' { - # mysqldump is what takes the mandatory pre-upgrade backup. Without it every - # upgrade skips the backup - and skips it AFTER the application pool has been - # stopped and the tree replaced, so the site is down and there is nothing to - # restore from. A site whose database is on another server typically has no - # MySQL client installed here at all, which is exactly the case that needs it. - $names = @('mysqldump.exe') - $found = '' - foreach ($root in @((Join-Path $PSScriptRoot 'mysqlclient'), - (Join-Path $AppRoot 'mysqlclient'), - 'C:\MySQL84', 'C:\Program Files\MySQL', 'C:\mysql56\bin', - 'C:\Program Files (x86)\MySQL')) { - if (-not (Test-Path $root)) { continue } - $hit = Get-ChildItem $root -Filter $names[0] -Recurse -ErrorAction SilentlyContinue | - Select-Object -First 1 - if ($hit) { $found = $hit.FullName; break } - } - if ($found) { - Add-Result 'MySQL' 'Backup client' 'PASS' "mysqldump found ($found)" - } else { - Add-Result 'MySQL' 'Backup client' 'WARN' 'mysqldump not found on this server' ` - 'Needed for the automatic pre-upgrade backup and for "shopdb-admin.ps1 backup". A first install works without it; upgrades will not be protected. It is not on this server yet - the installer stages its own copy, so this normally resolves itself during installation.' - } -} - -Invoke-Check 'MySQL' 'Version and config' { - # Find mysqld.exe via the service binary path; read the version and locate my.ini. - $svc = Get-CimInstance Win32_Service -ErrorAction SilentlyContinue | - Where-Object { $_.PathName -match 'mysqld' } | Select-Object -First 1 - if ($null -eq $svc) { return } - - $path = $svc.PathName - $exe = '' - if ($path -match '"([^"]+mysqld[^"]*)"') { $exe = $matches[1] } - elseif ($path -match '(\S+mysqld\S*)') { $exe = $matches[1] } - - $ver = '' - if ($exe -and (Test-Path $exe)) { - try { $ver = (& $exe --version 2>$null | Out-String).Trim() } catch { } - } - if ($ver) { Add-Result 'MySQL' 'Version' 'INFO' $ver } - - # my.ini path is passed as --defaults-file in the service command line. - $ini = '' - if ($path -match '--defaults-file="?([^"]+\.ini)"?') { $ini = $matches[1] } - if ($ini -and (Test-Path $ini)) { - Add-Result 'MySQL' 'Config file' 'INFO' $ini - - # MySQL 5.6 needs three flags or `flask db upgrade` dies with error 1071. - $is56 = ($ver -match '\b5\.6\.') - if ($is56) { - $content = Get-Content $ini -Raw - $need = @('innodb_file_per_table','innodb_file_format','innodb_large_prefix') - $missing = @() - foreach ($k in $need) { if ($content -notmatch $k) { $missing += $k } } - if ($missing.Count -eq 0) { - # Present in the FILE is not the same as ACTIVE. MySQL must be - # restarted for them to take effect, and the app's own - # `flask db-utils preflight` queries the live server - trust that. - Add-Result 'MySQL' '5.6 index flags' 'WARN' 'all three present in my.ini' ` - 'Present in the file only. They take effect after a MySQL RESTART, which interrupts the classic ASP app. Confirm with SHOW VARIABLES or flask db-utils preflight.' - } else { - # WARN, not FAIL. This inspects the LOCAL MySQL, which may not be the - # database the operator is about to install against - a bundled 8.4, - # or a remote server. Blocking the wizard here refused an install - # over a server that had nothing to do with it. Stage 3 runs - # 'flask db-utils preflight' against the database actually chosen, - # which is the check that can genuinely block. - Add-Result 'MySQL' '5.6 index flags' 'WARN' ("missing: " + ($missing -join ', ')) ` - "Add to [mysqld] in $ini and restart MySQL, or 'flask db upgrade' fails with error 1071. NOTE: restarting interrupts the classic ASP app." - } - } - } -} - -# ============================================================================= -# 5. Python (detect, but the installer uses its OWN bundled interpreter) -# ============================================================================= - -Invoke-Check 'Python' 'On PATH' { - $cmd = Get-Command python -ErrorAction SilentlyContinue - if ($null -eq $cmd) { - Add-Result 'Python' 'On PATH' 'INFO' 'no python on PATH' ` - 'Expected. The installer supplies its own interpreter.' - return - } - $v = '' - try { $v = (& $cmd.Source --version 2>&1 | Out-String).Trim() } catch { } - $detail = "$v at $($cmd.Source)" - - # A per-user install under %LOCALAPPDATA% is unreadable by the IIS app-pool - # identity. That produces a 500 with an empty HttpPlatform log. - if ($cmd.Source -like "$env:LOCALAPPDATA*") { - Add-Result 'Python' 'On PATH' 'WARN' "$detail (PER-USER install)" ` - 'The IIS app-pool identity cannot read %LOCALAPPDATA%. The installer must install Python for ALL USERS and use absolute paths.' - } elseif ($cmd.Source -like '*WindowsApps*') { - Add-Result 'Python' 'On PATH' 'WARN' "$detail (Microsoft Store)" ` - 'Store Python misbehaves under service identities. The installer will not use it.' - } else { - Add-Result 'Python' 'On PATH' 'INFO' $detail ` - 'Not used by the installer, but a manual `flask` command later would resolve to this interpreter.' - } -} - -Invoke-Check 'Python' 'Registered installs' { - $found = @() - foreach ($hive in @('HKLM:\SOFTWARE\Python\PythonCore','HKCU:\SOFTWARE\Python\PythonCore')) { - if (Test-Path $hive) { - foreach ($k in Get-ChildItem $hive -ErrorAction SilentlyContinue) { - $ip = Join-Path $k.PSPath 'InstallPath' - if (Test-Path $ip) { - $loc = (Get-ItemProperty $ip -ErrorAction SilentlyContinue).'(default)' - $scope = if ($hive -like 'HKLM*') { 'all-users' } else { 'per-user' } - $found += "$($k.PSChildName) ($scope) $loc" - } - } - } - } - if ($found.Count -eq 0) { Add-Result 'Python' 'Registered installs' 'INFO' 'none' } - else { Add-Result 'Python' 'Registered installs' 'INFO' ($found -join '; ') } -} - -# ============================================================================= -# Report -# ============================================================================= - -$fails = @($script:Results | Where-Object { $_.Status -eq 'FAIL' }) -$warns = @($script:Results | Where-Object { $_.Status -eq 'WARN' }) -$skips = @($script:Results | Where-Object { $_.Status -eq 'SKIP' }) - -if ($Delimited) { - # Data only. No padding, no colour, no alignment - the caller lays it out. - # Pipes are stripped from field values so the record can be split naively. - foreach ($r in $script:Results) { - $fix = '' - if ($r.Fix) { $fix = $r.Fix } - $fields = @($r.Status, $r.Area, $r.Check, $r.Detail, $fix) | ForEach-Object { - ([string]$_) -replace '\|', '/' -replace '\s*\r?\n\s*', ' ' - } - Write-Output ($fields -join '|') - } -} elseif ($Json) { - [PSCustomObject]@{ - Timestamp = (Get-Date).ToString('s') - Computer = $env:COMPUTERNAME - SitePort = $SitePort - AppRoot = $AppRoot - Failures = $fails.Count - Warnings = $warns.Count - Skipped = $skips.Count - Results = $script:Results - } | ConvertTo-Json -Depth 5 -} else { - Write-Host '' - Write-Host 'ShopDB-Flask preflight' -ForegroundColor Cyan - Write-Host (" host {0} site port {1} approot {2}" -f $env:COMPUTERNAME, $SitePort, $AppRoot) - Write-Host '' - $area = '' - foreach ($r in $script:Results) { - if ($r.Area -ne $area) { $area = $r.Area; Write-Host "[$area]" -ForegroundColor White } - $colour = 'Gray' - if ($r.Status -eq 'PASS') { $colour = 'Green' } - if ($r.Status -eq 'WARN') { $colour = 'Yellow' } - if ($r.Status -eq 'FAIL') { $colour = 'Red' } - if ($r.Status -eq 'SKIP') { $colour = 'DarkGray' } - Write-Host (" {0,-5} {1,-28} {2}" -f $r.Status, $r.Check, $r.Detail) -ForegroundColor $colour - if ($r.Fix -and $r.Status -ne 'PASS' -and $r.Status -ne 'INFO' -and $r.Status -ne 'SKIP') { - Write-Host (" -> {0}" -f $r.Fix) -ForegroundColor DarkGray - } - } - Write-Host '' - if ($fails.Count -eq 0) { - Write-Host "No blocking problems. $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Green - } else { - Write-Host "$($fails.Count) blocking problem(s), $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Red - } - if ($skips.Count -gt 0) { - Write-Host " Skipped checks were NOT verified. Re-run once their prerequisite is installed." -ForegroundColor DarkGray - } - Write-Host '' -} - -if ($fails.Count -gt 0) { exit 1 } else { exit 0 } diff --git a/deploy/windows/installer/shopdb.ico b/deploy/windows/installer/shopdb.ico deleted file mode 100644 index 82df68ac009028ea201167bb43fba957d5d0d059..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52695 zcmafZWl$Z#(&zyW4&mVL4#C~s-Q6uX1b5fq?iM7tyGw9)C%C)&$VE}-iumAuf`=-hLVZ01yBvQ6W|LEZand@Jut%U~alw*V_uPiL7?s+%{EDr!-kT zm|h*ET38^{DOIUCQ+~IT|2S)XnWI+52P4Ldrh^8rTxe}ZE9Pp6D{G>XxWW2LIYP1Z z*m)ThHsSd*rvDbf>FFH?@ZfWI9)>2D#c@B!Xa)%WB!s9%k=dB7yp68jY!_9@)#W

@i31Q#w^YHUVW%~_neXD#L->POKm1o59Cg0|0sw`ZppwX_I5`XwMr39C-R2)z zY&F{j09CeJ|Fmqdt6oLA!#CP6SFfR-_aVM}ArCXsln2l6F|YuxTUbF=bb$DH;U?)l z0Rx|oJU^eu@IkCvJ;y~;BZq9QpBr*FeP!sS-D2WjZH~kdm}S`U@nu#;-sOQ+Y50Ar z2g;ZvM!$-TPA5OHFdZG&pLU~k^lbO<8o3etUXOs$nPE2_x`80MG^iclN7&F#f}hyE zIm*4=Y#ek;7zD+bT`kEU&l()M&-(%0uI0a7pmk zENSc-uddEbBwjRPdUh50A5-S;zJ3wEo?({!y#5HGw?}H7#wr`%8-fR5WBnzNFwfml z+Ub?$~i7g zX}fd&;u}*u>bkYv4>M*Y^PLGH=^ZR6f2P5h@T-1dJ-hGx()wPGktH-@-1mdMM|YW1 zQu4v_oxXw?DcajJRtDlyfLKctK(YBS%PG)_yRO4@F7-Sc9$yU~&aFjEK6%gbULD>}g_* zXPigCeFsXFB~8JQY}d*HT8EL5!_V-l_4vG2X1B2+765R7@xc*%_yvTf-y~F6f6R}s z##a~WQ$HcAw4zsS5I+5j4(L~CS#S8RN=A=2M0#N6ciyLszFBl6$ue+@Uf-Y2XjA? zQ7bKzE^lJ6oySNI?2`!`$OVTjRZ0vkWF4qQC2~ExH$UIFujAoyI^3KK%lOv_mP|Ua z`_^?SY0+g5^BRVh<27By#n_P9F-{!bj<}-@M(mwW40eqdzJ;Rlx$+bbtzi5aM*=v) ze_I1{U)f@b`^gS2+J*HR3KgIR_buGeN}%Os=#n5mr}xm!>Onq0&=?h-3=ct`(!-9cgkopOK^0(cK#TzHcFd-KFZ>RlTm^xz;h^Gxj`w6lG9`4d)HH zR7e8ciRuEQ1P>4-gZT~Y%+_kQaMCb&ZNt0lDx%e#OWzOb=1lfB5uvnuzxk;6Fj$p*{#(G0&G(^EW)b$PFVo~;O~8YRAe*W^=+Wj1iruxWy81F) z7LB+{Lx~RKi%o@G9hNOr!f;>1Z+%Biw&6J z+6%q!dp4{Tv^Iv{_5`@-@YG_>`$6*EVgGAGW6Zbr3%XseaisMweMo9@dEg-frXKtG z_IOd?E29AAtLt4pd8J+}4jt?;E;tGJ-(X>C)ypmclW3Q_Ugrh*iFwm04UOyT?Z^Fq zwU>qD5w1OLW$&d2eH!(X`Gkk7>KSn6fa)Q5wSCLMJ~j*(K$rS(>IpBizA{4&)~^?h z#!{Wdrny+(XSdtAFz6QARHZOzw?BR{vSao!Ic{5P?EyTiv)()FY?)HjUCe6RJt}go zd6-qCAV$8E6hl(SxY))45r6}Nl8PevB;$M0gyHa7kq*(C-J z{uHTH!FsgMLRgIBh|=xq+Gnf1R>fX5v3=(?$LZEeHO1%BJvrq|R-Kz2{&{_o^1F?^P~lj`gMa`oZ8O?=?^FZ#$WpCL+E4mw>`2@CBQa~J$?;9qG;mSn{Vb=R~w9Eu}x3c=lI%4k{L!F`B;#SGp*Q>#KG?4 zc{~>!{8%tR4JZXw&cMe%zAR8UI+VjHT^$ymgE5|eQ;ynL;lzUV~%J;I$lJ?{Vj7^VLm;f)&BQECh5AB&r>SuBrC4CWwn ztR8G}(;k$3pp|8#CVI$^qDY_c1^EIB9BY$A3fm0zHHwGS(nhO{rpUo2Z zkLMl?EU^53YkdccgURey&cm~zlUR7FY@?c)Dg#O$ugXiE*v>^QAV9NAdM6$CtPnTX zR$0gavtJMKP#a7g0Fx_^MFc_ofI6U!Du(3pe%$t(lIeI)VlJ{ zHicR*-*9SNIO$iGHX6*1O^yXT6NYRx%Wd(}kpJ$es9$vlU;5BiIT}Z_9mZHJ+5327W&%}EyL!fVndDR?2GOJb%u)ms^iK>0BJ@jox zBa0;4m)!7}oQk7S$IY&)c0C?fV@U0U+npBH7r7beqFVnnN)w)r5#ixbtxY0j zJEZ)p2&r)d0;mukNbx2ER(b;zb1c3yZ+Q3^^JJmP7*X;IiXTt6`h@7|BS6(!>?SQHZpIHDPb7T z5p6sM)HZ3hcJ(*0s{d}S&A>fX2?X{snf%>9^cv(I&t&RFlg1H-i32ezg$p+kI+ns1 z$sypU^akAMqy|o}s_uSsHoGr!D&{oL!?NzzCd)klzow52du)y=?JmVUpMw5on1M2<` zx2%@=hx<1jcslkM%~|fI1gT~t{HdZLrvFieI%UgkxV1G`erlayeE5M7Lx9^P43dsN z@R5MJZ$&K=E$DMfmWr)>Kaxvfk~juiisqi*l`AX&erq4NMgiVvE{rQPG$<=&> z+Rwk}EI-~CwtworPf6lvKDNf2!6JXw!+w`s{9_?AT+H68I zR&--C8e%b<3HH!ar~XyHKY8eAY4vt%wr*5?i3G?q!_0+-sap(?i4Opgw!eqVQ;`#E zM3tghIkqqyR%tEoK=j1;42=g(bxdXYwy$#xIx6_}%HtSB2uq1bfo-9-c#O!V%ThJl z$xv2h`O(+4h9{!K6)lJBDyf*w;>L$6spLhAXi!wQ#T?qYQC#A&-&>WY;Yo8oh#M|7j9Ezb{z2ya8w1n-7i&vv9Yc-6r`^LM)he6!TdFQ6Y%*-_ zVuuHSp~J)9mEQxkk#%u+Bm3QTqbbOf2s7hCcJpGbjpJZ53!XrK0;{RaXr5TZXxr-T_KtM8DG#F?0Dg0m(PA5VC4V(mm z>{@AgE5;yfNlM=NLLXokcy=0SjdDN;4&b0VD^({9DtjULzWL1 zI{%w$cZvLEl+8KTTNjVtn4w$}rDH_wV*Xc`rtx zGqa@EA+J8o_@IAOG8TnFv9J4B@qHp`VQI#P3YHiDAD+m6@;Z$F^+YtlpThtEB#r-h zA`{vkS!fH5ACsNUWo=&OSXhQ>MoxmR$UP9n%{E^Dbpc^cogJYrvI7(7Oq_pmIQPU_Av%f%K^P<PD581F}acmoOy0-aHV1fdDS#J$U5Q=Rm{=pbjMAM8!AGPOm#`Monon zEKZ?2{%92Yz5VSm?2_5u~(wB z{s{gwt=15=L*ohZZsHo-;WcqT*$b4W8U>RA~BTo8=T%knm zl8jN_4cX+$)KK=nZzi#YM~ghhQ1g^Y=s5lKKu$%26o-Kk0#@P^T1D0dNkK`g9r$fAxCd1aFIm4pV~#kCHBEd34G3_|a`KqO4y_YYojBDi2sB zj_`e-+MpsFhb6;&axHGzi&K?6gdmE~mprCjlUnLeRqPevp)+EMPiu8fFz-eW#b-tm%N4Az8mwXS-mYV|95*Iid`cry(5zSlf8 zvf1pA_}Rzv8aTW2uO*qY3|utH$@#z=1cz5Kwu75`H=@g3!D8_GYw68C`gBT#;rJXZ z4xvXAS}L&JMRq5!x9Iv)mt!DjvwT1zril#DvRXV`;r*G{>b7m}_{>+1>SQm#K|?Fm zN||D(;A)i`+(e(Mpq=BBMD*NFFDmSx9TY*TiQjL9NDUK)TlaL5_OY$@+jDj7;w|wr zEPZz!QiL0Nalh^xY+yYCHS91X1_UT3evCUNzmSu8A|veMDgSxOf)0};dO8rOAzVGrJ}H5UOIXG?A@2ox(3u07_U8!#?=P5X z*a_i8zyJV-=-cg9b@YX|adgLPsFVAJOy+)5=RJi5urI9LY8SpskGhJGXBxj^BC^C_ z^(_47sr4@7^;?&3><#wOH(X_76+>-_iCrp0E&Y(9JQls@@~^Oo6> zmoh9mCXlyA!Yd?;p_P#LfrcJ;2L6-H7j(%rzbB4(EKV083>RopoX+u0ABVp7q8<|IS@kk>|9F(}3FV z*S7kXRO-}NBx0Cx!KGP}h5CxLZ}aSPHDG4NT=O75kqSu|O?*-ngUtS?h?Em$aZ=WHMGm!HTYm!NiwW3|OyE1f=uW@}KKHx{lKfHH)s9>}s zCG~NhGMuPOuRawruH5uAA(k%JrV8tC8-eI+1I2ocy?0U~8}D0q0nmrthkOF3(smk2 zc{&!=aF1u!drk_c#umC>P0Jc{2sOK;(WlhC!f`zIikM0+oD&<;Yc+8NYB|oTka!Ry zf~~>TocLDhSB2}f)d5bunZIu3$*Jx4gHQtr_1xs~{thm%ieq{A>#o%5MZzVHK4jh% zR&pl|o~wkKrF6B*xMW^&x^?a_-M(dHV*{os4kNpDTj;qzGj9t*50=S%N>RERz9w0u znpL8$b&3+r3wDu5R?^(eCrWE~PHc0To&M8cOY>v<0XTfDfhd=hOty>vihC|*;mqNb zVnfq%Kq+(XqcUaE@6bG{9sl&VS^Q*1qUwTtVKT94M z8SUsxA_1k~4Sn;^utLO9hzKfsy;s$?GQN32|2*zej>KSQ+f7R!8lvv2pG0^qgeh;D%n>Ji~J^bQ*&||*UUZURam3Sd7 zrZQAC!{Pw#HreUBEtpC-n>s@^X2@a*3f&Z})u{S9N^;odtsyn!Bpy zMr%!~3`<_Zcg38|{|DZHsi66BJ|X(05LV$z!-C$WlY(!=$zUB#D&9JHbPW@zGI+=F zdddu_r!1N_vcdDw_HvVj1I0x|F+wFpH(pFrJS}9=UFgdkm>+{OI$=-`FOG`g zOE16kX=}XiTRsU)vY6GP>l=o1bbhOB0f9|&pHczz)&Gy8xwb5*7oZ-frSyVND{D07J*6e2*BA#x}W1E|1Aw*C=(f&D@{2}!D@VOp- zq<7ZiMk53KOW)V|4rn_XzW@+Ht2^gmZf(+mO{;8mOa94f)RPl|vA{#8=)}-Ctn@nN zvRW(^>f~-?!+oF?P^^5Is4P?xH|p!h#eFQlNdUK5)0u*{W+@K&jb}IB#@NJJRKN#V zZqzC3KmKf&_PIP^u`NjehZK~}^=?~hdiL*l9pM;1nrm0tj~((%S6s7vWWcnzV{n4- za{a+nr+0He&y{CDO`bHslZo`FuW;!EEofE$=1QqLE>Lj|YbR7j0oKdK)>@#<3KT2f zO_X0rD%6+bHwNBmrjU&_+dfb4V-l9(?o0T37duEpqHZV%^HbnsB6B7?wxjIj{^TFc zXynG4&bP>&!Z1w27I41NUl0Jz_I*R=g9QZ-g>^pmcH<2KF1M8wQK`(l1^revNPj#^ zX$up5RwrH0gUijNIm%q>LBvaJtkby36LNjk6;cS2a&Nugg`)i6AW*}7Q+^b z{%5_}&%YJ4{kVQzytS2On=+6mX`zNgtnfd_sboDg+Z$KC-agsOhU~kieUBGIc4iLw zVZhQb6jwR1P7N!AoVdC~fs~N7P6#IW*kfqc@V0p13OmX7YBNihe(4{aPP%V``(B%& z*=(D&PalrdfW0!uuqLUuhY%#xFFQkwG4WQ{wHvRoht0unn`>~Zw44=fq5xY`Xh;qj zOw??*mhZ6F0Qu5huQ3GTiUomR@xdy(;Rj-kk52+M)0Z$ zP;gb%M%I4AmsWOvTDc#AU`xLWd1XNIV?VU@Tc?}yBsa5N51(rjhww^*A(!%QsgE&m zHz~>GPr`ttMu+tm?V|L35Arczd~>6NcpLDGt$m#G8uGhH#uo~6E!_o2!ZSg5tE8fK zXkii+(~5t-M(pqJD-5H=u)j0#wMPo4A_eAC6`_3T$#E~GTaC&f0t~Yj-R6q+`R0b_ zv=lf!e|w-5Oxx)QN8ko@rm9rtfMd`U?NvD{E}gV6mR4asQ!L)PoC3}U<%Ozp5A^Bm zvQtgEb*6BnF=;Xy+F%N5K%E{BMuE-{iL?ZhTGjeRrKf$YMjYG`Ar4(rr@Jc<yuIw6213))C_h1KX#)2XqSob15Gnb<{Fdp@@>-%8PV+shAief#bTBpL>OH;PPNf!&BoQ19lg_`cg6h@*W%Q?pG%a za}i%@#>~+dGnFNH#zf~2+lqc|Nriyt+?%R11Cnu!S(Puu@!MLDEI2A=_$oQaxmh_S zI9kHpUiZE{<5&0$q%tk1T%>QcWT2o2fx}a1M`h*Xq{!-A$eQy8eTz5vbHkzK%`5l8 zJJ9G3d=7~e3de(q+!r|2c5PHSa4I<>&_Ahy&8s*bL-FyUX~R}I3S~t4VMUeEM8j?L4gAl zAa#~sT!;+oPi_Lu&wj8lhd~0jnq&V*JTCY}7yNHLUS{dI2>@U&|1Tcj_p(S+6Ki^C zFSEMz5k90aK^03fB?|ZFrqM?ODfZwwjH4oGWFxn-g#Tvwjf%I+_6rtY2pOcL$Us5_ z4h00_NC~6ja6iI&U6yHY#X4ma?n2sR@!VDlyx(!Dxt!>!HTLP0!)#H@V)vZ5kAFKGVlfuaP<%z&4R4}msDNzn2lcmqn|0n_dH1nSf?41m$MqbIl>i)gcZ zw;3$TA^clf^ico`fUdv!|Xjg z_7XaiYF+oK6aqSfl-)P zNDUjyjo812nG@=;t+xPo)omxAg3EC?WVkBw>^^73uC!xJU?8lGWJaSvHisNc`9kb4 z%lAuY0)XVtw8g#%Ru+expPn=KeErk zWfJpGH+*(A{^;IYLMj06V>@cOk6bzfwY%9CKXMj#!;o(C1+ogr>PEoleTKi?lpsc^)wO=6Docw2ST&Q|Ha4nlGW z?~@Fn61tl97cA}17r8$?t+VSpY3RO->u9oqQnoj-wLEXqU@Kx3uS0b>5G_7o+Kr=%LmyGsiI+%~!^&FvU(qB(=)wBbo zEHc?c`L@zF`mfY&aXT{`w$S^0q1c&w9!glTrG7D1b?Nkp$@b>09Lg0T z?7`C3z@SCz?c++A~m7oZmjtcY00J(eTlD56aCP`{7r0w zvBN$B{2x*K+@?VjDc{W|Qt~cUG91EA4r8{?e)`T^IBxJ@b=9Msme}Zq)?vBn>c6M2 z!iYf?f3Lk7+Fvt2t)q$!bwcV_3zH;>&qFbUn19`B(2@UV104ny8P>hNcZp9zHD`_GoZ&Ht`Q+#Y!VPvYeR<$;gD?equk>AdR*5PhrI`-v)!oE2m9#f z$b>y-Zfc6e%?^E3?NPw~O+lX!43Rn~l;`<1;wRu-)=5e5Y|jd9pJ z_dP2-50Uhu)ZrOEwaCu%M-|KQ#QtU$C4=m@vlR+TR*aZ+j*4Ym(;(BN+DnU-+-LPI zgufzG8_LONUJGO%=3A~0@jCMG?Ya-%^Gjz`Lg|vsJTS;Y-1O`2cy?q>ueY6;8qmbS zGri3k8(3szG;tMGf!~4<9Ee>BA&DVRP`Z5V*7NQ%2`dv5^Ix|R-`VWH|g~oNgW?=bA#tFD-^(M$Y7K~!nTg{$RV|Vr}V5S zyPL&jvzz7lcw80Rs^}+#&6_?phXljG5*E}XRn`2XpVeXPoB?Lwgm}Lo3_(&y(-L)M zD2rAgA0E!6Kt;XO!Qr{soAw5}9VIvU*oLEJGCVI!g}(?hpa*t;yCKMkRRZzOTE{GjDj9gBR_i0MH9{S|q5pkA!Ln!YOoPjz@8{j5?`>a;wZ{}YW*`;-6}joG zLIJVewrs6RtZMoCz{T}QfjhmZe2g4G3KYVHCx*GC-%P@LTzWJSz~9_RQfOx%pNgFh z+&2SXMt$W}7R3Fv4Z$rA^=NHx=j&eObh+>@JSy(IMJ@`yXztqh%_lCdhFcWt31oxN z=L}~u7fbwqDv?|%^|#c)&x4p`OL_(cLCAro;P=Tq zM%~c7aj-7Yi{AVHs$Pn?3`$k?s8*ZCkvs`Egi*kVYKZ!pB7^-dSqRIs(#9Gih|-w7 zlD$TztBk@@o=*-W_z^~%mr4!vmd6SqY`^PJkR+W~zzb-p7grI?h+O`WKaOwiKF;x0 z4s-a?9TQ_F7|F+@3>Ro7{x$l-k}_$Tve=KhnybX?kI2L;?&^E8VWpVQzC3UQ7!CuT z#3ag&(2eQ6==a;@mUpAIFmCI^J#m;eyfDU2XIM#rD?ND%p{9~V2q5|xBllyb%+>}4 z%&tAJB%Rr<^79`me8FxNZrq!`DXQayI}`jsJJA7i2WP-a%cAwkIoR1AQa2! zE|W2=R}NE;MyEY1=#A4w?#w{F)^syMENW-X z)QeKgW;AP191WOgpO|X*>pwm%*Fp*6TRQVQT7~e6Fu>1dHRMMg%))ZvHIY!sJBiUD=Ad(q$IM1s&5-Q|ZfH zW^vkMimwWi|9Kbtru_TS>5ImuPBy?W*3uvt{nXX*Fo7g-;}>+)iGL!oIh0huTZ@d2 z8~J*Q-0n=B9xU7ESY-`{HmDVG$UofhyGlty;BpX0&IyBFBcI1yKjGko`_KH5U+rj9 z*TYB+Se`?yd`=Hmhli<;Ox;~A8aGW6hg>cZ0GXvTB8n!L2>+#IwY7lGRa0S8>o1Gb zVP^K>;U*hP#F%vFatCS1zE#J{(CQzG6 z#&vB)lKp7-ScLcF9%LRldrk;6tl0lk(t$*PgFUHV$EB8aNPLd_87a_FPLSI~@>8}Q z{PXuA1B>1&#>%EfH{Mzutx)v_A1hmhpO*xjva}Q+oHclyJ@W~D&pj#gu$OT}qQrMQ z$Ll7((bD#3GFnxFFrI9J_cSk20O$z^UuS>pt;KqmDc1M#jWQnnly;UG_xXGwg&8%5 z)8gK8x1-S^sN>149QB0buef$ zajyGL!&?cXb3b9T6qTFpSCJ3?=JVcL+ahW1!Plj}G1T#=gig~y0obJFHjHpx{>#%# zZw(Xr-ja^iDxnpUD+i$gv7z?C@ToIM5fBh9Cg;;bK5_Gge(HA9*zYMyvB^UAKFTK& z^!0s3)4{0!UVOKIe$k82U+OTv^t}5$+;~V(&hhqM;yqeh2?;%jb|6j;;PKgpqAsYy zU#t#l7{6=B#C-L0_ZE`RrKcNbnB-a9Qp#36eD~De+f80eK+O7W7K)}=LZDIb-UD2t zO*lRfyRNBYmt#!%(BNoE#RHNi!8$M6R4IOmZE~~DhT-30teCn?KyH?RLk)AAYF|m{ zeTu6;ENjolOT$)Z|H=u-sCP~z9Er`7tJj6^u~}D#U|3?a)!x$wd_N1xS?@QEGGJ(&#>3&9_@-P6OQnF$s#3(sDQavr#Q)JGH-9rh zP81pSRF&$mp8xUO@jXfq|X)CU1Q z6?&dV`kDzilbG5RI*4)ERa4ihL+PptxzhVJE5q-}qXDn?JSdre^4VCLDv5LFNq4;u9-x=ugFRvYaITVt1%Hb`v(e)l0!Z7oYv6hDcH*8vi6 zg%?qW-&p1>!xow|pOc%_6c}L`Uo@FS27uYpZ%}IZp@XOIu&Sj0ht3!o8sqBoZd7^y z!2RKbSdXO?mFR2dS!?QedLBqq1a>`S5pcolcL{%<6zC%h1utZl^;L7Ii^kZgq{WEg zO=kHCgK#=eGU1eT5@QhgOZog7YXh5-mt>~mp(MQFqnCN%TyupBGW8{LDJ zwc}w#*~hFvCUi+e?p3TYDC)SUpJ&yCM+yQPc&)yB*CTfL8KSbG2LZ6DQ_V?@|HgGxh8fTBi7maCQ3Y|o7pE6S9^m0wew z+5$XIManTGUUAJ7&?SJe;VDNZlMn7CmqNaJ4W6$3Z6rjqIu$Gvupa#9M0wY-P(7<7 z{5i){(%S^SYF})Hjs`N<#h%|NejQca)?A5=b!KRX5+q;72kqd{^J5wM@F2(iz5F00 zOh8uG>CsomnP1Dl^5qssK&g+4&B(C2UnVNqPX{o7UT%K}cCu9T! ztdmnE!+va#L2DqMAH=pdG#_%$?tiyj$x|hUDQ8!sxb)aY<*^tsx3_Sa<&FXIakH~E z8!W3BIJ_t+nM+O%f_=F`8}E^rVrsV1zV|n215<=gJ=u*vR80M(&uXXu!%r`fqlG?? z0k2BB5f2U5O}u4V2*|JB_I&QizTLLoLpnl}UZfE)&_}AcLKFBxnpUuo z9+WxUkENFQzJ#q@^f8Rp+_jFKA zBClz1mhich$%)nHFOYE9LZl4asCFmDNkc&Pq0%@CQ$_7TXpzpVqd!M@zjuDTL=8Q9 zsJszt{zXytD6pnmUC1bHUR4iayR4bniN6{&Y0l+M4Vi=%M*@+PZe&YwX4?V}Q~z&_ z24y6mJMHYFP(kPR$rj?irj})Wwa^t$Doj_vVOR`#kom@m_q%2n+s|#i`hl=m{eO>V ztAWt^eAGYt4frK1H%~}^%rr1Ya`5Zo;y)Wu z@a=YO_qLSqr}%k!8lC-JDIdQA!=ELE!W;2vZ&0kvJdh@qG;f7_mGjWMTC>1dm2z4X z?O#or9(>`5;hltS3;}^l&)PLTjU11?<6h+c_GLeDe+@f@2KEn5IvjjoL9^Aydf!g{ z9cB#ifJqKXV>NfQ+Qs?)vLVth$7AKVVX*ngUP0}K?9}3sh__KCX183yc+eID=cZ1X z&9HdU(C89yDvlPY`e&=dK8=7s&xDMJ3}X5#EW!G+df=OF_4@oQu5riHWj#?XPHj4E zMKsD;m;wC2-_6~uk>LJOC@0FqK+vX_t$^QrY4$lTVI}(K`vJ4t#0wk;<&sQ;6^a5& zKSa2b2@OW9XK6w)m-qYC1!t>-bu<0(+zn!~=V0JHugm7rADNK5`t1!Sa|gHgo3rWx z``mXaK5;&_&RB(J&m5Lu{Cfs{REANIsL z>9^+6_wbIXWkH>&oW?I|P3_26gT?gu#>i9!y-ws7h+# zZ^D_Ixk`J8;$Z+_Yh_X~$GXaYVri)JFvOSy2poZelEo11jU?FA8@je%jxM(jEebe7 zJgO72BY9L~v;jj}e%eXtzcUGpxSN;SZ|B?Yh;{b)g&~AkK%*N1)h$G3J6a7|F;^tj+fplK?!WJ%33BQs@Lw1hzU)V~Z}#!*HdSxkb) zBgj1Ktj9wIK+4tte(kL_=)Dh#9KvwCd@GeC#x;Prv%m2TXDbaiKA^#UIV7{#YQgW+ z0+_O-_)a$X7>kKm zJqRDh?+YgRkyS|``1M;K3V1*_IUlx)u%exZ@me2A+I*34nGM6 z)jG5VYucy(kAi^z7v2BA1%ZBkqp#|LVAKB!0_S>eX{rk=pPh&6%s%E8O*1^|-!*Fr zexRYB_5LU*wTha#v*L#sNJ~qzN>EUdlP#zntO|$IRQj$d2w9k_=W0&t)$V{0O9w&8A~|mxKgC2p-%v4`nXHwVvt9#!+bEOPe1# zJm?_?y4#<=3xJ6vTEahMO9E+;~0B zV_0{JVjRCdQ{|r$Rk938^as!);k5udUyD8r0v5h9n(DM*9y#&>$eDHzD)OVoM`RCtG7?1$2j8T><3O;+o8EQx9&WN=&q2eXD&cxE&&jCOtyu!xxLTM`(kqcyk~$nEK9weBe`V!$bZj3aeW&o>L1#S z34uQa-;^1XGi3SvozIOyUno>te;Ma$lTfzr1HR{VjptTSEkq<(roEZ!YBQU+yy9kK z2yWxavphhU9>Zyz0rdY58+}UOUi-6qp4uS)bRTT_j8VER7Vl%H5RG1_c<`$Hw)*Mscr7<)6D{g3C^Go~8yeq`Vf0 z1XTnI2JaXdknuw@KJa`hgkt4b&jOf8Idjwmc~qJSjX=P1f2`hX!1a}GA_V~JZbc_H z-uw=1pC_`1=kX(!5v#BeJ7KgMul?k*WC{&{$`nIt$&|LGrZysfCTO@wMn^H|2tYk| zd5HQUuZ{t4d1r!uCBJm}`e7zBi1gH>7hmCHHp zY~+AexnarOryl^>8eNhW|L z=3!M0?RnDcq->iS7h}!hoDZwv@g@Vt2vGccos9V-7m&s@ z7ZoZS@lrhPWsBmkR0`SqYs{%t0@GE@qH|fx$3-o_wB#(Ecr3}NzRQ-Xlk@}#{u)!< zoBMwG?eR_-lms;@50L5Guy8MSc*%$OTlfx*_0GIa1wic4O{~Pw*N=BI(9cnz(^NY< zrf(@zye4@r%XQ#2qf*=2FCv0lVU`S?Fs3nA(@7+5ef?+ZIX-+XGFc z;i4wvHlg#|&c_(NyMs4X5yzF)RPZ7%sz!v#9|W8TR-TX`u}PwgIoIHG+i~#d`Ex)9 za1Xd4^k{9r>v6K)SNT#QO zLbVk{zFlmbg0+fq&h;bJ++?TgePP>+ol;gr8gMtzSJh`g;I22}?WtX#fi%a~1i);l*)bjuMdaI~5o9_)YxEFV)xD*Le zC=SJ;I24ECT8g{77I!Ny!QCarOL2EA?gR*O^8Nnj;#{4za*;LfWM;D8Ju`bh`|>0IA689d=;&XzU(CRR?oRz}hCH?x+cwAf@A$rk->TI**8%48e49I; z{$fxb?6{RE^z4Ooi)V)uhz7Bh(z1?564qn(_sodo%^b}eYPneGG*4b-+JdmhasI%X zD#2iiD~<%}T0LtyCeMLOL9JQf73PwCs`~`FO*=EZhNCQzZQCYRSO=ezS8g=|l ztJgOO6h54=6Z=2Z60f7=>8GRXE+y&4k!DTgesVVde%7oCTQpD=@`KsJzE6L$?*Ut4PPUJ*7fzWXcx2$LNI)5BtSGq*sm;u%}9@g{?RNmu^ltPl@Ga zYDOj#K&MX&#`(&yg9(a(ciOxT{;ErkB;x2hSyL%dWFi8-5LFRNYmJ+M)W`FwXWI|j zu8UbnxX8R;MS8op0=Mec^=AOFLfbZW8oC?BHjOR`JxLCB(Q=4nY*fUn05y&SIC`FdaXsHU6aALVtRjM(>~er5}@SM+>G&pBII9dm1X2vLba$T;j>} zUXJBE>EWckFWMzU?02#Y>cQ5QJR3P;F2pq-Qs4Mp>N}3E=C{q}69mBQniO&6%3-~} z7C8Y!87&9tX@?CO{!baqLLH<0Eoo>i=afDAx1+l1!ie#p8 zH4^rIzU*Y`QyWivP0vr!vyY>uSAj(LxB8dudVIx)c+GkLnn-n(3GBELQw~WYOQx9u zaP&gJoo!4&DLYFRfd}0FB)5{CDMv5x+XX&b){i|`&|7|G)Fra6LdUZZaly##2o4gkRal=-* z0qkSPg*s<~1OW}F@Av+gXYYKkqxgA{h+?t!kYD?FH5gR#G`>lZEf&44OZ_!ai;L>Qehaxw-kp zgGkPjElIGJ*z>7`VOc!&^7G1A-f5Y?Yp)H7%n}A~coW#i5qcFhlNebE5r4))qInMQ z`ny0L@6=1(d*!5oQ>5}N4qO5LycN;OA$WO4^Qq=sTnh}1&3pU>1t;-6=D9GQFFe-( zAt$UUA{^jSjQS|+K_7F=x{V>ISMuRXZo|>@ zH*E7g$g{Nc7VnU|uf8~K8->#_*O-#+& z)NIo2Up!Mr0*v8~LcczpcqDL#0{mV)T*feT_Odn95SGo90}k<_`@?4o8^tM>EUkv0 zYz#N&$@1MQ<7@3ewSYGDzx=X$8E`C!xLXTdG|VZ6$W2TeLDuT1){`dTFI6$|BRQYE z66|phy|<%%4_0-iSoFei7X2@DaqBGvmuw|KoXb^|x^}S9l;658I@HuO{mkG?IT=vR z!vHzy>p#PCm(*&hO+w~s1Je2EB~c0394~J$;;S)j<64cyZsz|4to3pw%geNBVpuQs zD}yg`{KQj8ljQ6h+iWNAR5?_kkO>{uH}5Szr=t~L{1EP9W4gCNmv;@=n?B;l9QRv{ z)8DBj5buV@Pp*4=9eNbmpJMPVXGh0s0zLo71KstZ9>Zv$y{n~cKm zgK}#F9tcuiADG<+-hR-hhTBB&ztPfZ-h6$CzsX~_q(vo^HC+SM7`pfiFtm#ryWM|+ zcz@6gE_T5C!65JeDZy{d|7e@eU&5zI+hS91Bvs$}?>5J8+3z^Wi%lcVBCDE@U8)a@ zByDd7{|$;3P>ai9Q$qR!sK~Vlmc;2Af8}n44lhnB;zmU#Y6|3qikIl#fS$)5&Yg1G z1F|jC^mocPtj39Q^AM(bgBI@FlwW2oUo(4s6@CxWx~w5B>KJ|fIUec|1hHJ?o=`>o zscG35u5dx5J_!-8TR`pNn7C$;|~?VdZG5i|&w*AollCqht3Ss+TDQ0T@zK0+rz6-pMx6;! zj^#TeuEN{)3BK=6pJ97XR?dAZ6I*zwANVoim_~KO=WM*j?wG8y{glXt%=3e;9V#At zk4DJ5U}YoSNsAMi-#(YULE+mvci#v42hmY3ra51plR#^|mR(6opB?Iu+d*YLo@04= zIFZ&1U4l=*MXdI?xc2Z)X@y_>`!k()c2&`nQ5i_`9Zwh3d8!$Z!$!KzkKgP+7`ulg zI+!`=RG{~5<^)e0Y|5N_`+l9W2neWb=fejoYGLoXJ6(7mC**5>dEUmCldfR}s#ZQ7 zjQvqBylUq-Hl!6-u%i%gw4N2j_m~V3e`|Y79SXKse&$BOl&5aqERbwJaB7xl6TF>q zbx|q^F+orU>%Z0YC;CuFVb6tp2oJq^X~a$hcs@0|q}JjP1utumIVexB*zC%N9kV(4 zixdd~<*$A-Yg(E~6#M(XbPb$7+RZ3Clc!c*yuLt(Q<*4*n@GjYzuT?N=+&!}Dc7qa z2Ck*x*7E71OU_>@ZuUH>eIi2psx>@30yny5C?+y(|3wyWI#D7xGR1PKw7`5Q8sItn z+t&7$8o*T+@QTw}!=lM5DD-35?TZ)SUy{o^K*l_(w9e~crl$}ot_YYc;AoG#T^ zW19%BW7lsF`kZp&rXM~+No|)YerzU_5Rqo#c9X9jrq4zQ8WE?UmG? z&bX6YSn?SV1=*vZGMyo*v>?x+YcYOzlfJGan#vR!KtD{c6$^se!z`$A13u<-JDJXKM}Zp!C{ifjI?wMI~lhVW1AT9+bZ4fnt#@ zXipL3y=Ywc$@xdv|BzJhR3Qw4hI79$qJJuZ@3JMG_nm?+Fv=mi3&(B0;FripU?$W| z=_o?}7*fTBb68JaBE+SYa#0JF_I?J$Dz~L~{Jb~JuS%=!*#%28QwrRZug~O0u3!S% zi!^#H?su%|EH&y+%Q^FSOOv9*%;FLjd))5cBjOcS?(UBGA*ab;S}XAHAUfGXXF7V` zYL3~{jXnMR=hiSykHzOooI4y6L&zy?QHx5ZC8v);E1pGK zkwFQatM3$oy+_y(N7L75Y-D4jF&$&l4U7f$?)(6_m5)@O^E^2n?bp8>?R|0W;`C_W zda9Q(i;QZ9cp+C6>*8Uzo8kD82qgOYq7|%`FCM9v6sbBmY&+F3{N{USJO$_p}%VO#2Vi8}q z{jA#lXy~S+JqQ)GQ9XfBGZ)q8aNEf^Ob)>f}2&i-)dwwseacGUF z-2Mf!G3VEka*Te*iObl|Yj6r3)aEMYFhXjdc`9(CR^P9W{MMGpBO#?5@jX=Xg19Wr zZyYZ|ntV#^%AM|$C6(;RlO0v3qW7?G*Dd7Bh5jlo_sQq*a3%*RMJ9U;#C2{mz zj3Qj*bcp(AyglG?I30}%PP*gswlla^nRK<3w|awp*C)t2B_WnypCRHaZ_Up|e zSD?wW(F=ahVYkyppJjd_rxS=2H4O8j&u|r%sxUo$pEr`*+a!5DvkHuc84`-RTH0I! z#X;2rVzQj(=LD1+*LdYQWM({0RL%*ePg(0sNTT=YuoG1?-vY5_M+og~pEF(!|J(b` zq~A1=|Guufr0ds5eg@kHsv+Pn8~Z`*>u$%Xm$rT9 zM(UN%4Zy`=4wD|@m%&mh>BN{{IsW6@=nygdDyA-;n8aW_;S5my43o&U*ns^!-`c@lKk z52K5T$aOBy%gykUc!MU!<<)zc1JLV%nhc%Uos;iG=k7GaReNCwWi2LWvy>D zm!;|3E^%ihpnT1rAs(+8JnCrh`0>wktF z2R$=Czb=2hf8Ww{PdTqHx?ND{&TG%$&G3{<6WO#Ngd~zt2`s%w)gftKAziM7+Pl{UsXgTY+d9u2Mq97w24s04M~RoWE1RP0X3{Sxn^(X z#S^60urX~09Crove1iezlOyy}c!1h}%%$5b3Sj$nf<}GHFSPdLe`%mFUuXhm(rE-+ zSJ{q*(pY1?Ya&XcO-j${4)}h%=;Zc3Dzf{#SBuT5*P;%zTTG5K8* z;A_CCmwD209uS|*QSn_v*!Jk$9;~~rr2@a@O25k2tJ z4?=7lx@1g34k>Xqjwa+HQ}Wh{ulBboISQPnt;hXHBRq(_eX{4Dn4RZ!7|j(4TXzs+ zeFY=C>UcL(I6AbKlA6Ck$eZ}>Nvrux6pr#e_Crd(_4wO8&ucx#I%Lk=bR8oxoh=JO zvVd{l-jO6dFW*hMQeT3}W50UB@bfN*T_E!&s)E+^V!Q)7G-b=7@{-hq58Ev@b05TUl%DYv^gF*eMV)sYx3 ziL$*Cu>g3b+SUrHul*ar55Q%Mlb%CI_#4)sA4IU7XyJKPzWL|Hr28Rmf&D2c)di2*iHD+_s(A{|lvfCtu7Io4%-}b=V4Y zBbkPKJfSu4=H=-;#KWOUDe%!6;{Ryu`}m!zRo@2#AbN2K?0owH30_dr7%HC={zHE{ zP|Hbmkt1lT$&Sa9ow}gTLL^?npD@f5aS2V-hZ(B~c9D;xPXFa&`kg zl8vuseBXkCHE@GsXi$BF`QJs&QM2(fV8vY5>8sk*hOm!#L{B>qt^O zma}{wT`8irz5OC(6#PXsKfkUXbFP0c8){o$1;SiGh^o^;3P82G?a$)drJSw}2@y9w z8ajLlv}|$*(&HNGvJv&oyEEp6^X+*1ymzg;pqT?dwLNJNrUo?7DaCl=8s+W#jpkyU ze>EBw;T2@Zeu~)on>=!PpS&2_L`w$7cKqwK>loih z8uwY?Fw`FrG}^+Dyi`$A#8l5ZI{oG`ne)j8k|zn?-3Xo&uh&iHkR>HE3Ae~b2DlsU z&kqaSQ)=996ZOF&w)H#nfThtk$}r0mN@I9bzpEkc^Y>HhhfKfJX05=0!3HI72Q8If zMzyV4RQ)ok-G3$IguA?<6Is_UmJx9c)WTcYou$DW5}Dyve9Uv!4+4+?CUyx9+dPDt*_Q}b{*gW)Ok zm3zh~5}2NM5^aml0ww}^5j!xN(E-Ql@tG#z$LQ?pk3Dyz31?(t4g(|4Pv<DYX|m);)P#Mb!qk`n_4*&Jp5F5}PsDE?Psw|^ViI(?u3{xBns=_J(QQY8{f*#2nl zmxE?!naMGi1UR{O)r7-YR&Qo3`8VroLd_#?$kP~>1uq_=zbn{KY|QQ>1KJ}3guXt8 z>=ubVoJyP^w+`K$Rj6Jya8kCsx(r|7ezRQp5!W4Fj*)tkt)F-C@iO>L7hAZop_v7@ zp)$Tq_C5i!b~THS(Q3rc(zBZA3cZduTj4_0tEZYF5&)1$yuSgSEYC|=fozvcrRT4l zC*VR%vu8j$$QU!?Ze!RN?<{-kTG}}%QzyvssN2%$r=0y3ZwU3&O$n+bj&Bt&&^UBA zX4g@*9iEm?Zo>DkP~P2@n8H z+uou7OY33ymzukhP`kIm@0$XIOe&(j$}1Y#Kfd=E$Y`OdqO^gX#HIx*GAB}QU} zKgW{GM@|t+WPgdU02!1b^PvQO`Z#~nWoSt8{*7qki+nBeDobUxSde7Vw%%I!WQ%p&G@q&&cQ1GD|756fIEqwr7Uun2vlh$th2;e%6+MUX z$WKOn6Mc%~4coSp#KEYgEN(uG2MBH_l;U;fZawB?$SDhT$`#1146KNKVa>w?qQ~34a^-u~NC7S_L@-*Nnf_SzRv_yFG^^Xf68v_xyV}?n*Rs%^ zzf6NS-s^SbcO5A4`R51KC^VYG@I49XOR4~y8b(@<5`rDmL9&@VIXEZH&%~8^%OuVK z8yv7?IO1PBU3J+nU@rRpD_)opoR;wUv`#&Xug|}LFYw>~Atjkyzl5I|YIU|12N6KH zKFm)Em6nWQM^47R^|;(WH49H_b=NSe+~R{#DqE_pKMlE~rEy^bTQFeps>Z9J zLzT;CF4O#by-Yi!MZ*gC zyYyETh)t92RU9_w*t)QlbETk-OJR|9D{aN;OYg_ezXvQ8Y`RUg6C5=?CyZyb3>p}z zv6k^bWnAu>RmgSDN3;=2vk1o%$82$C^GE>RV9}=WM8+Q?VdWf;`xeWkY_$Q52~T_9 zd3k(sB+Dgg>J%wf=FT7ZLq9m8}Tj}4f+lq@c1w0 z1ijQ^)I-a;0qfsxG)=ezx^QP3aOX*Zy8F#UrEH+%y*_T^VJ-S`aJPj=?P4}6ycTq{ zH^1e5i^Y>dvV+=wsSVS2UUUBWm%BLglJkzLRjRL&pjs?>c!i3s^7;@&fPRI*hONFv z20R!C{`7Ih-*Nis=q`|a&zV*9GFEM>RTi5 z=;)Ih(!5T{x+wfJnnc0wPwkYJ)RF7Qk3TkpWah74(v2<@mP*Bq6OvE9VF4PHg62;Y z3(JT=9Pg~-86Z^CIg5HE9wPNAXqTH6NL>(yDQ|zb{nV0WYhL>T)gSX=Ocz>IBFwqk z3WH1K!}@l0GzB{ksc*v1S%h&*16PE|O+&WBapZcNn^FYBY_TfYjuIidsr#%Rd%ZGX=lAg}Wi zb?b@W#Q(4w<_ABn53?D+Jcy=l`a?2~k9K9jCsW$3UyKarH4K_~h_RRP@}^*z%28JM zlKIEZ{;Ga_458>Who98eKZFSvdTLcZnH9r{Z}Rw{WsDYMZ?#p5S^>ah+likcqRj^0 zJ@->jOCKzIm5LWKYrCw3JFULlR zl}4MbrJYGH;J7K7T{T5d1r1?)&0e&c{BuDzK@MXNvapM} z>dZILbCeI?#UIAv0Lnd|Z6LLrR74ke>-;6Um@S|{h+GP1LXu;d)C);npkNNCMQZP1 z%@urc982%^9HIY{Zs2=W+#UV?sVwRF_d~|-zVh(U0JDHIXwNWBbeOX6?b`9&m)A#E zZ5&}*M3$Ou6g_BAT3+oEHUSVdoW2J-TGDQJp`W1uc7U;JFsT#S^SMol@t3%O-x0j+ zBJ;gV8oNrX?6YM2c>UTBOlSHB?fyf?FW$$nv3*i-9w+!$CDa#p)lWeLo|sAeoqz5d z1y^3{>uU_8;$PEAJH&hQznoX-u5+J7)(YXCFn!r^-qfj6j#|_{RJZG3Wne*)M@y1S zq?NBZ1KlgVo}X>Z*1B3GsU_c}2HsPo{ZV;)mQb@CY3)&wfX|Xh#->aYdWrn`n3sei zF%eZ~q11U_kw*Re80EN#tq@LNPbQfW9%!M!gC!pf3;rV1g5D%Wob9fjyh z+mx0&nEb)0+PyJ4V?PBpFnFE9h1_Jx8+Z)@3!`__5z$sORl2tuNt3aur~oSh^o0g@ zXTY+}7$#!ZYQvTf`MI4YXIbN~q5=ZviUN_c#s=x~U?tGUtR&pV@3T$vs9$Ar+NnI2 zNbZbD0o7swhu;oBA^6L_d^9W>yK1vfk9Si|37uPv7apyDrbMB7hsO+6H_J%l4R%hx zo@$W2i%qr1=P9ZYYndT_h7Rw_uIj`LiUH#Y|upyhua>=tD`^3UZYVDOV%?qx4EYMHzQv!Ly+4l%Di&T2e|+*>CtmQ-DRi0n7o)nIu}*zr8tD*J=?U^;#&MTy{TN^<2#n|Zb~QhJQx9< zHWd|Pm46V^RDKYX!Hjl15#F#pD>`CgV184$5_;@Jm6}v!ME#(>vsjif7bE}r9qK#C zRZNSP!`b;9Hu^2P6HB?wQn*`K0A}`%>MlL^6;nR|_yCu`GujY5Y|gS?pe&>Ig$ER# zuXo-K3E3-t-+4Ykj)BUnNl_bZZ@=@;a+eo1eq4(|AzjA~30+iY6R<ham5MA?$^W(uMHZSNZSZwUyZv=olc&s$RL=w*1ebao>?Li?7OeS ztrq<*5)zvE86+iHUV`MsE@`;{xCOkl_G++zg%y(2D*lyAz`Z!c4V>$?Of9FJ$u4 zG(|aA#H*hzL?9^;8$3C!ZL|LI^Imti?0mV>1|BxcB#ngAthQ4}DfjvPlt*{lYq0Ue zSQBg;*7?&91KQfpVLXz`{boWDa0qAD=#01$PQVt5qTiMkG7zB@@dtyL|7$8XvYy@R zJQ&}s6HCQCJKW}VFaJgl)aJk8%`$?pvB6A7_R0s8vYAw{(bj8pl*?Kh2)%X)B3MeS zTp+u1<2R}@GL7ZHH5S0r9RH*s@^-Vx^nO%&gjv(~6v#Sz*N8F9WN2J9Wpot?@@BP+ zbU5br+q^8KAgkehPsMk2=~6YDZY<#BM`sr9^g2x+Eh{aG*HW$c#?$Jy((KY1xi!aR z-{Yr2PbWt!ry!w@isv^cBB3dOcfuVJx8NNC<^`4;scQ=!4nv$P$c9qQioa=}r%@{t z)hC+1Y)vIt9Cojy#z!#BMYx9ljc~VZ(>Yp&g>%81ltPR z>WlT9AGLfj7X76P^7;%r0tq5khJYJyiHf_71vX7&0UKexo^{W+Y`I5?4uGxexLL*{ z)0d>o-p$T}69EB{&7|nBbPoW@p6tPh->^&8q~z$$zaAPss+w<9)cm|Kc(K;;C`?Na=2ctQQt$pHvVr7{@(Eqy z6lH8Gl>)2JXPER_KTw?v3xBA4?@NN4@zsTuev#>_CTscg&CKo?6O~Zce*5R#X1dt-e|lJ#njHO(8CQAmL>e%0N7lu0G3B=V(uV0!y?a+)6KbmvKR&~acrLGmHsu5vl`#Ih@kTWMA<^z-XxIu$hqpS$XfON>xs za<4}jyJm>y68&r4{YzZEAs65h8J~ApO2p&YwPC|U)9xnNVttuK<6{4F6~^Qw8adyW zLd~n&zBJvVShQcyvrMt-yY6S{1n|iGtj<;@y9n7nz0?kCL(7Kc`@Ik25zniZoHo&+ z9D_WOH=h&YKY7Xx`-zD$OqOE@do$gJd%|79sC}ofFkGKYOh*>nz`vkTkUnQNy$rVP z*o3#g79?}_9LNSzSYdP!Ly>jl#01<@QC6|ok6j!0xVYZ!4a)LE2<%chUu?DS(w1Xp zugV?5m4YhER4csdln2-SzIV9ZzJ}~gL$NB)K2`#1jn^@T02f+J;U4_^*sMG zjlvR2u%^aDx{)v#yWhTzEzChso&2-iR zXTdEUpObU)AWye$!KkYg2Ya>Dl)Pzs4UVOc51Y3ufc@>+Sd@^vxQ;&$I(tcw(ch#t zH{}JRd6w7&1VkTvFi2O+DmNBV&$w@Qo_AroSjH4LygnTE26b&eD)HJo=10gnIbi~L z8MCES3s1}Ou_-rRB5IZE{zH`5grdWo-maVv<7Lx|li@7ZJ74{2-~{G%-~2X8Bm`zb5D~7 z0&g-?VSy~KJSuNTt;&N2(w0#Xx95>H!b55@syVVR?1nlMxsaR8ei-d3OM}}7DkAFd zV-6?)b%qfzr-(FJ(LIj$ey@Wb!LWIF8Y*8$i`NFb`~xPUYI!u%jOj!;mJ9?W`XU(% zIsJw*pw8K%hO2q~F$N2koWx(f!q%fE$2K_s0zl1r;Y;%Tf8k)4h3U>FE|ae243t2~ z)3NV<{IIc3>x7q~aBDemzhkA&;ToF~NF#6u+#P!GLoNj%>TF&~zRCvV6aPueF{d*# z%f=3V0@Sbbh;tUxc*6ZU_I@3DiP#J9-6%BMRO3(v1b61^>OGxt+}z=F*2aD6c^4+d zNLPybJKoVypv!oGSn@zAM7DbB)8o0MGEy`?sJ6(zKxvg{&2_C3=0P>U=oOeSOxC`T zn;kw7McnmPvkQiAqr+GYaU!>;wuj)wMXLs{VMP2L?r{$EIsSX8ibV|PcOyu3~6f3(78GxM&5%*KXp)c3HGu1Ep@%!hmvCY>#{{>LU3>rZA zV*bywm=*gRjB(>wbILC3`$d0%z*>2wGQfq`dP;h+g?_NZ*rlo6SSVa`pptR0g*b*p zg5DgxM6jc$%(mLVI39FT(Z=>E8Wt=C|Ba4U?_e{=^3_=d^f#3V_=-0O!I0zg<>7Ni z2<3bdyf`QO5$o$|xYhG2wIP`f*|z6U;KFLzSOA+lD)PTD0z`1AI{34&ZR!_cYq`5% zjr*T4#snuCAZVt)`cQ}XBDZtG^R|TV^`z=1)3;wuwT@OfkWU=l9Mu@l`GWbAp%Vh0 zs(q08w^%EHsiHtGDk+S|;zr3|n(sCsG5GQxLYhHPBpP6S|EvXm>pnyBdz)n|;`<`I zgNq^E1;+o}DsZZb`4?l^x{`g~R~Nbcn`|qQD7R_T$Ggj)KTPb{!(~1{uAKjP1edI* z5*uHr^ges@FaE505CQAa>)hkT6YDC>@79GA(3z1t!h&wISE@C9l=Z(c$BXaD@Ma)hbj!8vi4G$61emOb6Y?;q z3fyb%G9hq96AF0xJL@iN?)9P5+|39UEatURjKBA#L|-Pd*XT<`T*$gh!~VGZ zr=P|qBx(3)zcLxx6exqGQ{G zJjq0XYnd^U-K=jT5o)!ONS~NwLV{NqH#5*3M29SAw{CsB@sr_I`LDUIq#Z;eBm>JA zN7WJl**731nJZO)=m*nGw5jjkpG#8*ef?>w zc&24VbOD`FQfik+XP-m^AO=BcFp*86lZNL*J_r(0B$z=^08ZKgBAgO{)_WqABOKRI zMl!rVx;|tW(S66^FO7V)76&TpASy!n*I+W1BA9zb@Iz zel}qSy4wa(QV}FJVLqhJUPBBXn2F9M+8$%E@-|j?*JUSoxU3`-gg$imD{|BU|&t&xzb)=j;_?MOKA@vHoZv*->Y;{|6}k!%ah#O zPUc4O))+$JKRFTj(BPLIpR4LE;jxht#USDSPQib#KiT=_94pwNBYvOq z#|Bo+6>6k;(+j`p5>vwuK3xG7-i@d;6pFx58XFACHV;Bp$}&?GCryth%U_-PYPWre zO7BfNRB|86MordK1`De*|B=tJeI1F#IS~)~uJ8)Fxd35Y@NVS5j19Jd z?x8-{xC-`bkKBM(?enV%;SkJh8cg`5%}ntp9M#QuKh%(sx~NPm>wT*H-jnA}?$pnQ z$D8fG&a1^9W6BMS5lYIhH&Fl(gN;QW+>!^$oR2lo0ov`cll%7*ST2Zm6ccv@z{ZKO z(4GQ?~Ih_FO!)a%{WhXj&rb zlC9+0j$FobVPxjL)i@~b9WqRiRQDtfF$umaq(2)h?4s;BeMSfkZ$e~)s3m$*U>e(; zc(j03?*F(b#@V6DP9$Zx>@mJTYrt=C)De+{otaE@|NrR@;9!s80RKyOVDO{d5&+P^ z`+w6N_7Ewo)+88@UHt@%&e>bGsl)lqci%PQ{HwNZDbHB_E`;AP0e62j5#v! z6pI5^sXIHKesbdwg3`)<{6O4uL*CJZ8A4sBDA_PkC)vnf0;vy-Wi~G;85_Al7eQ z2$q$LTt=2_)POKbp4?0u1=AHyB6MMMM<(K$8Ilf%k~Lxe#lJO*0MiLt=ZMt2&}a z4-WHfV*7+9TL=sEJM#n1h?r6;U7rd#LUKDooHvs-Geffm0f`!(`;WOELh`kQ>LqRB zG>|mfWS|wG`aqjl&rR9@EJO#Oiy`ueGU|mh4h;ncV+UpAM=jxHtiuNmUzg zN(dAPU=y#d7^nOrCUOiIo;^;oAOQkA?tF+lO05R1?z4Lvo%IQap$DZM@FKAW+92a5 zq?S%Ja&|H;eC}Z%V3|k?F3Wr}BGMVCJlS(|$BdNDMWC}-42)cwB+~y8%A*)BE&~(M zf&yVM&=p&l=$DBup5Q{7G*yTKfzYZdqGJmY4>37LPNzsJ*_NbBb0ghqb_ZjN`0pO| zB}2Euf^nW!cbbW%Ln%h$azH6s5a>3XW0Y#0oTwv=yIlI0k1eccR$KE{0zOQIDxvUd z+fSb76hX0qq;}1PKammE|DDmEYj=?tlmAGmZBA!VC`^BUB$BzT4YiW#{NmI5*MVmN zog1bT8mMpysWnklyXg|-xqu#aAx zVLR+g?KOv|S-TT&mPT!Hl;Pv*nf5t27*t*_jCMp6N-Jii*O?EDJ2_F(codv2SK%iJ z?{koZ0+#AMV-du+moMr^4z6mbVYH=1BE}|$m?=o@7Q5=9QXb zCY5*s9qlUU8XH#ELeJb!;KS5ElZ}#!W&=E$&iVa2F!R1_hcT)zQ0mwmDXldQYUSTf z6af@|*BNoMI>VPDE|k4=^2yj~T|U?%SHoF@@lTKVqC*_&lgy$yQ_Hz<=<8PKAEZ=B zml?3{0-MznBMoxH&wdHgp8sx6%e3myH^q!>*8fgIxLvVf8mWL;&(v z_j90< zXqo`aK!pJe$g`c%Roef<(NzcH)wc168N%^U+qXikTyk@ZX8Nt=ZLsI=V5We`>e%Q0ryu6*D zXFB5YtmpsCX_rrQf8u}VlCKebhf!NYT2V`VO0!Zk$r;KnY~9RL$B$#Ymt4E~DDWX> z!YTI?2C>uy^c~;p2-<70vE`@V)iwp%cQt*P{+~}$GPj12{=j}jScr33adnq4;ev_y zTtG-`xvkKAr%#{X5&{jm(n3NbcXNx?e!5a@DK{xUJ+;}ju$(vcp2Gv0{*Kg+uD-|$>4N%BaH%Y30MN1I|mG{ zT*~o5N-%+7e|^GkFl=q0oShj@I%JbImt6A9a%_?oF_$3bWbbNW8Cp;y*cH!ZXXty& zNyQK&*%d7YhnGkjcittJt@2e~e9nWiRR3F-`2qTXnhGqxer;PrfoI>Sh7kwoCdcz1 zf8S4vuaG*D6a9p2Hd4KF=6NdQfKkE^C&+^c2WK(YU+1PG2UK;sA3b)2W4BOl#^RoM z1iI0gUGw7p7!nZsNsn#kFOGF{;d-d@nwV#-<{}@nr9wy-Wt{D+(a>J6T)mv!@<1`I z>AKC?0RqL`)GMNmF#axt**JVRj``*Or1igE4^oEOx+Zb%TjJA7o%cf6AZ= zA9-v4GB)9@w~o8SYI^XDT8Wey8N24;LZR}uE%u(aH-A7sVLSBWEHOZeo%FtEUpJWu z65uK_<8>kBH!Y$wE$Rbhj;~Wk(an$$s-#*Pl0h9!WU>4A2W@)zp6HvJ5CvYXnQn8B z2N{~V-|wk5X=Gp$M9mTe)}ruj`LIYA`_Lu*|10n{HiP1|$i)97vuHn7azscE?J$9A zlPATxTTRV4oJihDf(|lU&qH@U=l+1+n`BOhWyb0HZpV(Zab(-^E?mjPRweW-HjEF4 zQ|Ufehwj;6wxARh$D^Lp%i-PU@*)J3vsv0V#NfnWty_8i<`(Q40 zei`>XbK_x&bm^0GyKaGov|aLyMULAz@`KL^--{&@d5upq57qv&u?U1deUev9c?HN?5xfLdPAEdT7$uV3E;3<}W^Q0YG7gv$? zNE<9M+1g(kL_blPYeeasY-tm1L_FJzWGuk|#W+jr=0yS}c)M0>lbU!9V}*q*b}yKde@bG+xB;Cr9; zjk2>FNb;|v!iUJ;GVZ?pV)DT%IT)!!7PJrh{HflwB6>_}>j0+RKUTvvg@kpBTKa2b zL~~ynYRw(lDIIo|IBmmXLXeU_ELNa!p=C$KYfaEAJfHlqIDko0upYEz@SH_**#U7J>`2rv@l$AfnnLx^33R5#qEb?6iRQ_gKesGi?W)Ws8xp)F+CkouAB88)9>-; zYO=}xY;@Er*jJ_K_YHJhff0^Z$PMw2_e>@6)PhH{Y+6Q1K%z&|A%3le?`|H_q7J@q zUpK27&UUTl?ut*P#s2+xd8&={2d;SOX+Fi*pPd_ zV{}_$HhHB!?;E-o5+W_f)${-l#e;mWexX2gsb@p^#N4zT6g9h2d$sAb82N!H!@=Zc zmdkp<$J&`Xze}g>FV_v(eJz(-*@UUC{H4&*N?JlC-K1+5A88b27ix=pePKq-K{nJ4 z^vpZc>$8G{ZwRvqdcP1aD^6R_`rQ;G5h`U@Zj)GQpd0t4tOJ-|ZcoaYk(;(#>nP7g zHjB?_CcdMc+_~i}!zP%$yIqa;8R~^1iYf74ovm?p-!eDn**;*i3Keud}lsYHg?boeb0T5`Ah z-cd54Ji_~J3)j2TWJg33Xz)d4p{u8PO1lh(!C8jtdsFYGN)8J!JnP}P)5@tG^{`Mq zKeRT(_7(nmsZc`m2kNxjPpd5DOIYs%cPB+!uzIxZ)5+Q2j zgM>Ok`O(2*iV*qYqe*a98d83xOQJNqfwWs(m;K+}>tUOJbd!&g#kzC+%hik{+$0o* z!`quxrxK7S(Fmy?6*Ck+L%%PL@z<-jxtaaN7BNeHj5wUIsJL(ZW%Cv1LpyrE!v_xt zB(TZ3 z&Q-FstJhF=(^`G0`TDZ3$*rcYjT9XgW}NMogg&sxg1en!Nv#?JzKL2}j8ubsU7lQ) ze@L2fzGY z2R`{7?_D$9RKm$C$Tv;SUgJ)6?4pLd*GuU2G;lC|8Li^~u#X-F2NO=7p>9JF*oD#Q zJT?NQog*4ua2$SKSN!5Xu1H8&)`>(~RAW_s6R~E8;=d(v~ z)7f<0)gi@z#w#c1cIG57efiVL_w>Pd+CP@@t5_(ab_yb1vrKdVxXqAeu_yESI-A>hV~*o-!aM?~W~IylQoH;Zha7ngs%Ju@U>W9`_lBBmGoa zt+V6c^%2QH`}8lp%KMCjf594qE(vdNmW?;)T%65A3d}DRv zFn+G~GiRWox3HnJ+0}48X4C%rQ2kw1o;`USV-Qoc-bf2ehA7DFo}3j)dfDpRa4KC- zJ8uYilb_r31Y!n_QyhoV*fEshDBCXE;pQ^NoF5%lwgM^l)pi_xC8f{a*N2a9zb$)u z{OEB=la9c~#3@8_w@?%vcW}9TcB>}a2!mG0e2UPcg9KmImWrb|Q-Yq2<~Gz?@1A$mH+SYD03|}R z`G^0hBd4j-!p+pUzoZoUr(|lzf81?jH7Ds)x+N0(eS32h=um}x^04^eV%TS#=yN|q z4_XIhQnM70_-;DQB`T@Bm-iFSFJHB^LRA(0;v6Ps`wbKAE$>9bY{J~^!mNg#+k!Jy zu+*8(ZmHI}^frFtQIWZEZ?tPva9APhG$8Y$gvhp>+N1~ztpxTWHH0SqJBaZ>ni>?% z{mD~O=MA2K#`x9LOP4)BU}eaBIPwjQ7)EtU!9Lbk&xN{W|MNh!wrKHtJX>=hWGd_m z=fh!pUTe7ePU(dl*qQrsNY4};%exY6F-*7ExO!LfgNHYq@P^+W6J);Tdf7FJZg9FE zM6qod6d7|R2wXW}w3%3OEcp{cAk&}kHuIeW@~!YK*ix5GPWHgw28A!e{A(?{%jx+- zvB_4S6wBmt40KsP!#afjztxK;7aXSOxem+cYkMt4US*3yC>|i@4|hGkjzxJn!%F&r zn)O4aq_#92J;p2_T34STAAG@D(=%m4$m>?kq2>808e{uI6x(RM1H&{EK@U}_ehtLv zC)lQKA=g4C4rg=DRX)Z1?|r>%Urqds{ji2hpMlKSv6A?mUHN?BLYU%>OVLAwvy$!9 zJ@(ry1)_m@mYQXX9}$(Ys%mk}4tB}jjY_^rn72Kh<%y?6QXCl*nQltVNh{rRaU7;c zlQlG`Y9IFv*}ycy5#6bvtCAY)G(g#ywXh9KT;VAI1jqPq;_kcRt)!!gg{-TUBXWEj}wYlpOWv# z6vdhE6%$P9n(<$w-xoSq3z7f4B#xPRegCq~TU@mNE*A}Gmrj;l;yZ)IZ(C8GQe{*C zT5BydA1>qSG{*GZ>CsoOos$F5Bgcu~T;;9hB(;nydD3hI)IhOQdX;-D1x6{vFSxTi-Wkx@gI}LJG z$pae&Y2G?c-#E)qDBhGl)0pFWg|$P5kUx>+RB5;{g$h~aHWBp+uI-6JRq&2jqNAo6 zDGnd0ikEjMRwkmrv<#l3zg%|Rb83hs*!R>Qm3V4@ac0hW1TW`i$bfeX-YQd?F-AX1 zliN*lVin3yF{ngruc%-hv=a{ARo_xImtz#CYf1VDOby(oajcXh@hOzz_ecIweFwJF z$8D7bliTZ8vK(=<zdD4pHX?4jV(tYy#Dnp0bm@7^cBQlIcGaNzeFsKq`85hbLZyXxyVa@+mg zDct21I2hY4C5};h2Q|f_B$*bt0 zaeCY_iH=A*hAATTuM_Fkvw7m@G_qw1Br71`w~{Uq=Ei%CcR5G?#yIli=2(`YQD4;Z zjOY4^6YaVzmtt38H{E)&nDLJwClgmZXyA~<*dZUND1KVz(mrh1bMJB=jF^_t;!#}U z{*P`Bh)G*}!{Vf9w$U=Q>b#I+CDrBH9X)eheB{eLuajGYF!s0dVS%rfr~ytZj^+@Y0M+ts+6%J}C$an7uO zETZm9&vxiz(u&2v%Fq8Gdxi&ueWvC@x*KI5@uVp>mYzqtjRY7mwY=F_EDz59Q^;La zIBnDOF25?DaK>eBS(rLa%Txc8I8Y=9Vf!jvPcdLg8o=8ZQ)XZ2#+2fQW$^Q40>7U_ zUe`mTXnv9!!TuZZ`Xx)pA@2KK()iy@7YB~N*UCN`$|ZydYw-(^*BTBkht%+wKe;r0 z_W$qZcq%)tGnkiGv!LO8o)xG64$lRgF4<*rbbqWIlcByu?#UfnW(#xU=+$PX-#rAf zI_IdnSIYe{#%y5cI}2$0j*<`RpYe6|^7)w_+*CsYYmYgNSt`Y01QFO27)_rqTpCp; zwX%+WXrUcwY0WYGL3cZ|HF|48_=%Q=MWI=$?G9z+&sF)aZJ6e5N54|dVz+i_TTkuF z{h6nnEKEcgNZSwme#7MvC4$Zw=o1Ti^-X`0FvDIxnLNdo$qslqi*0ua<3I54sgXN$ zwASI`_d4$UzbMn0F;yl+r;+(nzDz~D(6H2sl7h9FV-%(ti1Pv2k~0t+V_{XWHcE(8 zW{?*g;XF-48_$Gg1H;L#9m}H=+_|+zl-&FeJmN~GmZk?MC=UU#q$6C2+Drm8N$9%8 zc0^xCG*pf4e0#dC3IXe$9AZ;Tx4YH3ry_tTBc@BkF(66^$AvW&A-B?gYIU@=_f7!7 zP}hT~x+?C4?cAg@cKQB&7ioNd&*m~see*J=`DXe1q{JcclmTD}U3*q03ffna4U`fu z%_T{l9`AJ?=Q-Z!pu2Z2Cmr~1EqdzVWUt{m(IzS&j-ow)7PRFysEcozCk|+Zvhl#b z+9r3(x5I}Bt0lqB<`NG3CkOg7=9PoV@G=ugU|FxXe}>5u5>cB8ZwU7hY!i!ka2LCx zLVc*r*&8ZwkxoVE9L{tKgOcl}Rcf`4NE%L4K!^#bLav^BgOyiCOsA#tFYPjGYb!6D zyUX=udM3?B-&3NMWB{z%izHL(FhZRrka={;SC)2n8YQ6ec zW^K;3C0;RJbH~#COBDffW@oO%yKtJ7iY{JfmqMMlTqq&VI%)}v)xFcd7;dgxsvSG0 z`y|^se;jWcNgv4ZPR|e8AK+kIp|qwE9`16+RKSMOn`3-SDAnZl_ahYB7|uVQIxQgg zudmutxZFLGFcu?9@N|Lq_p6<4aY~btFoPUFm3>Hmo;da&wKjh8$Zmi+A_#o?A|^(A zBv{2RL|?#lcCN8A=0w69Hns|3ifWl}Wpyv@3GxCbKwn2o1 z<308+TLvQ*S&V8HWZQMY#N|HZ;NBs{Y^}vf>M~WMxtoOvdEKuHQkfG~Jcg@rY+8k;XWo0k%c;bQ3n9*DJ7$)M)wb}zoPo|X z;tJ`_bQ3D?L7Wwfy5L~K#l+8Tp^duDfuw8DXp>Ovt71@6V<3}Pk;9iL5mKE~6Dg)x zq{K4s$xWO1zD`c;Ews~FThzWT9Y}?xp^fly6g5`=8jV{mJh)?*cMY&GHnkG}k|Vkj zt7xJ7*bde%=N@zFyau_VhT>UB(c)wY%_08`&{sz;%KyG+dJtr`w2;n5!ZD>mUpHl> zmP;6JFOq?{;3=W)_FRCkb}jW-jfX!6x-PKH)Y`i{Etth7z^b+RPdu`yn z(!wvKF2A(wPxGa6JO4q5QJ!h%yR5Ol0beJQu?M;yd(-Lyg5?ztR=U+bo7r3V|3Hz+ zZfza&*w~4ICrydiQWgT0D&v=v1u^+6Ds> z*Fry|1D=vHIh4FL5fFe1fu1`$loO5Ef$pw+Uns(M5v4k=dDkT}lX}la+$`_IN_NY_ zHYX#?c#0t&yS`wLsd^v2-TNyWNdbWX@K>N8(Y7f4Y1Kc?UW~gncSt&J^waHbMb0|*HonVsZ~%A1R_X4p ztko%aUcZ;X%p8@%+9BkUP4Sy{0nt3z#bVjfEv3ufP>yK65;Gj8go1;e0h6b@t?o;277L^1Ry?L@~X zAc+1c@zob0Ti^03d3&`j^mGGA0{tBaa!Uedv3-Y$vO4Y@=FN zw=7kYIt(_(OE3;J5=%ayUjlG*-ktyN_g}jBj%@m4H}#}2Ow6BoX}1?f;a96KJ=*3@ ziX`ZrLmt=Nwm2t3j(<tJv!A60xnkJD&%SMuYaw-2}zB2xBLX^QYKIZPJ`LS5LxhaScdo%CUkH%`( zdNstrXfW-oHSYVbVK`Hh_E{db1xy>8NAL=RnSbIi8#Y&R8)&_oS_X&HSfUAz4Fskr ztM+#%hJM%p>U@fZ3NZ>sj}#+`AU*x>&9_;P{GN$$sn2J9f?Ptx%9%O;1~9&?*B5Qu zRh^^110de>@=Om6C4?_kjI}R@E@c0ne+RXe~p4;!mOT@xm*@~1g%jOyH50FYL@0Z#i`bF*YE|p8K zrqVSU96;c)sk!xPqI_F%fEiG0K{4HM(>T_SX)sg%5D!nELePl?vr3GDI{A`%+ zQ;cV2x+=D?_S1kV{&{R?{6GELXP7$J&TSsRcALv68SY~dGS?|cA4H4PGZZM7pRA>( zS&aw7IQGjvhMMW-$-8<4q1U>93xeXlmq7^ZQn<|cQ=^2D7V|DPoo-asZ4{Q4SDABA zLH=1v9%oZb7<1}&Us84U%6$j%DD zNy`K9=#VrG^nswOT5T7i+|SZ0jwY%<_DKU9f8)(YS#0eLh$`}qtVysh@YK!6$u1kp z-1G}J-Ha=?-_lyVy#{hmDBYDS`nwomf+-IK7VQo$w_>tKfbA(E59i6Uf#koD0x==O zKTT@DR~!04dwOyAs^Y1Bb4a@~oF3O%;0j$6Dkk^Plug2B5;ix(-K5xzrdqfL3wzdB0eB3)q>dH<>o%6jn9pY?m?GB&4BC|tkIbz7H&f9ow z1w#@WI&+KPzxnJSM2u?^$qPU4*0a8jO2&N&fLN1XyD2W zHPhlF6QDTMlF#_zI^N~K7gSVOF|L^6@6tD1fTqTu@5d#Ga>VA5JOCNn<~c0{ADmB~ zuiUNzF*L?F<_imLk{9!{3m#UhzJSZ+a=P7jCGp#-=8Q*;Qy+X^J##*?-G}hMXWkwV z!Ro*fNh2jT7{nXzCwgEk5WM!0hk<;HiaKn?b`~MOt27$zu%15TlrcVF&UU4Ji3HNb{akc$(Ub246P+VXzYtf}R}+wvz@LYVPYG4+$@XaO6cF_m=H!SXFV`GO2uWM#48Xm`Z) zxj?hU(7f={qKv3ZNV*fPd~~o&Osz1$S%?sb4!t}_h0VVNOr&omGU10uVsI%^KLZp> zx?myS{-!qvUcr47Fi>_1d=cgsZen93Uc~S-WhrGIAP^RnM-o6Fvt#{-($1tS{cKHh zRLmXB17p>!A)LR$Kb~AoI<$!+<7!jrCHuzv{-&j5k+ZHnW+ z`_{H0Tr(tBZ^vIKLLLGd$>cMAkV{bsY$RnF;I3^|HbF>br3AyxsHtDCK7)tL ze6Y->T=6t~9u+WTZ|PKoO6&cQ7P+~`q{jrTF`FH1Q}`V?yxZ$_T;Ma~Oc6c<@)WEd zk6@T!XK75B4iAAa_=}6cM80`udorjLgc(92|0DS%eVgA9M<7frAO3JMLvum!wTQVn zx-zxfnc5mRk7dSZxO{A6-MQu3TH$@6h5wI>UuB%PbPZSt` zs@JAP+zGuY2DG6HMTq|3fGAI@3Qv!>10cC%TGm-gl)w}{UN!Yn@z)p)^VktUGtq%= zAj=?l?`EZTLqQG6IW>}P6j0sx=7YrNfD|n%m_kZ4S%0_;XFdLy?0g*;b+NY*WjM>y``0&= z3w=k0H~g4S+VS^`IB4hu?CNO*mz(rMYZHc)a`pdo#z9||ZQvY}K~SRGGswd6=hZGL zp<}XNO?1@t?-Zl!*)>E~rS(dSL#upr4t&)cA}4`+=X(+M`PJ(2MJizS?j2Whp9z~C=12ug;T&qxwfvNW! zAzsVm-6ga_Zo0**M6DUSwZ2V{Zgz)N_@hO?F!8DZ1Tr>?X6h$WocEXLvUp%M(nZag znIU#A19B6M7Wfwa6`f=;%i=2my-$}m1hM>o2t4gS2k45e<2GJTWAe@IpTm|?(t8;-lH};8AU(ai zVHU1kS)FM!r45?A>Ka@MCej~cnY^_~+sewxyiGZv_VX1R{RfaR6nJLqxZ)UMz|Whz z-sG=j$sg-2UJuWFJ(~9M0qh>6dAMa=kCb9H=|fG;lbvOEJ%P#flS_QOR9j=nqNm#e z0|or+GYcg~vvfGnaN~C^CrMj@N_Eq*Q@b%H`8fU$$Sw8Oqpw3V$Y}I`h!e?R`>`oz zq~=DR$boyZ9%6vI)o*nbhl?6}%(wQ}IWp-DK;I@WAl8BlZ;xa6xdFkSgO?Q4(9zJ& zWX)DLS6L&wI*DMCn1PDF@8QaSpv0yFI%&J#%zPetx&8?i6p*VPXVtyyqx$z#Jq#Kf z)_i1dR$Q5Qs1FVhQmP6u=R6QU9pxXG2pmli;AVbT6AR4EI1eFSddu+)AX|npPxBVD zTFlBvYHqG;<(39t4rbFLpuqiM39PElJW+Rd<-qv^nqJs`$F$aRQ6IRwvOZTlEdh~W zvp7gL-Jzgxb|}vT3U%oRKda#;+>~bHs_Vq)AL?gU$IqP32F}2NLMd!Ng&n{V4{$4S zPA&ztjixUFm8E|-{ zheHv+h%yu4jE|5%QL1}uqBDJOre>V1$QYc17fX*tD3qOEBrH#+b`_tOWW}g_63=D)7|k*7qu`D8T}L>;u#YnnQ(87R;jEy2^qH%8t#hh5N#IUh91V9~9Hj?(lh!u_({$c;czvE)%nUOTRut!kw zIcYt-INKxpks%NOpY2NsW>?pD2IwfaSX$%!lj5P!9=e3rv;Pg5}j69|FV?|=b^#M{A7RDJ>bOyHhc*35-re1N&s`avKmEOhRZx_#*q6(2JdRIN0{ zFk5mzlK6r!ArP4<2DbH00NOCDjj6)k%SoV)rjq~8kcLmxd2%m58}V)#7G;?Ba);`l z?(sI&d|jRltz*F|&59u5XI^`?ln75922(QoN7Yh{=;K3}11gd%b>VG$IGORF|LrXN za{PY0uhWy1sIH9*=gV;Tz@7b!rxZ~c!zu7v#}b^L14#WdMey3FJWdzwL6 z{PPOrB|l!gk`)a=@ItZa&ijQFfxk3*a5_bl7tRa<`HTC@SXJj)d|wrJzpvXG$f;_+ z`pI`>A`JX1VYvJPM;oOoI0PLmN}mq3I8f5P%Cf@Q6Xll}_x6&hxtp$fc^e3HOw3UI z{jeWTM+%Y*%a60Hgxx-uVqUI{4W~^DWq+(875w1Ohy0C988_5fUG7aD&rc(g1!T+h zpLhcJi3*eA#jvkpQiWe>lg=D`e0OEwWTbqx>!1{l>h$j>{H(zJ-8A|TsZkg#h57z9 zwge^#O7ZJZuX*_`GWY!416vRsZ)+U`xlo)u~gZtzvgAH zXLXlrgvrr1Yy<8|-kx>A$N1nSC@7GxcOP#5m!Z;Hw)e^xCV|5HUa9K*bJ(NN?e#{8 zfg82YF2vnS%``W>L*wH^<@EE0#Qo$?8Q6N8E7p@n zxaz%h(@Y-Ggx{yJx*aZmS}RTc+UHO27&DX$OPY>p zLgtHdG!MI$LG-H{cgQ0H7XcOFcR#qW9Ga359&ghs&U+1`%G^781~gi!xArJFUQ0m6 zG}%qzzW0V0CBnXV5yhF|^J_DbxQX@ub%}LuLN|gAL-r-zal>NKYxgDI9SyH6v$T%? z66dc*kzDJQP=rtRdoWl1BZeQ5`H0UIrWqr; zkBvpt{YJs9%GDK)W+VWbothr5yA=?s<83LGXuWl6{yJgb3byaX&1 zo)M+ufD(4M;+{N)xknj`ec+e>8+(9>db%PTR>?CV_+}}-zoEx`EcoZ8AjVTq_9IQP z+PcdBDh*Jyr?2fULJ!S-1?)|YersfGu_r`sV9?_8x9Kx;O?fYupeE;;K(t9Wc9bsv zBwa_8AX%}-Em((wL>11E$zlydohMVy*)KL}-!RsSFAbfA>Ih+RTgP)OyqAlC2Lo|} z^_cm&mt;fsGz-rTnd4Vm%$A2efc{T*Wy{chDk*pW*eSFAn*&&tHijv^K_8Abg6?XabIov`_=%Dhr3$qioXHk6|#@La+e z3(gAgMFqDkVYh=C%yy3F-BzvQ#K`@&J13O=i8-J#uIU-GZ+~TYNlA>>Cc+~Jr)gp< z(amES0n`9MohX-6GfT#wI7QQaZc5T7A+Wq7fDBSSmB8T<75e;mE3VKjl>Oe1a*c+_ zWasg6`F2%z5UX_5aOS(TAb?C&^VoDLVjS?n52LVr_5 zhUe!rm!wWq9r+f5&H37m?o(}PBI|aIDk*IE1qh}Ht7m)L*KlnWG8*pbKjK-s!TUF> zue6#`#Zt<)CcCdUpAFLoU8wOTkB?Es^1rvzBw$)Uj2ZYHy}^25v^7EKqgn)+SK+En z_Qxl2-3^y4=zI)$K$(}XOY{&bJUL`L7^?q|ON)U-dz*L(MCAoi`zzOrWXjwb{&X}lL__9a`C$e#+hjv&6 z*-U%$BeV*G8F$|Q0jzFbU70@yso5C-50h~wUgTs^@YPxF{#x$NX?lf+W? zL!VO2S*Uz;#q|8{=ch7Kz3+Qdc8h*l`PQEU0%-{D8TG=gp<8r@ttJrexTdpK{lBB> zmr#!N^v(^Ohs4WKy~DhDYoArdO?I5Pp#**Uoow~%gV1{)0}v@H@wk-i&LY$b0U0#- zM@*_6_iZQ;%(;>-vuqha08IV?)LGXOGub5b*Ova(mKNFq(8Rr$#4&-hZMTDwK(zEo zL}NTHSpN$>R+1i1(zKTeH1Mw?%@J6Xy5Tqyvhi=q52_PK2^+#Vgs>wzzZ^?n4r(T^ zes`$J{%2?xo0)BFlGkMV&GvKYR2B#zP5d5BBs^5H-F~d*L8otA9n=222Ts|%%J7yB zxG6ZFHdSIj9Cx5xRS^L}9gCDWZEO@1@s3T;)Rr5$K4oUxx^GdLe6hFWszc;JgPIa9 zCa^AZ9CWxKx3h%83<*3{C%tY4*_C zoVNFb4{_;N{e(jK=#Jh@{LL2o(#@^Y^OL%Nd)RE6C@g`Kq98B>)JN!u$S7&J%BNZh zju6%sM=wdDsl=|LWmt#}tJh=uxJTIH^9*6QBRQHaZho84r6*DAO5QL-uknXdm33^| zY^P8AcANxyu=ro3TU+DUqdGwT)0zt}-tFY}_uC`gLUb{4Kl6SdKXN8X+`P!3J5*Ef zk0eaU#LuzR<0RQrKs@zq-NyM^lSBed-e6ngK=)WU8_;+pfTH?=3CFJrgDzz zD%B5LSdVAhd7RsI@f1*9kA0G|!J3EBj=Vk!OHsCx`m zJ^>vG;?s!sqm?mP_-;f!5^?f6O=YaWa|smYKI>7%k42V*?;2|abDzMJi3ctDT|$5u zaBot;bNNnnbHz8`jJTXPXNMm4*%0h28mo@uqk?fG?Cd^s}xUX5xo?qpd}Y3fU#%qYmdgODJyKx!dW z;xx8Ia~EH?sQOyrzql`g@pkIs))n*iIu9j% z0v7z4W^N&pxoTciDzyY`MfijJX%J&w1l4iG(i3ODii;f!eJ%!AB8T%IQm;UEXL4<( z*@B4bDG}Y^=*iRSs;VS8-IDlwmsT=Wq#ejfB;BzQixDapy$>>c6D!$PH>>u+L*wH# zkroTjzI1ltH_)4v7e@BX^iScqG9*90A4CIn>+gFy;14xLgkW~AOwA9c+`9`rxHj?} z?7d=@H77p=Gb@YM1YL#3k9#2~!%o{Nu>Y-=pDhF)nG;dW1X?a&Lh;iaa)QU(b-l zyO{Q8y!^J9up(qZrGXn}2i9H=c!zBuWd32&{#8*PX(6GO`T*H_ImeV6f;2|%F|Sf2 zOFpt+C~m$6rRRRkCx)ui1B?$^7aPZ0NRH$uKn!Y1r%_HWj?>GbH^%hQ$ROsrJ$005 z5oM9P#lqfXgLWPTyA@vYfXRx>w$sciG4=lpCLdNtUF!ol$UN^f?8@Dy3`YNyU3ZU8 z^h{;h<}ey#f-9@KI9b%by$ct9yx+L?Bem8f_{)l_^HRj@C))*vMqlnIu;FE>5YiXk zr#*prCp3{#AjN`%zniuwxn%WcF9tuex_K1f2OUv1`FrS9K|)ERoAzX@O!zFDaCec* zT0vgGN?>pSTy%G5yr!)mN=LPzhv@*p0(Y&M`+2y3pyZIn)TzV5XoL#vA7?bC?qB`O7rZ-lR#JVLfV*3kml;IT zEI+8q17l7~E3SQAB!CVABqXOj&h>{?f1>lpvaKG}?9-*>z)NTYyHzy#(y%257PIYn zcjuQSbirPB062=By+D_u+2Pe7uc)orodBXg?B?Jo z!dog2EnWj~2hS5K3v}PdUL?7hX4{gA3CZlP_np97*SaSo*|`2x?bb*$$4K#L%v+~? zxFtx41KR;c>aL;nd2~#&t~9WiK3L#W&1=$xb*h<9f~a8djrVeSUW+VjNZ4IlNyyn{ z-QHWS6_^MmX@+m;X+bpJ<7w=uh>%8M|MCWO99MbybLOo_f$-FLKel#=nc=l|?Nf;o}CkCa* zkyU`%PhQVe;kCq2vgSmumr}!cKRw`?QVgaN#SD;6AKhuD4V4^3B&Ruy!MO29orAMK zp`+T8-xZ+fv;R8HS5CHFLn-sK2KAPZuPj1p8AJw;ga0;}{<#|K*Bc4T43}vU6(#`z z*e+L^SP*jVA|J62PuRZ%8A@QSUUZ4D;)40h1H}R$-47ctYYW)cRO9V+QUJvU^>Jlu zAQ{*ncgwGSAhl7TQX>yNJakXK-cL2Vt6P5xTy7jQ?um1m43s8%tUPWWso<*Wy@JnEB)mQC zvjg2sOGSyXc^;l(8y3Llzx*N_zrEIhy(V+n@p_)Gpt!8i36>=Cx|#sU-FTl>$XA+kO~fuHEsRHuShjeacdxjr zZr{9U*|&4Zl(zfNEE+$4H{Pa(q`oGj*sVuVfRI(N4HWOruqwe7^!`SJ`wt-;I~%Cg zkwyIPWAF<=Lao;5hx4sxzSm0zZEgF|*BD9pOzk|K+huTNtd8DsT_9xugh9OI?l(fR zk;VbPL2hl8$(Z2)Ge*CfBf5>bYr%8`#~yH;fUM7EAX(0L4E7Smo6CxP-g0N=@1E(v zH3jYLSBC>K^<6ox(MtRf6^V5vrG=cx3`~2_2u`s*YEsE%e{>h0)+ z+Ndln;_ouSix%{KKt>;hCtjn%P|Qd8N3fhfMJtzL(&?J@E%Lb8KM~*8iT{P3RhkDO zI;_GsFd_n>O-W12fgI!;+jTR*7{3-+KwpXsFEwe+$X95N-^35Z>U+5{ni@Dwoc59x znMr~C=t}8~A(S%-8aUk_2q4Ba2+6g29yJ+U=7oarB4|1*bKXV&iFq!5>S6-70wJOp zY2@Z)uZzucqV8q?+zQmvo3QV1_=^+00g0G!a8Bi<=zoE|sgNVHCisCpsvAwUa+dgh z{Dw62bgZIn2!s{G%ocRMyg=TSC+nK*3~u+Ap%5@A*7w4A_IueqD0lXxt(mlFO(If* zs)t{L1Czg$?v>V>-|Gh*CoeBnD8LXIi8J=RQRgdCDWJPQ8R`Ei*d@k*YJEKQbl>v| zfIcHx_i->M_(m0T_jCOVTBbDVQ0^&RnYN=`n>vWe!)*F+|HSN*B>ob7; zkKO#-)zQ(b6i8}{ifyTX%CUUM(|FG>HTaOPwxZImM^`}jRQc5!#Qys;qy#8_f9c-i z40F{^`BKez(bWs$m-KVAJI&-+E}U~R_*0|(U^ckd8^g%+%9x(%b{KfJMg}itV8)KT zMs*hO&>~-AeB0Nm1_IQDIp|>TT;GP|D;}Wt(muRPUEBuG#|f`hkR|!8X*$Du%j6K{#jAp zr9@}Y61V_zyPiPrCZmJ}*(J$@9gP++>p{ocH=bYO%NVJ?L`9vqS6zIt>hh+`Zt%2Z z2K3SLFUys;K}C{mq#(zxRDE-|y#zH-y92Szd#h+|n~*3Pv22=6oso zZTWme0}IaW+Gy^@&P>0x`gK1U5I5E)4Y{ohqy?52e4T@Vn%;0Uaxx~<7))X8XTqO(F18J%{z$rdWfGEo1Ac(_T5jXgOD z&x7iH(Ge{@opZ@AQ+nU$GT2|zCmQPJFOZ}BO7E2st*??nIRj}?iJQXGkHue`FbKUi z#J=zT32IRHt!RP(MWkB`H+m*yP8(zF6-l;wT=sH$FYjAAs1@WN)b=SiD7F2PQ}Y63 zsi9d$6-L7`njeC^JjRzU-!1uD-^ba=HgA=-ydKmnu9Xlw-rc6^J3D=9Vbrh*1b%jr zm=>U(qGfFrKK|kG;=Psg79)0+!zn0QugN{w=YRBC6)ig(Rft_f07GP!rx4y%BC|g# zB+^CA%_#$(G~Y5t90K1s86PfsXyjX$=?@72paQ}MgUkO+w^X25OQ7)_Q)pyy*?Xl^ zq<_M;1puCEibJ(1A=!8aSPa6L{Ql~@8lfLVeeCffqZySHp;(vGL(YhRW)k$?N;cMQ zI=-x`X`5OCYJoz>rRaZ{Eu8Gl8)Sa28FoFEbAG4`ClB810>gVF7T`&jJ8Ts)MFp#d zT3mLg<3iGPi;r3>(w@Do`L4`$)dvDFwk;ovD$;QxQtU6Yrq4+b>6%PH5G~uSXod+U z!h<6{4_pOx%E6qvqIp`tO||;UzN#|L0&5TxT@L%37#+GG=$9NS0qa*kILRC4-#)86 zyE~Rx{HS93e$Y(e4%U)x-^_9<2Y4@bl3)jfzSnS7I5#@K>|8YR&Y-5f^qpUO(Ht)2 z$ky&c_mL9brA0yG$YA+i5JcmqeD=o51ZMHjhM%)P8T8LBDh2O)e!+d?-*~r+sSQVr zfpnE_3XQJ_@!5@K`iJiX3<*(oVA=}%%@`%{M0pa~B&aeLX^{%XL;5GQR32ZO> zW->jWs2jlTOLvZ{lWZw?<;G=oKE1mq6qDXKo@u!fSpE=G?fyKTW*52h>}!xxjzYcr zB#;;(NUN|!=|yFUrSh<=F0SNipHJ^yLVI9J5*jq~R$l6{5N|fJQLCi5>LP}ZX%HsH zVaxFZq~MCGFU&9|pre$*1$}}vB_!_vsHO9h zf-91zuzVe3(xU#K#}&tr@vzZ zH8e$+JtRZ%9;C`3M3f|zc2LDtbpS7lg&;zfiSX1zSI|f?1VUWl&SW%_0&CPO7oAQB zml4Ir%6GI9_!v=Kb=!GM%aR1s29^@S5NUB@Bz1&wT1Sv%SiwSNE6_|*SS*Ee;?0+Gb3p?Q^AHWk zqf>VgA==SIKW}EYV-J%?I0#JOdhXae8F6iQ9R`lP)@iJ8ZZ{{W9oknjKi diff --git a/deploy/windows/installer/swatch-bad.bmp b/deploy/windows/installer/swatch-bad.bmp deleted file mode 100644 index c3a747a8aefd4ba845dcffad3bd5be9d581168eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 822 ucmZ?rHDhJ~12Z700mK4O%*Y@C7H0s;AK`;whyVkFg4U5yG#UaUHUt1?PZ1UX diff --git a/deploy/windows/installer/swatch-ok.bmp b/deploy/windows/installer/swatch-ok.bmp deleted file mode 100644 index 16ac2a4be866bcf4526bc838438830cc6d6c6b16..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 822 ucmZ?rHDhJ~12Z700mK4O%*Y@C7H0s;AK`;whyVkFcfHgo8V!LF8v+0$t>*^- diff --git a/deploy/windows/installer/swatch-warn.bmp b/deploy/windows/installer/swatch-warn.bmp deleted file mode 100644 index 109b7746305c542f3a2d7eff03bf7de2eeba854a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 822 tcmZ?rHDhJ~12Z700mK4O%*Y@C7H0s;AK`;whyakBcWo4nhQNpo0RVAEcNPEu diff --git a/deploy/windows/installer/tests/test-install-state.ps1 b/deploy/windows/installer/tests/test-install-state.ps1 deleted file mode 100644 index c5e19dcf..00000000 --- a/deploy/windows/installer/tests/test-install-state.ps1 +++ /dev/null @@ -1,130 +0,0 @@ -# Tests the installer's durable install record. -# -# The record answers one question: "is this a re-run of MY install?". Getting it -# wrong is destructive in one direction - answering "first provisioning" for a -# server holding real data lets prune-schema --force loose on it - and a dead -# end in the other, which is what it was built to fix. -# -# Run directly: pwsh -File test-install-state.ps1 -# pytest runs it through tests/test_installer_state.py wherever pwsh exists. - -Set-StrictMode -Version 2.0 -$ErrorActionPreference = 'Stop' - -$Here = Split-Path -Parent $MyInvocation.MyCommand.Path -$Installer = Join-Path (Split-Path -Parent $Here) 'shopdb-install.ps1' -if (-not (Test-Path $Installer)) { throw "installer not found at $Installer" } - -$Sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ('shopdb-state-' + [System.Guid]::NewGuid().ToString('N')) - -# Load ONLY the state block, so the rest of the installer does not run. Bounded -# by the same markers the installer uses, and it fails loudly if either moves -# rather than silently testing nothing. -$lines = Get-Content $Installer -$start = ($lines | Select-String -Pattern '^\$script:StateFile ' | Select-Object -First 1) -$end = ($lines | Select-String -Pattern '^function Invoke-Native' | Select-Object -First 1) -if (-not $start -or -not $end) { throw 'could not locate the install-record block in shopdb-install.ps1' } -$block = $lines[($start.LineNumber - 1)..($end.LineNumber - 2)] -join "`n" -if ($block -notmatch 'function Test-FirstProvisioning') { throw 'extracted block does not contain the state functions' } - -# Stand-ins for the surrounding script. -$script:Created = New-Object System.Collections.ArrayList -function Write-Log { param($m, $l = 'INFO') } -function Protect-File { param([string] $Path, [switch] $Directory) } -function Test-SamePath { - param([string] $A, [string] $B) - if (-not $A) { return -not $B } - return ($A.TrimEnd('\', '/').ToLowerInvariant() -eq $B.TrimEnd('\', '/').ToLowerInvariant()) -} -if (-not $env:ProgramData) { $env:ProgramData = [System.IO.Path]::GetTempPath() } - -Invoke-Expression $block - -$script:Failures = 0 -function Check { - param([string] $Name, $Expected, $Actual) - if ($Expected -eq $Actual) { - Write-Host (" PASS " + $Name) - } else { - Write-Host (" FAIL {0}: expected [{1}], got [{2}]" -f $Name, $Expected, $Actual) - $script:Failures++ - } -} - -function Reset-Case { - param([switch] $Stamped) - if (Test-Path $Sandbox) { Remove-Item $Sandbox -Recurse -Force } - New-Item -ItemType Directory -Path $Sandbox -Force | Out-Null - $script:AppRoot = $Sandbox - $script:StateFile = Join-Path $Sandbox 'install-state.json' - $script:InstallState = $null - if ($Stamped) { Set-Content (Join-Path $Sandbox '.installed-version') '0.7.0' } -} - -Write-Host 'brand new server' -Reset-Case -Initialize-InstallState -Check 'first provisioning' $true (Test-FirstProvisioning) -Check 'record written' $true (Test-Path $script:StateFile) - -Write-Host 'retry of a failed first install' -$script:InstallState = $null -Initialize-InstallState -Check 'still first provisioning' $true (Test-FirstProvisioning) - -Write-Host 'genuine upgrade after a completed install' -Complete-InstallState -$script:InstallState = $null -Initialize-InstallState -Check 'no longer first provisioning' $false (Test-FirstProvisioning) - -Write-Host 'install predating the record - must take the safe side' -Reset-Case -Stamped -Initialize-InstallState -Check 'treated as established' $false (Test-FirstProvisioning) - -Write-Host 'record naming a different directory' -Reset-Case -Initialize-InstallState -$other = Get-Content $script:StateFile -Raw | ConvertFrom-Json -$other.approot = 'C:\somewhere-else' -($other | ConvertTo-Json -Depth 6) | Set-Content $script:StateFile -$script:InstallState = $null -Check 'foreign record rejected' $null (Get-InstallState) - -Write-Host 'corrupt record' -Reset-Case -Set-Content $script:StateFile '{ this is not json' -$script:InstallState = $null -Check 'corrupt record rejected' $null (Get-InstallState) -Reset-Case -Set-Content $script:StateFile '{ this is not json' -Initialize-InstallState -Check 'recovers and rewrites' $true ($null -ne $script:InstallState) - -Write-Host 'created items survive a crash' -Reset-Case -Initialize-InstallState -Track 'site' 'shopdb-flask' -Track 'apppool' 'shopdbflask' -Track 'site' 'shopdb-flask' -$script:InstallState = $null -Initialize-InstallState -Check 'site remembered' 'shopdb-flask' ((Get-CreatedItems 'site') -join ',') -Check 'apppool remembered' 'shopdbflask' ((Get-CreatedItems 'apppool') -join ',') -# A zero-length array returned from a function unrolls to $null, and $null.Count -# is fatal under StrictMode. This is the check that catches losing the comma. -Check 'unknown kind is an empty array' 0 ((Get-CreatedItems 'firewall')).Count - -Write-Host 'no record at all' -$script:InstallState = $null -Check 'empty list, not null' 0 ((Get-CreatedItems 'site')).Count -Check 'not first provisioning' $false (Test-FirstProvisioning) - -if (Test-Path $Sandbox) { Remove-Item $Sandbox -Recurse -Force } -if ($script:Failures -gt 0) { - Write-Host ("{0} check(s) failed" -f $script:Failures) - exit 1 -} -Write-Host 'all checks passed' -exit 0 diff --git a/deploy/windows/installer/verify_bundle_lock.py b/deploy/windows/installer/verify_bundle_lock.py deleted file mode 100644 index b8d036a3..00000000 --- a/deploy/windows/installer/verify_bundle_lock.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -"""Check a staged installer bundle against bundle-lock.json. - -Prints one line per problem and exits non-zero if there are any. Exits 0 only -when the bundle's third-party payload is EXACTLY what the lock describes: no -missing file, no unexpected extra file, no changed content. - -Why this exists alongside bundle-lock.ps1, which does the same job: - - - bundle-lock.ps1 is canonical. It runs at INSTALL time on the target server, - where PowerShell is the only thing guaranteed to be present - Python is not - installed until stage 2, and verifying the payload after running part of it - would defeat the purpose. - - This file lets the Linux builder (build-installer.sh) do the same check - without adding pwsh as a build dependency. - -The two are kept honest by tests/test_bundle_lock.py, which runs BOTH against -the same fixtures and fails if they disagree. - -Usage: verify_bundle_lock.py -""" -import hashlib -import json -import os -import re -import sys - -# Must match $script:BundlePayloads in bundle-lock.ps1. -PAYLOADS = [ - ('wheels', True, 'Python wheels for the offline install'), - ('python', True, 'the Python installer'), - ('httpplatformhandler', True, 'the IIS module that launches waitress'), - ('urlrewrite', False, 'IIS URL Rewrite, for the client-IP rule'), - ('mysqlclient', False, 'mysql/mysqldump, for backups against a remote database'), - ('vcredist', False, 'the Visual C++ runtime MySQL requires'), - ('mysql', False, 'MySQL, for the bundled-database option'), -] - - -def digest(path): - sha = hashlib.sha256() - with open(path, 'rb') as fh: - for chunk in iter(lambda: fh.read(1024 * 1024), b''): - sha.update(chunk) - return sha.hexdigest() - - -def payload_files(directory): - """Every file under the directory, keyed by forward-slashed relative path.""" - found = {} - if not os.path.isdir(directory): - return found - for root, _dirs, files in os.walk(directory): - for name in files: - full = os.path.join(root, name) - rel = os.path.relpath(full, directory).replace(os.sep, '/') - found[rel] = {'sha256': digest(full), 'size': os.path.getsize(full)} - return found - - -def normalize(name): - """PEP 427 wheel filename form: runs of non-alphanumerics become one _.""" - return re.sub(r'[^A-Za-z0-9.]+', '_', name).lower() - - -def requirement_pins(requirements_path): - """Every 'name==version' pinned in a lockfile, including marked-out ones. - - Markers are deliberately IGNORED. A requirement guarded by - sys_platform == 'win32' is exactly the case that must be present, because the - target is Windows and the wheelhouse is usually assembled somewhere else. - """ - pins = {} - with open(requirements_path) as fh: - for line in fh: - line = line.strip() - if not line or line.startswith('#'): - continue - match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', line) - if match: - pins[normalize(match.group(1))] = match.group(2) - return pins - - -def check_wheelhouse_covers_requirements(bundle_root): - """The lock records what IS in the wheelhouse, not what the app NEEDS. - - Without this, an incomplete wheelhouse gets locked and blessed, and the - install fails on an air-gapped server. That is not hypothetical: assembling - the wheelhouse on Linux silently omits colorama, a win32-only dependency of - click, because pip evaluates environment markers against the machine doing - the downloading rather than the machine being targeted. - """ - wheels = os.path.join(bundle_root, 'wheels') - requirements = os.path.join(bundle_root, 'app', 'requirements.txt') - if not os.path.isdir(wheels) or not os.path.exists(requirements): - return [] - have = os.listdir(wheels) - problems = [] - for name, version in sorted(requirement_pins(requirements).items()): - prefix = '%s-%s-' % (name, version) - if not any(f.lower().startswith(prefix) for f in have): - problems.append( - 'wheels/ has no wheel for %s==%s, which requirements.txt pins ' - '(a marked-out dependency still installs on Windows)' % (name, version)) - return problems - - -def verify(bundle_root, lock): - problems = [] - locked = lock.get('payloads') - if not locked: - return ['bundle-lock.json has no "payloads" section'] - - for name, required, what in PAYLOADS: - directory = os.path.join(bundle_root, name) - present = os.path.isdir(directory) - if name not in locked: - if present: - problems.append( - '%s/ is present but is not in bundle-lock.json - regenerate the lock' % name) - elif required: - problems.append( - '%s/ is required but is in neither the bundle nor the lock' % name) - continue - if not present: - if required or locked[name].get('required'): - problems.append('%s/ is in the lock but missing from the bundle (%s)' % (name, what)) - continue - - expected = locked[name].get('files', {}) - actual = payload_files(directory) - for rel, want in sorted(expected.items()): - got = actual.get(rel) - if got is None: - problems.append('%s/%s is in the lock but missing from the bundle' % (name, rel)) - elif got['sha256'] != want['sha256']: - problems.append( - '%s/%s does NOT match the lock (expected sha256 %s..., got %s...)' - % (name, rel, want['sha256'][:12], got['sha256'][:12])) - elif int(got['size']) != int(want['size']): - # Impossible for a matching sha256, so the lock was hand-edited. - problems.append( - '%s/%s size disagrees with the lock - the lock has been edited by hand' - % (name, rel)) - for rel in sorted(actual): - if rel not in expected: - problems.append( - '%s/%s is in the bundle but NOT in the lock (unexpected extra file)' - % (name, rel)) - problems.extend(check_wheelhouse_covers_requirements(bundle_root)) - return problems - - -def main(): - if len(sys.argv) != 3: - sys.exit('usage: verify_bundle_lock.py ') - bundle_root, lock_path = sys.argv[1], sys.argv[2] - if not os.path.exists(lock_path): - print('no bundle-lock.json at %s' % lock_path) - return 1 - with open(lock_path) as fh: - lock = json.load(fh) - problems = verify(bundle_root, lock) - for problem in problems: - print(problem) - return 1 if problems else 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/deploy/windows/installer/wizard-image.bmp b/deploy/windows/installer/wizard-image.bmp deleted file mode 100644 index 1c8fd44ea50bc17dfb35c040849b9d8d170c7a01..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 154542 zcmeI5cU+Xm*2lf?-xtsX788xhjpmxF<|g)DP(TI5px8_7Q4ysGDqX559Vt?ziZrE& z3ZmE*>|*b|_ZF4BKbT}3o&`i%blE+8PB!P6Ip@ro@0pqB+1-!OXK?zc|7%qK(*gf| zi~r*BUzdM1YV@y0O`$bvG#~xjG#gd?SJ8l%^HC&3fCvx)B0vO)fU*-9@`E-B5g-CY zfCvx)B0vO)01+SpM1Tko0U|&IhyW2F0z`la5CI}U1c(3;AOb{y2oM1xKm>>Y5l}b* z(;u`+hyW2F0z`la5CI}U1c(3;AOb{y2oQm~68KTulteuU4E<4?ga{A;B0vO)01+Sp zM1Tko0U|&I>WRQ_KWdW@0U|&IhyW2F0z`laC<}p~w0|Q}4+3UCX_F8EB0vO)01+Sp zM1Tk=34vihX_F8EB0vO)01+Sp%1)q-_AnClATYd*HVF|R0z`la5CI}U1c-o=5HN3} zO+o~S01+Sp4V6G!ZF3SLKm=5az=*cmBt(D+5CI}U1c-o=6ByZ6n}i4u0U|&IhyW2# zass2;YLgHFB0vO)01+SpN={&OTWu0`A<#~HGzk$P0;)w|Ogn87B0vO)01;@g1a!u< zBT)+iW83MFs0)Fgb;gnq0V1GU1jhZWLqY_I01+SpM1Tk=If3#2(;*=OM1Tko0U|&I zhyW2F0z}{g6PWNn9TIgR@Qcm_5+XnZhyW32m;@&NqC-LihyW2F0u7YF@4xDh5CI}U z1c*QbB{1n%9TFly1XPVcd!0!n>Oo*~dmR!YKm>>Y5on+UrnJ`~Ap%4|)d+OZnL?r- z1g3V-At3@pfCvzQ21;OB2OSb3Km=5cKu4WvB+p8Hu_O=pvgk^mFf6aN>dot2XCxMy>+c%m`1+OHCBEviatn_j)Xe9=O&hyXo{3CL<5n3b>T7E=f9?+*76bLTf}iC z(UkVGhC=hMwT6AB~uSM>%xmXMvgY4W*+sM0DnZuzq;Vg*9(6+nceZ+v!dH$@{@)6`)s+^Z3 ztg>D5g1%L$A#=`ZS;f9~HU8ewpjE}bHUg81J=GICkw=i!`dm?}-?;ABbos&d(Q{3G zM|Rwkyv%vfw`f`GH8a6(BzJ^@vjS$ey*^`&h%a%kHiwD5aGqc(zI9*AN04u+a$aJd zN~q82pz*F^(?cf8?!+Z*53X_;i}n=9@hJVtjS1OHENpw~It^@vExbO!`b^;}m%*)> zP18Pw68h2{D4T30t*gKp=wdq0KEy?B+sr&+t&b5OQg^ZZr2)|MSK>MkLZ``}I1 zw&=ySJ!J1H&K3LmUA?${9o<=+@EQ8ZpWRlXER1+?6;tY7Xfc9{!BMg4%YkR>C2`RJK^m;|gkY1C1+MY#r!@5Y0~TmP2j zIU{lYaBQ6NZyqy3{YH0`T*5eZuE~+C2==XIyp5S%FIhoI`L~=~BAuXfaUI-he`*lB z%<}ppdX5qF1$~<0lP;G*t%_Iq;iK2>F^hM`dHRj%BpM@Ts`CUG%bTHbt>^Sp1&RC9 zf-q?8ydh|bw>3MG{r9)1@D)Qz#)Was_IzlVRXHgZ;lsc38ojr&d`jQEWtQB1cY=?H z$+zs%y0d1S)zJLl`I1Xt^PhLcdpjHa$Evd?>vHH{{w?Psy=D>uH878|7_=?g&1GOq zk0IY5TC=>mTFaXSPUw#Hv)H(`>)>xnQv8$bNBWKJgipoYO}?}1t;-UqVO$v0Y|m?5 z-J~RQ{9u>7M(?fIbZvY*FP44igjduWq3nCfa(yjO(2apMi;ey(xs07_%WgRizmxt`gS%t9?4)m}6%2ktZ;#`0zfw!<>r zr?Qx7E)!8}7>oIA2(T7INydd^&Gvj~XTz^?k6;5S!>e^Cjk-&Q>!jcb-4G|yc39Lb zLntd|8srDs*miBqf=e!ahqc?2b&0xUx zwOhr$PR70-)3G>tnY;0~aLo3ZnY>_R&Er;SFF&+PMXkjgKCaI3)_n*`~8* z$tpj)?w=x*qh51taQ4k(oRz8M5(a2In!N(wTIL7MpWp2h+;iE^+8pMLb@q z9scW|pEJdq-*4hB{d60sEb6YQJ|eAk-RAD1r`xbZeF}}4Dz?@PT>~n&Lw8N}5lO8T zIB{1!UF8=A&R6+E)nBdo+?BcFJjpsL&V96_XAdlha4p@k_WJ1~PafQP_2-M%uU|cT z{NU!fV}~}b#&5cF>h+2IC^hi+HvZ~nOkp1aB1&{7rfBEP0moJ_@ zxOwHoj=U^iOET&>04ROgA$A3qerS5hC{y|HqxiD(a99P7fNomiHYd@3%m zVDRsKB#!d6mUl$UnZ1QUquX;|H$7b@)z-Rll*e~(;-5ZoFNG_PJv0>}*jAMjulnll z)$^EwIHJfO?}NlHACt=aI?|Q*;@1UNJ%G@Pt-5la33q`ajwKjMRg^nuYzNV?mbFHp z7tfxocAg;mLa$}5;r{aZv*2+Z-TLd+he)z|?@Lx|l(Hz!7mIU=X=|6h+8%~qM9(%n ze{j1I)E+udnl+zSeB1l$XXq}JvC8nK5aBKw)Q!LJ<0F)sSzcwaf!xCZwm`w^^^ z-o`BxY|ZXmJ|nvC%Z;m#LL;VA4^4&0+2YM{RzsY6YRO0QFlZ+7KfX0vw7D*PzJVZO z*OJQndQ+q;>n#X(7U8V(w-LQ|rUZ+UU4kgfHUuca^?8GH;!dxKE^G1@4m|S z>v$_u-UY>__a`M;F>ieT`U|Hq)*iQ!xTDsK`|EOvo~1AP-n)99cPUok`P82R6fd7Y z<7%*g9C$Se*;JxBlPww54tW2yK(J{%+oI6U2C8;&c zoO?C7Tv{x;ZwOgfv-_Jm4rq$UJnoC9wKrYDlBjzl&!?UguR&i)*8HU-yT!3Y{x zD0}@HEv&a@_sFTexfc|?2XybRTOZ=wQ&WMGWHVO7 z?<1Z)y6@7v3GWRY*-rE=h;U|Ik~7@H;B)Q*C3L*>4ClS#SmlePmlp4WvM!*~8J=u! z&U>ZBvNq*iqH_QGB{AWE5p7s+b?(SKZl3X-OQ%JzltrW52k6#^h^f{^cj?{u;QAyn zXL%i^#A1+)BJzhzy4xgfkYH^p2FY1Amvy1&VmoTYU7+k*UHRF}^>^MY87tA&jCVo7 zsq!XlS=kbkmb|yRVsQhe+&owMCc<^dvC^TZa^wd;{q&90q1SP<^yGx$sSu_XMTKDdj8?J+<%Y+@ID_QHceiJ5J{+-??7y9VBKx$DXJppvm3-nx=tHDqx=on=G6EzXSO0im4Sv5tE|krWGy zQ0(QpWM2o4Xv?}J?&3gP$1IX;&3GuLfP(@>68ya{!HuU41s_KMSN?K`xVc8&1HY&t ztc1T9MAYwJzv$Y#36FzcS|#>YslR^tXZ$>4?gFKjW8qyx$k_Ixqoi0>*4kr0Q*oJL zg`qa>{W+Ds`$)7gmDsrTX>$4K9?L+pNNv*?w?^2s7zel-i6Sg)sr+07Ulf@?kxyvlEKHc?nmKc2%J6nHSyf3yM z4EAP(OQJR0TH{0w8rgQO*NhuyDt{jS>Sg>qBXOG5)!Jupi#&fT@eId1bTo>-Z`EsG zbhlob3Xv64dWc(_*IlBZ){^1GTAwS~Ea_E}OG&XLlM*kn&eL5cNYbvo`|+Z+65m>0 zdJWKhtJE@7ZOoGAn>~Hox7ey&>$0-5dp9i~*Iu4gEo+TC{!){#<=t!FQggX$AE!2! zzD8f=hc3kS#A7f%BE$32qubZ;@ikg@r-tA+ScZ)LSrVs#?rS_ZVhiKOg#57?3nN`Z z$NVCh8o#Wr)D(opQ^Ui!MC zEbgsIqVfd1^>raCKTVn-5l}+{?!7fhhyW2F0z{yJ67cAwNkRmO01+SpM1Tk=IRVc; znj}Pk2&fu?zM7sS>OsJ(uO^Aw5YY1KOF{&Q01+Sp4U&L&Uo8?MKm=5cKtC;S67?Y9 z(@%?p2oM1xKm>>Y5m0ghOZsV%5CI~fY6SGOmXHtuBA{9XeD$n?G$>}h5(pS5JCq%@aoH>=>pUl)+p~dH7Qfu@`iXssGkc@`-jz3hrRdp%J8PXr zZVIy6mmcbGs9mqQQE){P7|{6WhQyS)`k4!dJ-m5Y{N;^*>t$sxo<5Scmz8Y}wN14( z!Xi;*GWBz0QHG4b&Nz>3w{hE6IElYcRa)69ugl7?yYsxK7DX&-=rFIJC|Tn$yrIvc z=KOIZE=di__p@Nd#Gea1loMT?;(x31pK?I&vxj%_9KIvg9rtwn9!qBb`0jPw(+{i( zkDb(^=7E&F7r!fb{gUZQn518SEk()6Eg4BOd%gOnb{Y1jD<^fpYoMXdzO*-gZ{=F) zo-nhoBg|W8IgN}O+a_*u$A>pAvT_#&+k_8mh4*E(Noo0S{|PZ}RvXJ&+Ju?43>>6e zvoUM}47DrS3}_ra{2Q?mwcfB~I&Az0G^u3SUn{`iQ%MUh(TE6s0~(86RW^zS@1qaU z@>7H)TlCu=?Q*Q(@4w)H->T}T-}uUrohahj-FX3qpI$n+6*_)pYwW~dR$Cde2R4{t zF{IlMVYSA?8q1)9p+i-M)(7n~f=8 z>9Y20*D=o@-NO?IR`{-XPu7T9Kj?o)u)&6z#FU|9b08`Cq5P>zSRoXLhb* z#&{ zb+Mq$j8W?gC7TeIN!*lRcwR%_%5FRUUMl%xhex^;zzT5V+jearIsSVb5Vy7CCN!#dAN$ejNi z%TK~Sx^$ojEo^IJp&rOwj$MOd<#|oUGDg?sL)&oz@INw`Fr$0Sgr5v0a6~_^? z!Jge7L219Tj52iAnKMiiRWI^S;q1b)M@>`a z4#-^i8+Tbbxg+*9j@IfXc>DaZlC&U(00JzGsKO6Vn<pC@;3qOKA+P%>Kg* z2L(t$BYtYg)U`gCQSLtu+!3;k+u452kz1I|+knvMHRXHzS>m4z!%nRt4C2GxD*z3Wy z;bMdU{U+r*Yw1ihV)?-)W_(-d0S2ESLzX-)arl(1_J@L6qdk0BOX$moehK>w`ytRF zSh2X1Lb-mt_-I}nGr$sxF#iTRYP~gLAr#1^{acRYt%Q z{XOQ3itmVVL%DIbV=tsx4`erVW{))hgF{)7*w@gjoEon+43cK{ge1=B#w<~5Je#pb zyhe@rK@tIVVUYJEdfz@*%6rf5T89!NnF9YgxOb=98leld#zPdgIi4|aiLxS9bRmEI zeLc1mag5+VMwzeyu^BN_SfbYO%5oZks-cuPxX`#M!1Bf82WuUNLlAabqyrkG#{7um z8cQq9X3+V)8*iN4&l*wdJ&8W6=jor`wiXM3)g4_*A^MF=HzWWr2=FQc8t&96F;L)n zb>q@MvYkiaodq6>qR0M(lM@Rbr{z=O{{HmAZ2-YEaldA(U4C}ukG_fC^6hA;`3#3a zw)04E4mAAq#)*Bn>F^-oW?vu7ia@!s4YAi)d#>9!+~U`{PsAe=TCfOl{-YeYv#(h+ zjM-qlm2ODHd$)`GH)k#w${Mi%aJZG%?!>;Gv5RqTpdEuEZ5-f`y~#`YR>h+ro;LBu z1YWTd|HmSL*ty+#H%=WmzA1$_vMyzK#eTt&m^h<5uQfIiE|dP~v2s#-Fe!h>VbTn@ zMHm$L%?5$;n`Tu%*b!{^RXHy_(1HN(!fB6NKU)}Ytu-DMu#d3hunzgnmxoe$B>*=p zMM%l&fcPmL!-lp1MpnRW5eu<*wuadkESt4IEjVWEPbW5|U>BelT{tkK$NY$;E~z%& zgCx)FiT3KULO^_)f#We^c&ikPet7$XeZh=T>&pkXX3RHcvf8r0d=MDlyL<|VNuYic z`Cb)o(bo!4grr#N?TB_pD4c|>e3uSv*`45t(`osTW>4?m%CsMHATyM8;cZCNnD6hL zKZdn|`=(g#L)nq=O8TRFby)=s{Iob_8GApDb#<|1Q}oy$nK5cDhQjA&@4z3Bqy+Gl)Q9y+g*puLu>ozvaaX3C<;1#Tmg44J#Xx@77Dce^#Dm*I+ zST6(&8sp<%oXq7Xxm>yNmP)=6D*+!vrP&Njw;gmiH@aT3Qp5v^3M3Y&uccTCG15J< zE+&8Jj4X#?_?(JwYJ9v9WcbOdSv{|n?#8K(c0911D$2-q9)b44fH`<_3m&Ac*kmZ$ z2Ov>Y5g-B$lt7Ta7KyqLFwhDj zAp%5z2oM1xKm>>Y5g-CYfCvx)B0vP{Mj+Tgi-ZUe0U|&IhyW2F0z`la5CI}U1c(3; zAOb{y2oM1xKm>>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2F0-QjIVPg_CA@KjX C_HPmZ diff --git a/deploy/windows/installer/wizard-image@125.bmp b/deploy/windows/installer/wizard-image@125.bmp deleted file mode 100644 index a3b9c771ca66df25f330eaea10ea5af088f8d7f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 222390 zcmeI5cUV-%_y6;Ke*b<16JhbANj@eqQ4?d-SfWv>DvAX~z}|Z=D2Pb!ReJBe7ZI>f z6cojZz4w|Jqlqz{{N6Ch?D$09K0GYt|V zKm>>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2F0z`la5CI}U1c(3;AOb{y2oM1x zKm>>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&IT8Y5a&ooGg01+SpM1Tko0U|&IhyW2F z0z`la5CI}U1c(3;AOb{y2q>OFUyZ3GM4+VvO!{h&5CI}U1c(3;AOb{y2oM1xKm>>Y z5g-CYfCvx)B0vOW5}4LkgMp0yDnQARz)ofCvx)B0vO)01+SpM4*WTW`3bTLIj8a5g-CYfCvx)B0vO)Kobef z`a*+*2oM1xKm>>Y5g-CYfCvzQCK8zag$4-`AOb|7eF*f|m`y?ihyW324+3-gYmg8D zB0vO)01;3@0-AIBlMn$SKm^)>z})_tBt)R)1O{l%B_RStfCvx)BA{vn<_*v!Ap%5z z2oM1xKm>?Dhaxb4fF=nMXgPs_n)6AB01+SpM1Tko0U|&IhyW2F0z`la5CP>TuwbAj z2@z;HfiE=|kPrbPKm>>Y5l}S(3%}GPAp%5z2oM1xKm>?Dha#|OkR}NcAOb{y2oM1x zKm>Y5g-CYfCzLb0?UVJk`MtRKm^){z);QQBt)R4 z1Xc{yBq0K?K|r!%Cp0_%oLNQeLtAOb{y2oM1x(4h#dA1)yw0xc)-wPZaB5g-CY zpgjm|_*z0j1c(3;AOb`{&wq!}X>&C(8~_fX9rq(!t5l^qWFp z-QfSom(3$3Bt)Pw0`h}LSlgHfD(}bygb~sdy(6=txl0pRKcrI&WXmWC2@wE+76cD( zLI8;X4~ExK2xEsOuwh6iMTzAo$r~UScPDt54tN75(l%32RPb;=Z8PlF>xJT{S#0^b zD^_XN7?jGG;-^{5v0-Q@1xk6iZS=Z{%%S6ij}OMVUMo*OogWspc7mn0gh~AZnGN8V zTnS%kc%eA%c2#bYl{pH9W|{U2iv2b+O9!K#`+~P#+MNg~@wc!W{~`APWt&0wT$iQr zbG0 znJ&|t%tF`;hs2tz=;y|vofIgDpO|$M;c(M1^;5YaIZjKqYHRt=8-Bea!)L}ID{Tn` z=se8^FkSe0Ji|Y3;}k0$Eorl?hWJ7GRvoRQX}&u>S34MeWIz7H_>EJNEX{>Z!j_Lt zx1H-W@e{kTJt`t>3%%B&R@mdeu5`QB=7r_Et*{;YzmUbce1tGQo*Cd`+{a~dA1GN4 z3x)onBYqMrrn17a&~2&KGof#l$#Y$fDw(c)XAMDyvDW)9Yr7|4 zbAiVyW*o87kQIin7*m@Y%5?rIGk#=xlUd082#3TytD@3M@e{mA`(#!Scb;Z5dvA~x zy97Efa~b&uf$_@tL3+7;Fz;4n&aJ9k>~zqp6Wm4EW7khUoa9xL>Vv4WF?biX!o}jD zh}bxl6U^J99Gej{_W37_y{CVP7~?ifkr_WSy~!+OP8x$C>Pz`AH({?)oK%F{#;%(P zhn@}jXY=sR%z265#ypp$OutwXA29E0=ps1M@e{aUB(p*8!kz`)d-^~uDE0+bo@8mZ zH_%e($IE=+xx#422|a~{SS)E*jU@*dw@v;DS)wZ(q>AcnO%?UYha7E+Gg0!MG4MtNSoGr zfA`C3tKwYvIPny~&(Elp=}l%K8&bN>9Nc1;_a-yXD*CzUt4<1(vM`(a{i{Vj&lkn| z&Khi`BVmGd^u#ggpUc-#6zB@WD37qMfBtG1SXo%Z^Oqt9`8pG9$F>$!Ae;EvJd& z_z#bz`QiB}#mZccSyQU8&EYXkAz&37KWGVI7h+B~e2DvGtPghUw#Y#G)`b!D5bqz%M#THnKo z9(cicGCL@AiB22zs6t(+Ztw_4-m1Wpw@eG1KiuB%{e(@%*DEqzCx6na-Kmyy;VP)Q z-Uj3k#k(eLHfe*7RHX|A#!OX=Q=ZEbp~LWHdU%avqobMQxbQ-8?A6kgif|iSgKpNN zx`;|xEBi6;?hV|6*ENt5zYVZNdH7P@3q`T@-z&42Ds&DV&ON5|VTGl^R?Gn8lPyj0 z|evA^bSeva2{QFEF!1Qc~1tmK+$}#qxF%U0&@Pn9xvCidTHhIoV zVC+1xXKBdROM4O_djc$+j6Rh1T#mQXTg_QHA5v2q@*zd7`ujU|+z<1nJfD`sIw?w` zR*u1U0y(O5oS~9u~NDOO{|8i9Zm7x>9*W{bay+0 zZgJ}-Bw0*B`SEnWxV1)>!_~N3uCVxFmn?pmp15)H{s`M0vj*X(OJ1g*JB)oVWBY8p z?h9S4ZKErJAbxzO4`6zZ!-CyAHa2E#tKaqP&d6M+MUIB=N3Ixsvofc^ZTWURtz&7v zyS!GrO!&yz=)**dDXCWG(w~Zq=*Vr-C-@m? zz?`9Wqq~DVzSkiqe!NZlAw5bPqvZ0Q#O?ZB+1gb`J0N~`%p7#FBu?hC3L{fGewx^j z#}B?!=RbR}5{3;QlI8GD3X~Gx_2r?~%wgJA^Ani?OpjSJ?r@@eO|n;}?d)yZT~H`p z61GGeAt(H97!Dh-#O75WKeEPIA3sz3u&UA!tEA0SnVz_5GS&`y;wEE!lljJhEEdcm zi$>b$Ng(yBJYxXUa~$jQ5A+l(Q+y%h=F7%WtHzwn3S^e}EcRVj;J!R^rTA-Xm>shb zdj30p8hT{;r-=>ukno)r|2czs?@Fjx4adfzF!7)DCB8_!T~`x6kEQ#iTA4!eGWqme zVU*+8cX>zBC8+YA_8HQ}))DH=44cWme8>cd7Jgyt|x(Dl@Uu!FK}I20_LK5;jZnG-0Mt!Sm>gWD3>2U z)$y*5hVQvg{`m6VB%F}338MLtB#(6K>3l9W+Fg=18K21yXTgb8z$zb3@^GKh+ii0F zm(!5A_|iCx?T)ZNyEB4?2R@-A44lTjk6eWF58`LLe*M?@k&B#-dia>ve~m42g+B98 z?>W)kZSu$7W}oBqbviF>$E+`5R+ECb~;%}m^Vdxy=BeBvQ{XelejdUGNpH8`2BfuPX+F@A zC2aL1u)*0I&2Q|_Lb;FW=e#)@VtZv@POAvpW6VMtGD!1j=uS&0RPo zVB~YhXn7Y(rEsK*Qe2EaxUoMI03(;{E5xUq{G$;6O0^uK!6<+J**d(F!UYK&CmEYj zZ!5$ziywu!lH&yVN(qIqzDN#i(yALw*I;kNn-@; z^d%(n2{eX|$~gh~06Tasr$mZ;0v$X~8k@d+XjCgrpfQpivwYjvok&_hz+PWM0s`Ac zbRuan0eb^U2bUJ3qlTP7M-CyI5uHdBMZm#8($Pdw=*SNz(4j(z&zcm)66hdNWHUmI zr1c0m7-+rOvVDXaNgEJwG|+k@W&21qk~SmYI9lrslQu&~9dQD0QV7|OR8y4PjCrfg zL{VdIM?X%ZwcY?xAiy>Y9(1I@VYj<0m5w3>(xdo!9hfQ_MXxhuYO8PpDi=j|Bh^Th zkbv`OEtL?vQEDVY0;N$-e+mBGI$bTSZDZYejFys18v{}P?vyHi7%pWFA?07tHXfl8 z#*Z>bk^Lw&5+Q-Im-nyRr>ljv%AmW9(NZ2!1|Ui`2IY?*hD%98NU7FN83v;&@uMVB z(?;qU40MP%7XWu=#b{3<;Z*yu^@9J zg(Wgwap(0opFjKV&ecl?i%P@ogJutO9i!Fu;-IajSjlr+@$Icke428UyXTJu&mAn? zyEuN%REum9H%i5i-a9YZ=&0`r+4kGdKgsF++nX07m+8FJssV*`@7~5A-8p+i+SMPw z{kqe8t;j?CIvMdJ-4-QvfBuQ3k6xiK^1v^f?h4-i{Qb8>X#sBIy19S5Rc|iuln{KfNbiW_O}OFi)|ZCUP$7{r;tsch1#_8VWD7 zofZi_G;7h}2dQaaxUGYZy3h&Jo9hbRYD-ekw&TPPrhK`&M1)SXg14KQfA)si3fK3q zy1D}iUcM&1oeeaQaIkv{YmuXNn0+Q3fT;I+ygdQqlUAYJ8MaPs;>!jP+YvTW+m+O^BJOB9jfv`E#i*{_{ zPMFe|tNL0Ub?-?Z;MY7px_(aN>TqVT(2v_#tyUFbUkcHWWQO2w^)u<+m^a)YqABC| zPYR7iE7+{~!LKEM$$4F!@6=C(&Y_F^<7n-y--X+VYT-?KE~odT346JJ=@f4+Z_)p` zRdF{-@A4Ri*Tf#&50s|33NY_090_!ssf13r-bzu#{kNIoXVObF<$Sjl!rnzI*sS=e z$qo}b$Agm4Gt(0`Oc2^Yk61i{n~8dV`tS~~;!2Un-xFj%5#Oz z@0>X-^vv|oc|(OZ(6PyKGg0sN&L87dT*=%%TjYUn!A)FrxC&o5T;v3s5$^&@Q(XC* zeI{~pD0PR>iA-IAQRJkf*4pAaS~F&aUWvcOxw7n^e|##MFD^sp4Hk}}Jo=^c&x6aS zgtgF|;-_(S}P+HXYXpSrs6g~LR>G;4ToG5A@ zxnu-46ZQV|(H&mJ75rs+p$F_=d;szmUFudnxhr1i2YS>pZQhHtE0MAEWleS{Z{Bp} zb)u;0y)?^mPp~x(gwhifmEc({cvgRrXVD6xN$~7}LYo|?mk!Bz6U%EwRd~4DVbY7& zGNmHMSvU-g)5V|*w~_AM-?TTcWs2zhEX}#38h_$jD?qY7may| z#@VdPmPJvsx@e;D;fx^R)MV;d^i#!2zEgTi`;=b6Q$>kFv+6W|q0b*4-{-Yn6W;sf zr}2v=X@54i^z2>nAx{P0?lf}g~RjANGTa2sh?B4g3z`SKiRU3K34brg@W zT1}MK=|yJS6FXx?^8#?(9f=!^yeGadvx%rsw1W39p166kQsndB%euPwwT3Tw&{aS4 z^81;8jbGLfhiR76UTUkOChq;x$-AB$A)n4PfU#Tt; zy5)LVq`eG>y6Ss_tzY@`)97W|(ru-CM|!~+c#mB9qK;7hSp*3`LDinoxQT6gO1>TM9k8uv0H^^hG#C^0zfLZT!Ycm{% z@uf0^8D0%Z58#cAA6Nai{fs||TcuwS?TF+lyv3Ma;=f6RLQed+8N36emnwIP{9M?d zFLKg)mn@1D?muL9f9x-!c{O&~5#pz@xeLpW7eq@hgmnA}o5<7)+?GlEQMrTJ+nsRf%g28=FhBTwOl6%XiZIjk!|Aa=*zv_5^OZe5eTT41ay`90wsh zP-E-Aci|XbA>tU2wq=TsQFrO7y?OD2m+kn<*^8%-@pX4R#MKrj?e@0_n=?Rq?!po| z@dKE6qI~+`79L0sCV9s!9Vu+s!F836pANTndTOpDM4+Vv-1O8)hyW2F0z`la5CJ04 zp$NF^sgV!?B0vO)01;3<0{Uw1Bt)R41U&TBNQeLtAOh_~K;1*1ga{A;B0vO)fQk|D z)K@1V0z`lav=0FTbx#r^Km>?Ddl2w4P$wY*M1Tko0U|&IhyW2F0z{yR1iTH@Nr(Uu zAOh_}V6?h72@xOyM1Tko0aYX5Gg_U52oM1xKm>>Y5g-CYfCvzQCKB)+txiG&hyW32 z9|B|4eMyJ_5g-CYfC#7>0lzWoBt(D+5P^9d7<{ z8tU|78EB67V0_r83Iyo2#dHxh(J-+c{`*xA?_Fl2?w5nBFU7 z>i-ZtXLl#zZ>c;zjtkiVP_nZPUeOx=X@14DvBRQ^7O8lJez43 zO4E6eRD@cFnSOvs_~vSD)@CC_$b$<1;{Dh0-)QDRNzi(>rU0z^;n66G{By!Q3Zw)Cx;jRs6y) zQJ%bV1iQ?&o_ZoDb{{wFsvOvyYI1Ip7m5zAvO?lEKqT8B9?gkAaZcC{2gY3@f=O_0gBkUpQAa3{pivOOs!s5ZaK3n zHhKAQ+;o^b|M$o5zXC4;fIZ0n`urL8ltXb&FhEwp+yMkPDW--E4uzKwOCzx?1DwbqfIAL%uF&9WRY9}jrzXV{Jx0&!!<|IU=!J!-Er>u#^vvF|$zkk)$fj0a1 z{JRGk>qm!~{O{_)LX<$yw=so+T^@7!oZJuPRDL+JP0GrVSQLO|ZLv+D3WZ2( z!rUT)5Hk6{us>ZGEi7+t{NOABCsz;dJeKaq8k%IuzrA?QVKyaxa0C85&dVopJV09^8RgUwKL{J_N*Eq)D?VSY;M|CVC6)=f=*MsG zVRaC1Nz1-M3W5!m24sZqh;V;pHXJW;vMR7O$Kj!|iO0_C2M^fyVGwHE9rr%u*@K&+ z{R>|gVO8uCi9+NM$1~&#R*t-5nI$p)SU!&&iEUMR~Fcvypia~%$o zpo)ijL=@Ib*yLha2Ak}$j2%snpYUlt_^roJ-R%5~rx;!Z1fBl6|Tp}v)z=-Sz9f6Gv9dG8D*>|@tAPq9T{;Bq_zi{xz))S40s?q7-N5{;ZfeiAe!PxXJqf1*(;i+kz)bf+lES$P&pLmcqGX!ckATF1{^!L;q6Vvj>Zfy065(1v!XErUXJC!FmQ=&;{Lg6UJfO2 z+}r#7R$r;iyLG%2i8N+*A65(HOi^sw8XeptC})L5F7qz$&&K%;Z!i)TeaZbKFCTV( zchcS3O86;sn90gv01ufP$DGO!Llw4B+=jeK#(D$1A!JRkmGg2aMb2|CRb<^cwg24i z#FQ1oc@=IeDONW*RZQ`qs8#Z=s-x9ZlVJ7c^38DxyDhpU2 zYW!ckSAiMs9ZU-7aMSTtA|=<_81IE5r}u1Z`f!W=5i24zKoe{bh))zYJ$}mk*I~?< z1wIc(+X)?#-l_x;B*-TbW*_XdGv@(-AVH@%8(>qb2-%DSXW;mEu+V91w67hif1E#_ z6@aXjXFVlh(Lf|^-jFN5JbxNC^l+O z=jPEp7yzP3ey3EcKmuV?y0cY|FztbxmjwVe;?ODG@NN(rP}Gc%@yxZ?dwJ5b`n#JM z;QWIz;3$ZV4~GHaiz8-mwm1%18e5qBp{A}Lh2uBeB4%)6L`TR-1-53a6^kE46#wA` zlwFRqWcp;=D1K5Pvz7{5j{vqXtQ7p?RWMt9d}1~6IEgo+2&5y)?#M*}<2vKKh@1Nvl25|I0fqL{@l4dLuz@j}9832NoupNpAATstzV*gQo|TqB z(&9llHs@?H%Ck0>ez8-N?4G=A=*6;szoy7y43FxFnWQCyLnn8|3r3uQm3D$Ou+}CJ zY1-pv&F;$ZtueEDohgjMlN3)xctON^PhYDG1H63RAF?@X>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2#G=bo8 z>Lf&f2oM1xKm>>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2F0z`la5CI}U1c(3; zAOb{y2oM1xKm>>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2F0z`ll2pQj*gb1{l G!2bt;>4TL3 diff --git a/deploy/windows/installer/wizard-image@250.bmp b/deploy/windows/installer/wizard-image@250.bmp deleted file mode 100644 index f074efc667352587d1b9b0767a7dc0a4347d78f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 889398 zcmeIbb(|eV`iIN?-23N`BtSC37M5L>g~eTh#a&2;C_ocG~~<|DJZ*|8v^exxuHMruZGDPRskx1e|u-hm&v+fB*y_009U<00Izz00bZa z0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009Ue zQVHC9MOhpKAOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$## zAOHafKmY;|fB*y_009U<00Izz00bZa0SKfbfh)`2i-Q0JAOHafKmY;|fB*y_0D>00Izz00bZa0SG_<0uX=z1Rwwb z2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uU$`1Rl7$EDiz?fB*y_ z009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009WZMBu@z z%i00bZa0SG_<0uX=z1d>jm#Wm${5P$##AOHafKmY;|fB*y_P#6TRE!P4E z0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_0D+Q1;Gt{F;UEA32tWV=5P$## zAOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwx`VoKoQYs=vv009U<00Izz zKzb3luH3^o2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb z2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX>eQ4@INx^g%OKmY00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmYCC-Ceo88`?)00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*yv zNZ`3!GH?)p00bZa0SG_<0uX=z1Rzit1a8fE4hI1UKmY;|fB*y_009U<00Izz00baV zatXA$H3J6$2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX>e0SUCe zH3J6$2tWV=5P$##(u=@t8Le>;fB*y_009U<00Izz00bZa0SG_<0wtHg^S5Q-AOHaf zKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5GWvl7jDbIK>z{}fB*y_ z0D<%(P$A<590VW$0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKp@csUaXLTg8&2|009U< z00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafK%jsGUaF9Rg8&2|P&5Q?&v*$3 z0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_0D+Q1;N{yha1ek11Rwwb2tWV= z5P$##AOHafKmY;|fB*y_0D*)PXm!K?$t65nGK2sGAP|v2>l^;hb!~3Xz(D{45GWi1 zt#AB4ziV?x1`YxcfIzYd_>GSZNp{iX4*>|IIRR^J!gM}oZ0b*Cr<}* zh5!W8lYrmO1c#(2C29=;2qc3*u(b&nn+#3J69N!OHv-S!_z{}h?_v% z#>Y(8X@I7fxp6WvY0)=mOJSB1qAOL~1CE&3JMRl3B{HZtu(wRU}8Jv*i zrt^(R<+>kOF#6-2OD43coytn0TX7^1(hd}PU~wc_f_29Jl;E3Nv{ifYrqff_wYxI| zmkI=o{h8RRb}A@?4kd{|${HW5SV^K?OzVREDW*3r_0_iBJjFe!PqGASi2W(SH@9f3 zmf~h7yLDZyeD0Yw#Oi5rb-4TNsV_Hrb;h7~Hq82P+rndOrfweBWkib#R=|4V?mn4M zRN6JI|MB%RKiaWaY8+lN*5nzG;YZsSNpe8rYi;SqGq~9eYlgpkc{teAh!u#=f_C?g!fzytN{G!o_nWU`*DqVTs?W_!Y;S5FC} z6b?Y-DV@v0Az=G;YI^+k$LQI)>IJeqYPWv3pvPk#bn&&iA@|>8%#JN0PWy9L`6d0H z`8dyl*{rNb_R6EJ|8jeFFDviy%dK znl7>UTHT64t(Dupxg+{Dyi$+XC^Pi;^!eQ%F9*J_R3bp^{V?8b3ppU8FKCKF28e1U-gJXi?d9K`Fpa}<%zjb0sb9R`Y5a10fsMC zK!zuJdXsBSs-AT(F=K`WJsUwd#^I}o!X{h#JWgE+UCx`PUV^PRQ~)cKRhP&ViFEOXE zCinI$+~(FYS{haC*r%#mjPbR)*JdTa{%Flv%BYiCWeVcyR{f8ePgGpc<58_$o3$w| zx!Vi_s_C(cMys7u``WbDBRd%m;JmIateEwf?*GQJ@iru!Cwlu6lX@`;2OtWkHsMJn z;P1jI6%x|^Y#5Wf^r40qQq|+@W++w(Ew}huT|(^7#;i_KCfF_L{)kmIoJR{j+b8uh zX2j&Cy)gCtj+M@_Ndl_ru|J#gv|2x^olWa{wBHvJ;FK2|xMIeyQKf{nBj%6tghX$D zVp1I8bW3j?>ojlqIGcMmQ&F#`WSg~GpFL`Ib2)Py98Kos^ZwTPed=EpJ-~?eM-x3+IX6SSA*Iqk+_peF>?XWN z?LX_Zp#n1>ujoq3uQh5(YAh5{D>L3(Ik>ea2hXMHT+wKy#d))Ktr?t~Nr$`3@0!|A z+G+#Q4L3P!GQbmb4^jeiub1bY<$TSjH0IIy zVTErP)4>%E>7V|}y^_?yX?c`Z#!Y{r(B4E>-`a`sz54DNg5qT=|gx=sc|^b9m`klf*RR6@yy6n|A`ol~IV@5I>Rs-Q^|e%2@y2IYYZvyC9qz zHeAMG-`wFgmFscWkYmzH21FA&H!JIxMY2U3kI6aj{B!PD&UIR>mf`ZqdoJg6e$Y)~ z{W4{e^zXkbFede)7d}yuAupIgsnO)k6%#!Qb6IEvJY6+)E`#ll^lWo$=}E2YXufcK z{fxZVktS=KdvMe1{8cdaXWY|Od)4`i=KgQ3n5b8%^aM1Le$-LgA8q~gu79c4ay1Ol zOzQ1*(>IUp;x#_`XOMK-S$kndQZ`B}KdPXerulX7gmkWQ-rUa3_st!qv?}Y!%1N)z z7^rpB?lmv6lBRP-uT_VNXHR|U9&P2nvtg!rpG*1=Z+@%I(0Xjns+|UG8U;y*-1onR zw+XotZW9_$9QH>EqgO9_VOtT++SZMHMeF&h$Q$9GM4pOpnQ#LB-kMS&vM_o`>HX7W zYvl(UasCzSfkuB3i4C!xE3w%oDmVN7g(t(1;~$R`%ej7T+AA?3hI**0;fBvOn5+$$FwvyF1P*G&OWX zu|J7z)mkPpw{pU>dV}?Rn>qH!qDVu}%eS1K;?8WTtLYl2*S-ozVJ)zzMD5zNzgus% zp5ji-bXwQ;gct2)YHH;&**#-`y^kJ`mSS8<{Aog<%`K;=xa)XVxg~v`)N-*Fjn!yn ze+pUQ4tMMGR;Rev^L`H^yLD|(c+p;_rdBSKZq+Z)nE~xQYSq_oe_ToYX+ofo`*QIu zE=}44)r3G{5-7d~p{O)3%)3Kb5GdIMimDeE_i{^itDtreC>#RC-3}D3{)KaoC zSpw0(M+O3EPQdmTJjo!C=B?&jw@sW9V=$wAOHaoDCDk3ah5;{YkpZz+`TEQ=Sg*L(4*ioyplG`urCS2YyTyf& zbMLwr@0`*nyH(AB_g>Mp>bX5?{&DP6mG;le{bs(#LJBMd9VntW60q$bPbv^7j!h_> z&RwgXrypXI30gV0RX7>K)0{vmbUJkK6DS$Ya_UR&bAOxfgRKj?R4U)K%DIzU*V;U`^P$C=`n^AWFY(Rg6E}=*JM6ye z-5jD6Gf4*Bd&TOZ&mUegR=*u4_vW(kyQcP?+v)y(^)7Mqlz+@4w<}6N29)~?@A?VR zC0QN;&!zcQpb5`Z^@NXqsPm=0-ntreCh`kW=)z4{-P^m!upGnHEx=m&j-V%$@e z2RFG|C2AGO&DQvb-G9T{5id*aJh1ciQN&lL518Mr#h}Jlxx(Re&VH_@a@H@hxx&UD zl2Y`AhAUAygruZ!O*VH=@2^^$TOU#@qX2(B1U!vDbuLFXzgZ1TZuBD+0<&~PP6tC03*_=7ijZawzLy@SDiw-cq7|F%*Z{HXPA8P_?SV#Dsg-Zbwq zkKQhswZmUtGwj8z$M5P_|F50yF4ytSvj#T0eErB*B%_wov&MxcpD4xVcWrJm&mBSC ze|_h>%XPTpjNY{`8vekIi+VhwR_bcCVn9m?@0&BMZ@s^?uTaK~3g?ez{Ntw8ee~O( z?B{CaD!F6v!XA(Gt$&F|EV6|9T((wyjq-8ZpO|vqI-#qIdw=uXne7_)s(q1`phq;n z*)@z>k+$tn!!x7Xrp%6`9=@%6jSD2-zu{$i&`5L7WG$REh}Hf?Uud`zrGH5Kvua3d zRcHUa5d#}v*`@N?wwidNA<*vD(^J-|o9oW%N8Wo>|3gS6jcw5v>DyN*9a2Dn?Qbik zS&8z=eDwBkGJdh1H7?LRLa7#XYY~vjM(vo?OTx3;H?biRJ;we_e5R^L(#*dOFUj(R z>+;~j+~r)&zdBdUFiz%0Au8R57|*z;Dk)q}0yfI_EE{4yre_UpcC8JGK4&w@Qdv`8 zsAH4M)2-^cZ>`8SYV@r6M-$br{v`&i=8UdzIDa^uC*{w-{0Xgg;=BK2< zT~qp~j`KP{V2i^OJpq3sPo0pyVNR8pCT;&N>-%JY&O@7CV}|+z8eAH^*o-y}mH)BT zQ#w>A6OhVAnP>gf7wX!Oh@QCZk1<*17ONLaPpzEz@3`6ij@$l3pYyyf4@d_s;AjcX zqsFZEP0Vew_Gfm7rnz9(Ozm3vY>#lr%W&2pCi@dpq2WrDdLix4o>_xcqZzL>45>eX z!2}|9lcE*aGjot>zyW^;HvF4M$*z^kk9nlRg03xgPwi{w=9{xRdSrANAhu@c^D1%U z=yv{8Awyo9nfqg5g=v3`8lxV%)sWG|Q!8eg9(JvAj=AGn6H`>&_9yzB*NteSg0_zD z>ThYI9=gS-XYG&KR?talzi`NqaMmCu`x8^4;YyTx*q@O1s33tMdsFHJPJ6MQX}|;S z@s3_zoz`Dbns>X}$`);(*u%6FlTf2^O^Z6*e#$T=qS(eU?M?E4zw^61Xp1f?`y&N3 z{0mSrZu_G~wPL`t(d4{&Y$u6nm(br12QP|33I{vQyKPqCtU*lnC#FKf zl_-_4KlZ*P%G2()(^J`LNN<)>7j}Qh)w4&9KbV2dtakU>@SZg-2L|>E3>Vn z^_*P)$=4^QLc^7){6kXOGdq9&m0z6I1W!Z)o|c?~mm4#4ha$P_u}-I(*|w1awKUzm z>bW*t$8r>?lP9^O9Y@&Xk49{2-ndIkD4w+D(oFd$wW?ub8TqC#GJ zaz45KQ@OlH-YmQ*=briYXGY$QlkN3Ln#;X&hDvzqOZ9B5(Ro?_r)`Mwn3W|_>R$bP zPt@@|N)uXEwc#-p8Y?C2kL4VPr~Pf(AxdedeJj&D1~vMdRn@EuZJW@|hRCxj@3hIv zfi2xYzdstW9@Wk>PyEbBZu8_Yu;FF7KOekfai7{3*(hUwroC9#hD7w3@sCVOw<_nj z!W&1oHT7N4^+7j$K!d-Ua4F!1hxGTXe!h;wNq)qGH+HF%VVvn%Iw9Go?w@U-Jlar` zq$;HyD>po(zi+*ZJvk3)dX-UZ+DrA^B;Bi>cVyKh6Vx_^FuOYYf#aZgrs zh2N#F%=psrM-FMpP8>Tw8i0x zoM7ZjSEKH zfBo`)Piv9HN=7NBpdOxPs@-YwJs$SOV&NHRt z_@^qGnVQCLZc8vdOPOdsrQ<#Q>iScVfnRoq?OyTPu#6zR5B*o z#a1dayOHs9g}0gWKU?N$sZGXB!kT%RQKE8|v-U@+#8V+UETr?&Lz-UQqx$*ES+?tF z-d)zRxtp`qSp#6rO#9TiNCt3T=lcyM14t2JC%4sk=ifY%x_o=>w1M|rYK7G>OwZEN*1LHJqTD138QSb>PvcoT{3Tb$`ZZ=7 z+Uy!vxKEu3=bv5s)1`9x6U%EpXu5rRaJV@eon>InsLARekIr~&8F1_PE^eUwq9i-% zW|HfVy3pt;nMt~*bX~r&bgZ}Z{2?ge9s;&~rm{!<$lT>2jjxoR#>lAW`gPVQMa#nb z<_vjv<7^G)wLbak^!~HjHqz+A6^>F&Nk%+ygZ9Fdk8)F}99=!xD6IM8i5XdiQd3{3qY~fUI7>E49u1S+S{T19;iTuPYZFQ%6OGp7H2BfL zNuw!`u(5bkp0BOVL=9|?u9~D0wOX=eTo;WWJPkvaG6hp#tgDUn+}GbW&y`aVI_cf7 z-o<|5o;C8`|5`d$#|w|I&wc+(!xc%I8+VHR$?J?dAR(QN4zhk)`O#y=KLIq>Kspa@ za^+-YB{5jws2aa{cO=kZ20EB}Sx#{Rzi8`fyJZP1#E` z`_MqBZ~kJb4i=`4wsFkAictCJn+K8+2{8YPh$ujy2zxP*7q@lXiIqkzF!rf=VM?jJC-40W?Z2m#Rxv4~btGII1g!mu zBP~f{YNACeJpReMHS&?$HMw`uYEptrr$<1bFbS04u0+-1CQz6ykh0R5K-^7~gy}#a zox28gF9ZS|Dx8jkKrn%>l{0V{@IWAL0%_ifsD9i8;%;&zgn*ww z-0hHr=}Exfgb0B^0STmMSEAN&5GY_vBRK@Z5s0HTk|f;-gljy6Lm(jp(yc>L-+Q0n=Q6dmr&Yp!gC< z^IlwhtKa4JQaA{d3<5o?l*bh>0wts2Q40vT1d3N5OlNJo+))Y#ff7leXVvn!6emz3 z8y=N{fF+RP9Wc$+v$qKk1WGW0H1FmqTm6)6avx4Qt>n3srJX*CrEE`3PsLKU;gvk& zqZ*|w?Q~NtJv(~}*1GE*rBc(CNc)qTsMA)fURBHEik3jiwms}m%HEoir&!VU!E`P? zW!n<=CuMIIH%L2ozOMOwV%TyGNvgK*=VMp4Oo7YhAKi5w(Lr z0SOd-mrSb?3wU=(o(=?l|Mj2$3;%EXjA11nLR#5`!mMn!J4)do5KJJV{RvK9Vq$w& zEsskqfo^w}!a*RIKm_|Ub69Zl5*FLLT6tWe2$Zn4L3NTr0Q(b?YNGmJI_DD-EP)Vk z31EL*8XkW-TaSX*-2;1zLfBZ~5AqDS=>6wEEBbN~H6TtrXnS=~UPa9H@T8G3) zAOu_j*dLchH2#8g$+Sw_{mxQ22m}-OSK#@d9W#doCr{hhKGn+Of(fK;+o0mn6TtpN z&&n?u+yT=(){l!22zUr!e>_0qxJ+|96R!GkkR(aE5y1W=@Q#P;iRl@hK(K|M)sTjl z&TQ9s^~iRwEgXA%_u4Ptee)lmfB4UTeDm+$e)(Tc&VT;(`)@!0sE|+IeC^QM+3Ux4 zo%CFdUX{xhzMe@fv1g^TroK>TQ+DqoTbF(D&S6viod5n;)&KRU@4xxz?RWQV+&O#1 zysjDOGw} z8-J~f-8~)kz;!E!zI1r~yzjsKIQ%=2VPn)0ly=gy)nl!AtbNd*|F&s^!m5=Q(Ys29 zKUF|T)}y!WUp_@O3eMKX{`Wuse&^M7v)VW9epl&$Z2eKu*L_g^Kfk_e`hWlZFI%YJ z^Uv?UT{F6a%%;Dfa3Px}_VVWwA|ywq?%#U){QBcRe)#&cZ$3Qs@!{Rawy)T`Bzxod z9x{DdkKEd`;+f$}i8VZ0t^A6fcsTx`HvHMq{cmkqqP8)&^8$X9Dfd(0ea?Yx81_@#3rd=f_^pezsaT zJ^kTP)_wZR4SxCI?@_As>5+ZI8ei@&D`ZIgJ>93xzkmPr!-G54Wpxq5kYz<5m_V(J znlhdr9@@EVP^WdmD9S+6MjU_fq(vOju` z|Mv3_1+2d&Xxpa`>2Y_N0EKN-G<9Ft>v46Tn6&!o+b_nnxXG3h;7NE-&($+0=l%U# zr@mM>pxof7q-d2#@W*dHUpnAf+eu5Xr^j8TQs2$(bbm|@J0{_^qdP=HAYvbkm`OzY z)2C`i#7s7f>lRb(f`zqY)T{DY5eto2_u1{6#A0sr9>waYG(t?b~-rv6^xOBxPyQgcde&hJ=5jlADOtx0Kn;*QsZ9whw zu|FR2g?yP*`=h(8r*PluX9rg*xDV)~5U zstIy=rmR1#{mIGs?z8vx*wU)V%vTzYd#u9nCYSfCd2Y{&XY{La&dB?&p53A8YYVb| z`Qf|Z_OIB#fBIf<^@IChvhB~t2|XfKK>|m&FBdDVsPwKGCO=nQ)z>n6#I$ek zToq9L;JUk+zqxVYtakSfx#urhRvO&kVohu{zjFn``PXH33Q(YMdb-i=-8~zkSre1C zg{IZP)iVs7l4peiZhGoOdF+p#*J>myhCHu!9*vT+NC#KX4A=5)xSlcrxdr#Zq}m@D zlb^r)%IX=?qiy9m9h-&JUt^o^zx*U*s*`~^Ib&Mf7&5s6gX?ZG(W#qo%V-LhdMC>Y z<;%7I;g_Kz_ViDW>~)pbY!>>%1-x?4CFJvttKhI;*gI?kG36aQ;}eGWqnZ zSf+oi^K=qv`Jh%h85;_QH|+O+{?x0|nO2RGWQA;{QrTne&lkrJX|14tt@C5n_U_U; zAnm=cr(UlZ@B#FboR|{O>6o{{#Y5_;f{& z4kg11^}MT8>N^eTHH@G9Y_)=@I<<8zUn@H$VBeC70r>~_!JRXQ1*A1m+asNS?prk@ zWG08#%{@g?-^KCWYeFV;12<>)_N6-|Kr>la-t`C9UAesb+Im+$r2m5h+fFIZcd=zs zpO6U^xH;1KXN`ZtrFFxz9=iG4kB|FG4iTX7Sf46o-L!sxqO{sIdw2*9P7PYx?`gjR zCZu2W^2OJMt5nGgA6)OEQ*WvFD!3O1_ravvpC7;Z%&Y6E*E8GP6Y+ixuK%ZqM|6fY zx-?`y!F87|I{ybq&Fy%f6?Q$rb=S`%mL%YhYB1Jrh-Rt#!&vO$OPY2U|{xBlR|A6_@dN(Ot5?N|}2*pp$y z8($VOhXnR?oB!U$6HXQ!#&=-F6f5udBv7kwKYk~S3MYb>_It*!K*W&Lw?YxCP}~Iu z*12HgxNgyWq(bM20xBEa2a{)imJfU`xMhYj8veROG-dmjP4=f!NO0ZvE*c*)l|S&% znpweh8TaU|{!|J{U{AOCYd#)4AC#(k57f&0J&DpvE0YmVwvT`OcE18qhNQ9;in0pD zOH9*t-7n21w9**-EgH8wXd5D+n!$Z=*PIanX-(9Q8AIIS{Qi*pU#o__gk?)v~@~xhG&Xj+%2Y5|uiWr%v+U8-2S z(f3`oKC_dKL+5^TwE+LL8ac4``2i?`?az_Tiv!Y{s8{EW3RbXRwG4kI3nOh?zrJ#+ zKUK((6@w$L|BMWoFu3ko{|TAOANY2p^`8+5`G7eqs*@&`uT{~A#99Dgc>#Mje9X5|KszW?grJEGWE=Z$ugxc-P*Ma(=Jf$h@< zx`igzU*skzvDGP@+$OcGtTrFby^B&t@7IKT^4b~&_d$Jkcu?I7tZ+!rp|ufKUwc?*Vo#56{G(eI%vP}h!^?+!0}zHHC}R2B*R~XRwx)KfNlJ)`KHht2Z~yk6WwYoFm5Sg|%o5 zdpPyt2rqm?6o-;UnB>6nDG~2lOal7Bgw?}dil#;p`{1`9y&XIb`RVUp2IwXu6Hbjc z)<<~%E1>7vJhx8n8&JBU^!}F>_IM)J{k^>#JxTnRA0CXb*`)Ws{HaVxvh2^@rGkfK z!lD-sK(4c!VE7QD^!>@Do9zQ?z=MjlRP1A>#0FM ze*3xBTKiTl7rjc+^g$J}c2qlmo37JFI zLpS~K)u*Qd@LlEP$VP=suE1#Op8El!Fpq47NBjnwUVzeUD4YTjdwP8HFEuJ32yeh^ z^T))kl{c@bueDMq-NKbynBmE`LWNnEf|M2U=vp-QR{iL$&5P!BzCU=P6jIM<`k>K4 zr`ojEF{1IMvTypka>(;CcM;WFJ^ba6<(j}v6S_ytK>}~@T&ZnAQ?wR*H0av1aI7|P zBc@ftfYPI{yUroZZr^xVqf2Bn^>=o=dj&jV=@QT(iU0+YWPhf;5aE+7Qf%Frj^PxG zs8uu-4=1DFv7PY59pQ>D{O}|hqTY9xN`2SAT1Ld_L%92>pTc}^&xWm&`b>YJR=+A` z*>t#mb(C zO&&?Bh?xfq=&?1d!JosGUL@fIs+Ui!E0S8oTIlo_YX{#cH&#o^U%vas{-xQ=`aPZ1 z;)dQ8OUGJVNcp7pLA}EsvQRg0TI-q-H)~uD(fc=Kb#@E#`;%IC>A8GBOMjtmNRsSN z?)kAu|3)>v+||VO$ETGZf+L$;wy4L$(HuV1?!Q}Niue;~f>NYL!D?)d;_K^L9Xhgk z(Sj}y461craBlI%2KPY~t)DfI)Rx1>YwdI6j_%Wo7|~}Khh%Uc+%;>M4k`!BgsZ|kuda<=pN-?XCh+)^Oh?};XGJVMxL&IJ zoc4_)Ze)k7z81!12O=s^(4HP@ks;z~a4CFn)wBTJ3a*u=)j$8^>yTt@;PL^_2I!Cq zQ3WwXsi1#E9driF-VVPmWih5XzISW*JH7oART$iogjxY4xYe*_jFH&npFi?$`G5~u8}99dzlce zg4?##&}YfrLQ~cspA|~k>bQy>*|NmngKWs*wX97ztHuDPTrK44@R-#t*z?_| z?*%L`1XM%|2ybm#823Qv^J535JX6!m ztsMQ3O7N{w?Dt=M)Th!JHkIF#5Uuts%nH`z*!JcAqEj{`Rx6aU6>$|iv}R_&J<ZGDn)$~dxPH%L6^Wjl_ z5NX%!VGFuFIH=C~i7GTWpW*jjvSvgZeQiVUbm-k)ZQE(W_|;n_s6}5YU!vl!m7Ls+J9zi482cRs(93mo>J9hV&fWw#=4T>^!Pq zueS%K!>uH<$;5Bj7VpP-1Hc#mO`LX@cq>X5QqR9lA_y|Po>7O0h8=Ujl z7T3SNVoJmfFKYp2w`&-jp<)ZDRo_Zwq>?Q$oJSM1h?=Chz`z>i!?_gK+^9FY+2!Hx zjtSS>4dWlVIjXu;DxLl09oj|GDP;{3lw5`-nuOS&XecE~Cf1%lsMa}pxJ-EbHl4?K zZT@I&kD7ZO+=^_hw#K5VNUU0!Jo{HGr}n(0*Q2tTw!{F>ib2msqfF|{gfu~^ueQ-p zGmC(Gq$oA8<2zTb8q#|5GZlw7yjYd(TltJ3^)48F-xV@AOL{-HbLJ4u)cgyi*q_vI z0}*?AaNCF}R_iVOs-6`Mlfuv{Cr8FA8fA)$jK>NU7qwSL`gUkcZOj$M{-k~zD2$$- z*Cswbyi*+{N+5-x)d%~xM5#=%6ElXWxTxK~YFT|?$F&=Y@?(EezYP>ZPgip~x@F0L z>KW0~NVZlQ(ob$#DSCa7Oe%pX&sIs`CpfJ6!u}-nt|cV%WcPG!VQk6n*{4$Jn6yo{ zR@iWdJlBMW4FekWKy-#a%CJUktEp! z7Ib|;FI)uJlUSp$Kgqsj1|PVAoe3xZ%lF?#LqKLKTn-A~J!@DrnIID%fvt)A(vFU0espmArg2?I zH~m|D%Ggv1)JjcAXOpx)yQq8fewEL#Mc_$U0#lx;cxd%>{rF3OSqK;P!&jdi+q!ha zm=2SlzPo>wGoz^^_9q%jNs@`Or)zGm6++!99sJgt^*a0g$s4=h-oE_6vg|Drdo1qp z(8R}Xjq-L!Q;{gOI;9cjDw=)-uwGD9b7R*?Qq76wFXwth^A0vQi{Of z+UL%Cxvq{ezrB6gm&Xt3lk#eNdf7pbIZe}k`u20}ntga+>l^E4@0vM8oA^_ot*j(* zQ~~=F2Q-pE00Idifc;4bMlyi_1Rwwb2tWV=5P(2h6R1?GFAf3_fB*y_009W36M=q} zO5q>?0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZafnrLa zf2C452tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SJ^F0+mbk$3XxB5P$## zAOHafKmY;|fI#vI45(ZR2LT8`00Izz00bZa0SG_<0);}L^nl7Z2tWV=5P$##AOHaf zKmY;|fB*y_009U<00Izz00bZa0SJ^30s||T#z6oA5P$##AOHafKmY;|fB*y_009U< z00Izz00a_Gpi1e1I0!%h0uX=z1Rwwb2tWV=5P$##AOL}qOJGoy(l`h}00Izz00bZa z0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZq6M?~1O5-2^ z0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY=XCs4KYU>pP>009U<00Izz00bZa0SG_< z0uX=z1Rwwb2tXjw1cp>Cje`ILAOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_P%H=xty&rf0SG_<0uX=z1Rwwb2tWV=5P$## zAOHafKmY;|NIZdRrHA4m009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AW%{W z469Ze2LT8`00PM)kp18li#y%BVfgc}&KU6Kn(4W^RF`r+OU@9tW8Y}5RM z%O>oY)MNF4C+EFXBeUTJ$b8-!GlEOWY0t?#J)2vE%xMq(soLmz=PYdBV9)F! zTEO|``){o=k?_X{wyYWWl-GJxFZ1u;f3;$M&wIO88T%$9rp0XuXv=I|lZ!p&atQ$l zqyvFJoeluc2zI^+Drc^dzc8jZQR5;J?|NL?7 z;Acz{Suoiv&BfeIY;JfU0D%%rVB3T)+LqGNan`*TnQCglvm?9v&)lx47UV>3mH3&e!C9rYCi?R&w zzqV#n-4k0;tNK3r(>I@mG#AMTeE-$eW*1CDub;pDLgyP91-Ul}KLnyAFuYpn;^lN! zN6WvT9NI1mAkDKHUZ~T{$uKg(d4BNP+L1NSk^XA5LiY*WyJIZ6N@G zmQ)`X`TCthnGMcY!YPkj_uVJ&1RILv#lEp}vQd8S3)Qr&t>x&EwaUg+9fVVy zfDVc6nAlBjYgYRdR~7Y=O(3)Wd9pTopF;akDoeJ=-PlT=oe9W!b-%|=t=A2HMq>Nt zk4!cM@-IvRtNJ`*?!J}^3$xrbDyw?w;l;!qSvN~-&U&Rn3Qv9XdM*40ScbxhQnQ?J z-(L+ty~ir`7I$h?TzH~qL;|b&I`*fSbd!jcLLd$T3)@L>tkPyRy70SCLr=T@ z`|p2!dU%IS!CUKQYKU!}jZW0`9KtY6zw6Cb$DRWGaI zA9haap>sP{CcyLa-@n*6;)SvI{?W}r{z(sAbztF`u%;|0XWA1tndxh%pUB? zfy-h_0Q*x+Z(L&4-aO_N_3M>=9#*z%20R(khJ3VdwYCe+flX4=~d9L-#t9M zMj4HN?8n4h3!tgl)W>cxrO$3v@!-;Nugw~`sC|8_&$z~a(g^C5B+kVftEY~rafTJ1 z@$_xGru06%B75Jw;fp#nP`M^*T$4X*pWR7!!uy}!f3>!2>f<-+O?fj7JWBOq3# z#O7RRxs9w{HW_8blWqRZ4XIDj+8+)3LuwTe7!Lu~wE_uj81cON`O4lcgk4h`fA?32 zR%y&l8Q<)ZfBpJTEA!F*O)?rLN|r&p$2QNmQO2W<^@;aiYBHJe zOa;C4;Stc~`k~Jn0sVb@%K}*>yDn|RaAG!GVn$vVN7g#ahRc)H=z<-Sx_^G;wG&my z$@%fCPb9p!V?$TW`rlYJ#bgzro`k18anqsY6Gg^Mko8i`SI1v}bw>a3O)s`txgI@e z)(m(;pKAGM{!w`y-_k3_YG%2H{nV!Du|JbqT)Ah?kWUZqRD1j6;I`Lh4a#nQxhILe z)B|zcE7>Rs?3~=wWLv<$ zYLH*Pz29G=8=?`{@oh^y?MsqJ+6SF&aIMQ*Z$}cV7{p7 zCYP0vHS?6BOFa~{k>N_pFGHto6n#IzgwJ~JE>A*P?~sp3$;)Uj>(##Hd&KN)NbhUtnd`>t!DN-u6UvS9 z_%)$3k~3T0;fd0vUc+~LkRHLZnKtLRJ=qWb%`@NBBDpN8o{#03ci&$o{08mY1gKj zcbgoPzlNO}Ip`ZrD#8TnX{EgbH(QNfY>66)X@0$HYM-MU=h#mlXu#~sTl=GWnO%$@ zzxr5TsMGUEHs53=1E?v#n`-{cHLOjLfzo#Bhx;~|hNo|g|MJ6$uQ7P+kDh~;o@9=1 zU8Lh=`fH!LQeeXUCk}g<;#T*6+*U{JO(RAP$z;rBdri1X_YiRP#2=A>T9j_I8Mvy! zSlS+sI-{9GY0U3mB~}iae3`PgPweWZ)rOY|SKqPQWO@dz9rTn&<7!v7A!!ZDYJ2ia z^-f-Mp08{Lwq&+b%-k2M+K~7>YU;KGwJuLv(%^s7sF$rkfc=p^YVF~i>_a&9H0HF= z@nzlb3nym6C191A^5}I|*cx_mJ7v;Qql`W^X_Dw+tpf};i98YQkKI$yL&QxhEOx9V zXf{s`p{>zAuqe}AL(^kR(-xzNR{v}gYkw3aft9@;_OjhI=LiS^hYo9f#Sj<|QkeFo{v1W{T^?FC-o#F5_7`hX6h7_o8%_A? z{F7!1T;2Ck6FAoCeF`7f_#zXo;f*dm|F(_mpx8OBE9tLFk+ys13^DTY{hj^XUA71f z7(!=Kql}fY1dQZZXa2QR-@%UeMt_p`J#Ceu1^M7iBw&=$@W7S)Hh=R`tNL!Z{5mUj zVmau4|69_rp&RSkA2;0ir#yU(t@x~#cX$Lg=bL3&yb(3ZnAmBL-C&aljwgxz4DW_v zt!$LrDt1rnYr~CaZT?oQ#w}y&mbbCCMw%YlK;zjnYmn7J_t(Z|)<4JE`Q_d3v*E_0 zqD?B(7fhhrJSIK(H&2qa1D`TUG$ZyzaTz6n3C%7M!`)N+NPb6lH^VdBUk&C*)jG@M zoO@FG)PKMK{KM$lHv2f|k+;@l1X6I$O%jr*AWXW9pr40~fZf<4LlhO-&o?+MhYC zD%xD$k3hQei%Xi#SZt!MfZHyNt3t0QZkX~XeEPhfesW+FWArRqxZ!E0;m z4GQOxjWU^!s$Ev+a6Dp*J2Wt{`pqvBrNOXO=Gex$CQu6qCQ!3@HwVqBPYBm2W9Qed zz`Hw_8-Z25ACgB$7B%nF2m?uBc z{}!~VWwKS`4;tQV07@ z5^dXslOeny1T>@3GMaMG5IX-pNBF<&)yCniZJ}%WKdw=zNuxao6Yh?IwX9?U_vJ5y zY7$}QUz(a}Vbi45zI@g_=Nm~oeDbKG{TCy!en?AsrazHurZ$aw$ppswSF=T%zxzb? z=2Iy4$HMu?r|)Vo7SbuoD13Co$!D~*b?uJ^r6JRrK+pPBvh9x^4fg&T&9!c$afnMe zKKpZc`9zbUtd`bc+<{Xv?2in70M%Eg_p^%(Zq@KdB%q@jW;SG>4%#D7@7^_~m$fRk zle(TaS5LL;fwEn)3>p&a(Pm22yYp692Fe8Le4`Cin>nFdjWSv^$bD+(uVHHN<8EQt zB=XqRW{m{Y+T+-Zvra)*@34J;JQ*JK3i@Z4@!20486A_bIjg~FPgaMxG|o~s-m5zr}jn}e2)wTY$! zv^o$gH~0CPUY9Uk|$KzyX z4GNv#^VHO}KbrZ<&U>6akQ{FO<8FxC%;Q`Ck=pKgX>Ek%&TaDtw>ER@iQE2|9Q292 z?Gw5Pokl*|4Al_J?y?D2qU?|Crw6Gn?cN;YAA2`r@@N7j`=evDO7+@|{(EK(bd$>< z*uEd^(Xk4hOR+}AWTM7kQ)%vO0(IBy4zi7n#-k=Z@5O3XJ)QcP_d-=&YSrkgw-4Cx zm^=aYN7G5QXX~tnp7j$|$3|(1(#m^0o?ahL$(6mDo7~KboJY)_`P`D())O_p$)Bwn zn#_7)b*XiGBVacOjEtRsy*6`zKb74?&{0Ewth-=1^O*`(;K;g}rYt>5a<>V>TOO0d z+8?zd`+TvvxodQKRN3$VkDg^GX*lP9boS{)xc0yDA7nNk9X!*lR&GU)2W+4$n@J-3 zV*{rf+((Yv;%W#`-tF{@Cpmk5&Qp$0O-p z`bR7wRaG0d3ty9(U*XEwN3F88>-kn7catdpEeWl1*l_F7T{imcnAp_@%46-145k}y z{C2%Aq-8keI8F1*ZE}0`%P!X0C_UiJDy{}CHqhE1X=oyfE91rRo3uPYVL}{D$TWnzd>E6)sOt zxU7Na{F^$I+P}0-()g!H?2oFW4MbCSy|iNQg*+PbnDCGfVhQLFyAkkM7)9wW+tb3H z(}~vpD6OWnZi(`n?M0KVf8E$D7%Eop<++CeG#oWaynFgJ%GlEm?x7vm{@ASwtBIa6 zCN~*a4fAZc{Yrz~a0Clk6I&O&o*cg+a}Q%+h(Qn_BPyj zG`cgfpS`im6UAjT1l;u>@z;3Lq|#ZeD2;T8nQf^Q)nS5 zcby@BKt8GY<$4Q44NT86EfLspyS?MqBO~Kh$Fzzm4_~doB(48QH|6hl?!gt6?#{n- zR@N_XLfl^9R>be$lJ^xjRotGmG&1&RXt zPYujWDv#l{b5*UzyMr7}tSl?NPUtEi|L6JNUeLKywN)FdiGY2XOGn~uxD>Esb-m_h zo2~>z(jM9JkMwK~c!CHx`(Jum*@3-w!OTq${;Om5C#JnM%4h@KZ8<7bBW;aRWLt~^ zIv}BC5ScZ-ETa)}NUhwJL$_$XwQAqX&{IsqUB#NNR2chT;g40j_NO54e_0xucA1K+ z%4+-;o!w3{t6o;#dNgyf=Or{EJ-8%GrcAHZ*#4_*{fj)-{^%WW7r1>*!@lCASlvxo zIi2@Aw0wen*Iv&Q^Dd0lL}%b_R`2azrIk8uR_fhA8444wXO$tM}0=3SNSb5{gT>{FVsOlRXIz6i-YVFp{ zYMIe9($ncOtAos!mPvI~RUGUttqABT<0&YmE=N``ow6?P{LgZ!x43=%*)4B3dWL-W z%H9Rz(M{KlF!Gw$=LY8Fyt8e|#AcV6z@6r8vYZ^v&L%Xy*aXgdzRJ7XmyT_4UhZX$ zGTHb2)r60#dFIL<57^ZYPg%Ou>z3Qcb<}BX^L0c`D3p9m-uWLjSCdJsf3uza_eE{% z#F||4pU8`Bxrk07dJz9okRE;zINleJXS87`mKvJ`a0TQqhW4m(kjV-d(l%N8{0xNV%yX32z?tk|*aVFJ=8~S*c@x>~v}0oS~*7 z_PyOBYi9&kO|L#3T%6_ZFsg&-e8Q^UE&TmgS!vi~w(6!ne6TW^a%v9>TtE0} zJFihe8Uk$^@j^)DB|QDH8}xK@Kh>h8a&5dSiJoY-2#;s}i`8UoZR1oas@eyyuC@l> zBd^OP9q-Y!&aPkO68QED9l@0XeqlnQCt%+H8eO}rlB^r_lu2lZ%Hr(#r&j>RH@+xj z{#raVpQTb8HUb*%y6Z@4?Gu{(*+gjwWFNksBBHreFH=4%^Ip)7&vYocbg*S<`@<*} z@~>7!HjQ{*PL|dcNXGbQUjDHMZRne_Y=2azc1sWNkG<#oDJ`y?*z7On&IU+EAEh3x z+b2skuF(ZirqT#x(*2hz$=HT}h%$*0)2+!(WyZ--Odr%HC9kqVj(I{Bt&yZjHKFmJ z0!Ec?T0L_`K;yUQ3Ao9N%wI!ujTCh&dfcZzIRB+;(r{((=0?we(a6%y_vXsfEVFs^ z%QK(8MgCDW&(K81B-9K}Pewg+$K89O3CSJ3JOB308)m}wchZBGMf~cilk^mCuBoh{ z_uq5BoR-@d;>D}UyUAAz4&gFU1?@P0&&&a?n5t>jTGpkp8x^--#htLsIXMD5xy9wC z2&tNN&$)4DOF{^wAOW)mvU_T8>9)LkGozv1%(b1%c(gV+s@9p3**HA+_4YOW9yRh> zlUdTKq4Cdr`c{eUn>*B8j=ksPnHDA4I<}n&H-E>}EALtP^pqI-GABp9QaNb7T7zvj z!&v>FzH#DRq0isi9WJems#PO*VZ^a@$*@1lUsD@X;`$-aXwYT+AMRb}7L6YQC4+#b zR9gB|UDT<^H~ynMn(W&i=Xx|rH&xcJ5GveQErnzzOXlH8wyy5QdiJgDFu_9YT7c)&8U8k%b z?w+y&E;aR2DO)mMk&Kx+nnL6FD;sFv`V<7s7AG|Mlg%glfxo`FdWzLVX4Xa-k86Ln zX0@Bs>aK8Ze3OejX#*~^8k}Qhd>TofSoO`x(R4K+2~iLzrUcCVLIV=3zdDiabgoCE z8nf8)`l8$eLfYLh-AesMJw+iuef_B!K~22xl-~%mnP{8Ll(Kfh;>0 zwPFDt4YVdS{j-vMb^L%vZd!@5YRBhU+_8cCzN9u`Wo9+f`~Hg$T+1mtFypD4Jw>?o zCqQRKg&SOXGPIZW5S__ZCn9VV9te~$0$NDXaaOH-ntI7&?1{fmX}?S|T9+_UcG=dR z+&H{^g2_ai#s1{l!OX0GwgfcrFfRpY4cW+>@r*x-C*;WbS(4W(lfrjT>1EOu@b98_ zb?kvUPjTi_3taPFsNydrdG=@1$QN8qUYpt9UpgTWC>aDyYhT&xL0cVlbGQ9lzlI9h z`?f!Ur=1$DFe{aYJ!Sx7$0y6XHMOZ+k8GDI<%G$~$v-JzHNn7SEyZ|LiH4%+FAW+ndaA2Q^?f>EZvxf7W@1ES?qg-melxh5_Sp4WtI=V$ByukBv@VIdu?h`=5=THM z&(+iI9+swH;dbF&gOKab{kBG~!I4|=a&>)B+AL-3uU9hv+8P{v+`dzj zFL=uTHP$lAP1+UG($l2of1{5>00Je0fQCR?0@5hR%%@Z-%|}k@|KTq*6|#oQ(g;qj zjc+SyJoemg=yGy$^z8^qnt@pXx7|HOa|rMbalxZ#pE98(Bdh zWeJSZOH#$ejca(m1{lBo@a^OWFEcuuqdp)+uMd8do?5$1Ua1(y^m!ohdcuJzq)Zg}ykt$JnA+ zD}_7+AW&Qgtm*rRW?`Saz9qA6hIF3Ljpewh|PTRW_^-KzXGg1 zwc^4MJs|*rxCxk7y0k>Bqj$W~lu5GY;*c4l|gVz0jKKmOherMdool>akQ5$%s&JC(_j4UtDfsfhAMK$5a}3tp;j zLCt@$szyHgz}=i@@34V*AOL}MAh2=hv)Y`}2Q8^i@EP0S98Ku-k&U0f{ahwRE6I9U?299>y|H4V)|#^K`-?|DtNz()O$rlzK5A+3;%)uadh8aE$}mvWc8b8-$W7|Cu| z+!#p+ff7SNc3|)9L7I8VwrE`_D{oO+wbKh6&oF^)~xN-sl9c+QKOyQ+13{yyf&lX#AX*4aup~J0uU(H1hlcGQ_5PG zw!cBC{d&DYrq352+cfvhRg-o8M{khHBN>fz^y^?JR*G_Rw5_iX)aRb|sad92LqK^5 zK%j^T)GVWIGA-|Ds-*=Sef(J?qW5>L)K0xlE$S<#GDq5N-#w+L_VKlMX3jJgvChoz zAOHafKmY;|fB*y_009U<00Izz00bZa0SFXx0;BaS=Qs#J00Izz00bbARs?F69*u(l z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV= z5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHaf zKmY;|fB*y_009U<00Iz59Dy-4OXDB_0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;| zfB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U< z00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa z0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV= z5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHaf uKmY;|fB*y_009U<00Izz00bZa0SG_<0uX>eOawA(mBB#(0uU$?0{=hs*8ivg diff --git a/deploy/windows/installer/wizard-small.bmp b/deploy/windows/installer/wizard-small.bmp deleted file mode 100644 index c064afbcb3715c71fa9446d41c6fd8def8ab25cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9294 zcmeI0`BRlu7{}`$Xrk5jeVb~geVe9zC(Sam%(BH6D@!f2J6cIxL z0Ra(^O+XO@1Q7*fkws*Yd;7dR&hhQM+{=4!{n8BY-22XR&UwyrzVCCM?VLx34!ioa zzUMC9*Ym&o3hV=ChVLa^&`S*43a{eElL7e~XE)D8)P@mpuz|ni+NZGE^#I;!~ zW^DUoQo@|~_@ylRbmyvHOOw|%R1`WKz1~SS&+^(nd%BMA+ZGu+eN6Dk%T|rMeap|U zXDs_6f5W2PvCH^D>g=#t zoo&q?ArxkhaQ2?)K~W$~{OyD0>QW^1bavF2<{m0a=XbQGyuI;oLq$<(QpCnDhb?~Q zO#fl$MND`wE#Qm8yEB{)M^n{aOain;F~L}iM`-QiY&ldB@yWxe1-)rYpts(hu77ko z97*#h09E}~ym52kra-p-NO{5f&z|JhR$u*(DQ+7!aaQe&Upnf_+=%(8#gON4)D)y* zkdc!g4;g)pfLT8FrmU5}ba%ANf-oqMida7O#>&hscvepMTqYjemEsoNsX;q9_a>}f z`tnt*%e2n+qo7?i?l!fwMlgg8V8eg?`j$3yvgzxS7Vstd$C$B!vXu3w(72`s#95Xa z#fM{c$beop=DPk@H*K*U20{VWPC!FA8g>ovv2SvJb#MCt(eE*cH~&b*$A5R5v)c&V4_5!6(d( zoAHLW^y>HTj{fR7Vz}0CZ3YZKf6a&Y6Q;u6z0*2Mj*#%Xb?*3Wiza)4v!-x6rij-R zMlUhTU-s&?7=sdsfo+)bl$k-%7))VIP5O@0#`ypW96Q`31I|wXxt(0mp9Ek&=?HymQg}bO+i|5{3_$?`eNFi zxaEs|eH4Gtib&3P#NDS;5=7f@lY2&d`y?AbfRW+LiD zS7-S6dlV=AqVr>owd*E6qWanW&ekUQ*<#fR0df%%I_7#;D(x31){-4Qr?s|<70m*1 zOa!u(W`&e&4X1N|#-ECls)HUfDABoEHxB6{Z%@7NE68mCkd^+ ztvE$p?0C+a-;^jtwA5B2LDIK$V#X+$J;k}Cr#x*VJe65-(#%`;JD(jh8zkf`VLK>Q+L)^e73pV=G#97P&p$+#jY8xXm zP;$egJyx7-Q2oBV{PlleOBgnL8ae4PO|1X0b78=y$fvn$=b>2i&qDOW$y%&JkqJ$R zKvy*Jba8e{yY-(wVHOB}W4hB>k-mvQ5kCH24J1t~PMo^!NDaG>y$OGHZlYPNqETjT zaTYwsTN-4c(AREYH2Ul3>7--|eBnZxxJE&A0LsUMF#gEN`d71qvJmGff(!v)n&!6^ zCmQH#Z%Of+!afRTZp1u=%}T`nI9fIuW=eMnSqE|q&mw)BBbaqAJy>k>!F{q2+sF4V z89qds=C>=HOc6E?3ry^H2|97lnD0lDeMnxyk12B2pkBpz=pjZyyP8fv_9GK&x<&JbwW^xRN@i z8IJGk!x4knYn5j1A;MGMQ0gODd#v`!r=>LRloXl#{$jAo?Lkf6=c33uR- zj&O=znL@2{tjP%YoKIK2b&Dp)+9V88IN^yl<2+FaM?gMt544&<%43GM2YxL9oSl+t zhX6%A41V;iP%)iNVbdYZg_9;zn%EDL*(^$52<@@zleGdzT+Ahgahu_Agp9t{ES7Qt z2^gN_AmA~o0z26pTIEEk)$v3E9sbpC!Jvdoj3wFGtV4i-wR66fFOoE^q=mh62jFoW zday=#C|bygSTd?p#fI>6>W3lvtKl)TE^OAv z6;5tcI0ax=Oi2&^&MI7bh&GaU3=eWTQJThyUx0xj)Wut~B4I8`TtlG75o-6IIw8Vr zeq7;%hcg2At`L+N;L!l6CdP@m5^}jSl)^&|3BmVrG$2)Rw6vys%0HG!ocE!t6NjL6 zgA;h1<|OxV+n}xfpEToVjl&E0My>*6?*x_g?FJ{za;YLaYiPs6OC)#vzCP>%l>mEf z6fJ$B!wOIJxW@?_n#6KQOoSt@XWcDr`zz2Mg($))P7u!hv%=$w7!W5D$+_64WPC!L zc34)(`3kBLBLC|5?oyo^!A&nksmit#E$G1&HgmrnobYq?N4>|TH(DjSYVlma$oURt zTsuEJA5oWk5<;GkOMkY~n-=RX2H`{7y(DxlnItXt|xh z-UeFGxbq>sQWjG94alq*?Biq#zKL2$-`wNL%cPtmn1dg`qo$%XrKaK_ zi5exk@}=B^;WeakzEokbLwJN!1QNHY{Xk3l0F|89S@&p78NkTtRat*w1E}4&C*i!@ zR7Y{bMu3oto!ZF`Fpb9xoFV}g5%(ynsWT~(N!*#sf8x%qIBo?$2%6mE;Zvkk>H+TI zB_(M9Y&N~dspO+cHX6lduK1a1hb+boImrdpkr1VhQp{cx{~o8;OM5hN5T{4#8jyKV Mp98AMBL?*OH~X!G7XSbN diff --git a/deploy/windows/installer/wizard-small@125.bmp b/deploy/windows/installer/wizard-small@125.bmp deleted file mode 100644 index 9bc049646bfa036b2561842391c0f15d7e2b2399..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12342 zcmeI2iC0zE6^HFV(3P}lwL{u;O0(LgO;=l!rc;{Q)iy>$qsGM87&EIeQ5$2lCTdK~ zV2s8@0hv@3K|~QzBp@I&$fSS>B7%q_g2*5;$b0=RTTf2TeLNn6HC>Ch@n10zN_hkwH-Fnj{TCop^hUw{*6 zsXJYrpI8{}pB4Oe+J;3*Yv=H!T@)QqouAZNf94Ayqbb6bj`kC&yAQ0I5&Yzx>mRr& zVDk4O=8cJ6F^v)A=Se$a?wEi_Zew=K^j{>bd9flry1T2xWb{ApH=U_C_{rRWN#6~6 z?1xEfUp{^?;!;Dc8!1{F&Xy&HCw}x&;G;j_HB-D_Z9aF(4gK|-YN@M^TluWdxLcy% zm{^$|+kK^ry&5Wyp3ID?NZ)fTKBRbGV0Q3|xc8sg{^B1v{Jl#a$p~D^3pSlS31xRz zCzN{@KkPI9YYD4oxqs`SeG5d*4g1jV;crI2KH+@%VZ|KbQGV4`jgHo)oNX(;$9@In zk$+*cexDn6K^fpwa($_R;#X4gJa({|L>ch_z4PZY|+`)+E`t7 zrYQaF(JcFU9@_e@l3yC!9Vg#ZTM3`?l*o-!f6R+qx^T{5JA~cZlI+0A-`}_F(aX&Z z!WJ7u4U)a*ht9ZX{n(pz33tr-%jv_(YWJ$_PxV>VMt`z4zr~%lHP&&VyhUAkfm$_$ z4dmSL&u?UIdeiA}z?Kuv2wZ{xrh9RdEG4HQ^3NtVQ!iRMWPUoSJ_XrjjPL&S+71~=bZh7jig}Xa1UmD~q#J)3q$DI4Gbhg)@EG9^KD8^vOh#JV;)o~d& zi-Q`B{FPbzeZ59rtSRrf)V%eXUl~#bPyczk|LdqLMh##vW?go-JwF;}l^4DyC-ehk z3!3sHI~n#uC_(HCRmW)SPOM+=!kL3rP(@a83&uwAE1!cvXIN912E9S1x=3DgBt@sE zjj9l~I3h(Rjs0&wA(WXLmxVm{8wu{l+CIAg0DJ=8uOefw!GH6#pNa5TL`mG%y5sr6 z?Q&B+N^20)#XWL^LPLxO0v(Ir26mE>+LA-~2Yr?IgI^UxM*q1Zi2Ib*AmEiz4=bj3 zU$J7-wa_mO02uV`%@^?1GO}m!1T39F;EfuTDFh%kF$Jqs-`5n7pmA{BJUQFa1EIzi z;_g^QBjnj%_ZM0aE)j#*FH6{NaHb2MfpdLDp*|J4V65zdMd;%M3yArFHuZ671L~|f zha+s(@ATPS^Z#LuJDpq{;egyhx*k4Jc(MK6uZ`2$N*kyQpRV>cBZ?Kr1Tl`vDNluN ziw~@?C=!4{98B~34<{f&jV=I>RgCBxVY$kA0(1|odI1^_f0$LzD<=Zi)phI4Q3>(V z*<0Sx$MI2e1>{^Z9=YH_*%V+PBjqW(7(thUrra(grwa};!nn?gHuet8#t^{g{*%7r zxfSC{I5&T*G~$1H|0CpS^!csvF%Z5bhq%68!| z|CteNM7khwA;9y9{$y=hu9!$8f5%41hgxQ77lKnaqm5iqksV4Yw*;&U}+A= z;^S}-l0=`Q7Cj`KB{uC{I$64;TC4?r$ob=W=%Sp(F&ldrbmL@n=CtvCOrbnH0WhAM zK=|xG73~cqg2Nw6ER5QK$9DNH$b2}`TXg z06bIq+TqCchVXPHMLLld#T6gf?L!hO3;TQ$wm=RTFCIh1CalT6g^UoMKt~o^hl5N? z^nr*Vvtki|*sK1>Yb23T86$BJ#UNlA%#~i@Imz*P3+wJY`{DU(j-&!VNa)lGaEP&m z-sPs^KX_hJT3M2~X0{NJ;LC_^O=uSx4dy`YLg%B}dD~aZDk?MhUJ?)}Wu(t1r*W<{ zhkpeT9}6+ZML!fGpV5SVoj*ym=)c%L`fT7ULw?GGN?;loK2JawHACVQZMb1mNgvO}uj-V{iX$=@ ziqC`+7md|Wl<1fKZ=d}~{0|XUVkMco!C&uMShT|e($ooYKFEWf&^?*C53=x=?!zz9 zZEtCWKtdHrPwW@Gy8o?z>D;7E##a&>432^!cSjT^l2Xusk0-w#|GyAQP#jPEr+o`X zcukQ_c;YCKmT}xK=S$--FGFaK}F--Y8@Rh^f> z)1~~N7f}YWR>=5qs$eq&;QHu-@Rf%1E>bmu9)D{H>86VxgB9Mx^Z1MN+~75e6Qh{M zT3ouFzf}lwI!Ru@y)@7SLbhlHrMA!+zZC~Uo~YKgHD0Z)p_$+cHSWHFB3;8j*=*#= zkC-Bm8^ggE0Z#TU#5HkEKxi>iAWh8jsUkM+C_Uf}_QG=2T8V4iriEP{4m_2b>aBr8 z=9W?8e&J8{rD87^N1N@a)*4#~)EDGNq<{=1hlYY1Z8k-7D26DU#+1}^$la(w^2)ra z4*U%M(uERkL`KqW8lL|w$UX5^E>4yxFrao}YohUe?%W{U+N4b-~x{obE9EZJRA-% z13;aWkWE-9fK8_)uVD&B!m)!$X#-Jo0=*65f+3C$FX6G&pAjMeSAYYNAu=hQxxFn> zm{=p9ge>^_`SYhsWS3bhK=df5%lHc=!J2)sCh`J!ekN9bT2{^6Ym^jftip)RiVX>| zdP-*)ChoL1+vGgKLL3fK5#PBig9%+?7T%lU60`l)eB=GBPlxlySRT*mbeUz?cXuh0 zK}JxTKg~aYKo6Qo9gnfr8>x zi6*CV4-8mNHw($c@!NnLjDo$W3G|lNNa7=`z}VJzYV9pJUJ^-Qo>h|S`5Fi%=#*j$ z-ia^5Ze$qb6d5|^P5NGVd%lAZxui_3PtoRbiIo)1B#s-iL)!&f zkPf3u`<6W>A4&km+=YcDu6+W=JyIIgQv)Rf(WLS8an+@W9lc}{_eeofaIBiTt_>~% zkc?Jf2%rMl8q+4^Avswg83zs=+(;MXMg@$l7Dymj5A*;zVjQppczL7#UkxgOqXwJ< z43gKsLJ7j13 HID!8Hz@b#y diff --git a/deploy/windows/installer/wizard-small@250.bmp b/deploy/windows/installer/wizard-small@250.bmp deleted file mode 100644 index a8cd3df6e390ce1a82ecbe8f367e7e01e1b89624..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57462 zcmeI5iN96Fk;j>TU_QQ?Op;kL`;wW->@%5tlF97cXiSVT8WUq;7Gq4*L`{r|8WWRf zj7Fn?Y_fA2|K0t^GRxyqAC9&3`v{{r3y~-r0Ho{agF>oA-+&xfk>DuF`t)FuDK)I!BkDs=F%H0FymaaX1+WIMX z50qQF_V{V*r`$bIZs~qr#?SF%M-K0QZQsjV_%(*I`r7KrgI-uZ zbKlOb)#~xIol}Bxr-FVM63-0v#lY zP*(jgL2X$y_3QLMYQmk{ zmOTFYj!nnkI+niIDE#`4ja!#YU-{rq(b&+lf6J-s-t+%x{yo?1+PEq`&%P;H8vQU} z)=eM9w{ClmK{DmKvsXRzvsX7hUEsRGXxX)4#mWhHJaX+>ZfHI5<$rztsR`v7P9@OK z(Kp|CdeVTA7yN0DW-1*IeeJ*3P8s^<{yoW`>Cr<64(xhy@AeHWCfip&xq0EE8|O}h z;4d0D@am3+hTNgoUu9ODaQT1s)ZYIztcqu+k8JpEvTTJsJ*oORe(Wgo)v%BMp7sqE zz~YhL!yt$@?u|V=H_n~3WYkSFZu#7lZ=W^(tN;GMrT;eR>JPHsPrUN~#$EC+to_PL ziQ(t|{)8+3>&e?MWx(uMJ(tfR%A5&s(Y-g=sc+<`{^Xfy_ZB#AqxB}=JbC(gan)1z zfAR0N7e}1`Zgh3@&_O3JA2I93+yPh3zT+}9!{ozUckJ*XS4=PWzw*Mm8TYYL4fxJpFCszw-Wt!>)U2?ZR*#JMzYU%nR%WedWQ+KJd!=<;uU0iIbrprk&|OxKKNw z_L)u{_SVtE(WYZA(2<#}0pWwEH0{RowU#Ks(0%f!UxB|fw2vKmbIl`z@BKuVmIhRQ ziLayKWE+*g$(wzypY^lG=`6(Z&vzV-nI_w0Utc!%$5G9=oA|kyb_^c-E|>ELeUtGf zg;D0##gEwneL(l2e$WFBiFOQbJ?nZOG!Z9CD@`Vboc)_PPKWlsVx`)%_1Q^RSGr!T zbYO<}rQOh1`gwKJYPFpiKa3*V2MIKY4E`e~7Ckrn&dX=s^7&am`O^F$*8%_l^rRVk ztXIiuaZeF%+5NZlTH>lz%(=lI`86qtUBcvVwXFM_1!6eIJ%matn5E zc?MexY+(3#f8aXLT{8JRdfmr%te%&^5G%2st>C!X%MqXa<4yA?Teh#guzuW^{z=LV z`RKb|SpI~4+9JC3`oX(oeXTXC(lKw)H|p5|tkA5}AP01e?WdIVW8eRjo0qi1Sl3fB z35(76(Px3hl@)daV_1se*sgzKtUhWH(MtW`Im{pYEosf5550p?6m7)8-7hT~@!e6M z{pB(_tT@*txi6XSSUTQ1(qpQ#&^6B=4uBa}ekM1yLh;RxmN+Bw*HUmvqg0LFgv8e&j_4()61yc?AmUHWn5u;>Bna~p!_u7l!Y9yEk|AW=OXs#cy6P#Xf4DE>u52`q*hmiYvEs-I5;Zs?~+VuRj%4 zQBP)~v^}jmaRTc{96S`&(7UrQHl&Nw4_Py#FZ_#IgDM^NGs2o;wXWCDQ}jNDqBFWT zJUL$4LYMlZ1)3=5LuEI6$LF6p&!7)d(M0(auKN{wxc4e z3v@~=Az63=>Dpch>{aquc!huK;Z3%DL%In4kYyo_*u3Botq_1Sxn=Gth6RDT@t3}j ze_hUoD_O`TkN~SC*&zHKdBJ;f0H`7cAz+;^r0pAKJs^(=!fS8jCYsjI#yJm(IIJ@& z1rdRiEoPx5GhqA5*}Ym>t?u5umeAU`Oa8eKZ3q+KiYedu=-Nkz5qEL{S*Buw*vrS> zI`-m!$Xes-hX-oIg&?t2n9wfwDC=c&`XR!I6N)St)CB&e@;CSGCY>xzEBW|d+xA?N z$ki(PCz~b>w}di$1q7&7!lW8coFGVQ6C)DX$|DB6i`eab@p*DpvLUccE zPCx3^tQdcLH^&n|{SDhh0qjsra(yd-z(NODNy9T*hR!BLm%u-K#ONX{f>xsp!<%HJ zI3}dBNHN0Z?op=8CiH`KDKHv=QJtMgKMTbV@7(j@U(FiaCSe25DrA*>hv;3J7;Ci8 zwy&5ai+yqxJmnLx&?(&brX*ql`qL{E8?CAS%_j7-c+~eraDW@B0KY6{7^jBG_x^*r zn8*nPp$CHDy&ra6v2CGYSJ?tAiEf=ywwWb z;U%5#6Oxc(t0vy146_n>^>gIlK1t;p{n@|LK1d%4bI{y|*$+CK4a>6v0zr?kZtd>b+7X3M?q=; zQDNRW-KBy*8S-{EK=yjm2pbdJXU#;aK44TWa+9SaOezF-CPOsTjOD|3;V^evhZwd@ zl%`Soooa70BS>L~*J*)IjHHfWb_ZBf`>QBwzYCGhlFMH&deWw!77s% znF%%9M2k6sBw<@cjjmQP3u(R#S$+0f^3inY12&I zq{i8H3C)Ts6@?2_SU9|sOFzV@s1tWjW20-P3`uCNd&J%ZD+qZ@u7JA)JaYMFq~s)g z(4p~xx9iq7S4DUU3}A&MWj`$A?%=1{SA=G?OEKc@;nRk9vg-%SK+rb4WMvO7%L_9J0o>>J!F7E+SI!%PTPIc=Q~AVDD$cgMny z`nrUSX>|@OA-jGwLaGog0YoQe!%JsDtDl?jA||l57=l$i>{MO{_P52LYhQ5SI1vr8 zbD-vd^$1Yu(Ahy4vSG64X`P&wXbzbg+F&70wuYD4^+Q@GV&WC(Mw$gfztd1~SAb+) zD;f*1-FV{)%OycR`wFz;vbH>+xhocqG)BI&wb)iwv5mY_+0c!T;!z`m?PHW=8t*l+Yti~dM*Zwuw^Ss; z^A@HJS9jL}y;R#zg<}QPr%{5$EG-NXU9)YBgEII_EZHtF|IYUbu<(+%^j>@=LxcWMyD+0<{T&5)RpmIR)gAR zEAs~&M_(7hp)I6y00z1;a_N?fG!7n1qK+BjbNUq{mGGpyjC763jQV-_n`clDJC#(5 zW^z9!UirTYtcs|p2=FD^Tim4eE+`r9?ZK3`ttNHVakIHAlw`$l1@zK=J}FyjH+d=O zLo+3}%tpzqob~8T`l(h0uzu`&pHzMgO=E3tlprjltb_oz68|b%Ua?pyymZWuTrp-~ z%TV19E#~&X(H4DV337uiALx`DD=hYa{jN-8Y5?WaJ86h4`oXxD7BJFtXyEtinZ3 z8B{jjhU4UFBni#6(se(c8)s73GJNqsLKGtqfPuVa(KP!Mx)!hQ-v8&SbC)$}#QfR$ z+*0yn{)%ra=Hy z-Mb1a8r{>VYSjU2gw1=Nc2b%)sA4b}T$zMRMj%IE=)1`TB`qtyI5s%R9X<+qH#(o& zw=A7SKVn%~07Y#rT-5zLw^_!-9WsG(UG1)PkbG&87vYKh!-pssgA4ZiWjnxSFxbLQ zbxQHuL3}X;X1b!haikS4@3mOR420Sf`67RG!e(peovCW(y@E<8q0)_w3eFC z{YQY7hre-p_l?H?&!QjMy*%lv{>oPJs4#)_jwNVcQfqqKU13Q;XV+X|uCA9{>KIlI zVM<25WXZ7?|05qln9h!ug(I2ObwFU-d}wS1CT+bbpAy^i^-e<~i+*IU82zDK*-pXq z@0MGA8X8yHg((TXa^`ObyD5JmX5Iefkeh>MSTJ>?TUeNI35cnj^Ed6g=h1&r(>{FB z-T_5&t})^<;7Np2ff+fxk|~y&ML%+8p0!UAinF7%en)7If^A0*n*IatW;8nfa#=&~ zgYW@v;2W>?ItB!*!U1tPBuRJ$f;J7%92#@zD%OSFDgr(0n)RN{!+lYKjIK z*;EJ6qmnlyn$Qoe=i_j2iByQW6?Rx5J;4~piVvMxgu!d>b;w`;)0`!NY?P7A+d_m9 zCM;99VbPu@t(G#1F}7Gg;rxcz7PZ`%jtc1j7B`GWPVObX%~o*b8FiEBArjs&@T>+x zyqHxnO{7}uYDlOD8mfo|he=28w|IV&CDz7Ia7YwiQYn~ia;&ee8{ojvV{XmC^JO|^ z{tkOxYlpzyXI1QCSm~&l+r7_G#;hj&13N&De_Y*s=627tm=VaqX!EC>p6=>pO63R4+Yg499=NOa+%3!QOI zbDoPu-apQq9c%V3m(#I*0H|&qlX0N9uGSDq#OO1e#Is#cdMNa;0 zF}@SxqdC>H-ZQ1HJ6R*+8_vyB-4_twIn%(GpaS@Sw=#W5yTZWI;cHngOy2clXX1Ym(&16>a^fWdV zV-Ie206@$BJj;hq)8)R0D_-Yua6NG{>3bKy!14r+nWjZ2l%5i3*R44MPiO>!SoB3%&!JFu$s{+8OW91^bhm=mIrF6967; zOva87X$0QCCMAUXIUc?KoM?^gnh*N`J0qGmALWqmrUu(JzX}OMNfPd4)Q=wbtqJZp z;?~5rrc`p9(4>u%GByN%wfQg1*UAP=+VVi+oWnx9cheKle7cVOTa)9 zm|U}>S%hKIynPG6Ak(mKs1_*zPfexLJUzz0t-2wdQ9pdjvI5vB9f{ytPx^qcCQk4! z{jH6**v-K`FN>c@pws64@e09nR$pua2Y*HPcoTb52_ZN%)>`r0A&*W*uZqz;VKWaV zUDY3ktb}$gup-r?GwKI6GRI>mXcmu!NYmp7IG!q$j1%b+(gO58gdXK_{}IPX_mUo z`g)Y8Wjz}sQ&;N;FZ&Oj1%x5L?-1pX^(J`?+VK*q#F?-kmp)AQh=5Zu`>0X$S%$!VN1?lIUtVRc3 z^aEEtuOS*sv+2^w4FZ|R31z36>tolQz%h0LWE+w-kW+0gsB|#Z*#5A4^~l<`vz%gO z$-+mU+HY33M7pi4R*YmiuYRx+#8LvqvMrO^(p%ZlCdy3*n&}r2KII!p;G>V+-XU~u z(RPcWr<^mDvnBLk3syOawlIY3*OaUf_cX75V51PQN~F9cFV9MygRUhvm;?Nn(TPPW zA8Oa?QYLyckGX{58s^`f+i20e+?$?jhJ5!trg*JlEh=z&q6z%~UI?6x=jb3Z>en+k zwMTOhWEJQ_3s3C5+Rr0(_Nd)P%3_t0xjp__I;rL|b)L>|_tlYHnUbL`hW<5h$l#H5 zCro>1!lzdbC7aL>Z<7Kg0*0SRO3%c$FG^ceKw1V`U@Pe{(D)PZ!s4+_-d` z_R_G$!q7Zkex^l|cUYX&#|vvobEi4|Py-OUh))8BtI(o0X_}Z&#{CYQt%&?LYuY6+ zv5&wB>aE#$cUgfr>`99OYFLS#e^%5Sbkq8wPWEg{)3+>|>O_#Bq3xL5G#LgYkAi4x z70l}#@sd8`4jImLq3u6p z=?s{?-L|HAnhYxPBEhxmo%@lg3eZA=7g#gw1W#119N4d+Ag(X2YL<%`e{ z{3KByVF`3NTQ1G9D4BY+RE6gla0{dDf8?aETCbSQc&)fT>2tB~Gpib-_1nrg|lB&Tz4n(neAiY`>C(C;TI+kT|ZWc<9s;9^L3VJI7@U^n(;#J01F;My|Qd9^p~#NcC<=vcR5e zH7k&S(7WU7(?}7JeU9#L_)2T^gUzOaeD({0$Df)=T*e1*2 zcQM1*kWTci^f$>;CDX?yt7xxt)S703^Xm-Okkc}S5G~UW(lLs3cNGA`rumbt$l$3H zE^q3$o})oYpl#wE>6H+cWNZ`H09RDJ(~1qv4zez!q0-S1yLInoy=7xM3zGC%7l~W!TLxK0PN*mta8Bmt?y#9}-6-(TdvH zewPWq#CeloF=C)4muiwmjC^JKNhx(EFnX@%_6QhB-#l^|6D)7ST65R1nC+-gEz78&r zwld#M;!9w?Ix`bEut^X3cxHX8&-H_Vz@_%oY5W$@cxyLEBUnJl>?AnjbmA^a`OvJ4 z^sOf^l2y^|kkr?)+z8!Cc2uppN(T{dO{LUgeL$QH{YbM|l5H9zS|! z&u2<{iv%2{v&lvx^MwHxZ(Ows=E`6rZwMC!@F?^~nT{|-IO&Gfi5(=Ei)>gT+nRhi z%mNh1{265qSVOLm{RDsqP-7n~aZ!2X=1J9$h*0}vC){aIBVgrWJdix-z{qGj$RR>$ z?4rY`XH1g%Np=(|_3R|XLaV0@Wkm`k79CqCEh>m5$w+HxaVwM75hc)%G!CPPDc_yl zfdN5U5LwK%gf|lmYqmc+e}A3%kU7(N$3)jihi#qBkyY~2=%-8YY89l8tukR&4tO2; zy(~l|E!9qH$)YYs5z`l=3!QLygC3WpD;Uf<=p~1pk+r|GmM@!ry5(m$krhf`vh7TJ z(Gm(?!H{r+MERg7GV3_>r#u6uy92uZUsnC7 zm@LmM>MRiqo^ViUPp2n<0dTI+Axl=n)k(!TSw!jeV>QdG59KQyyK!Bx&m>)+^VsKjqOyOp>;KV!cwHdu{!cM;9?k+WLw0N_p.txt next to itself and prints the path. -Send that file back. - -SECRETS: the report never contains them. Values from .env (database password, -SECRET_KEY, JWT_SECRET_KEY) are read first, then scrubbed out of every section -of the report before it is written, including command output and tracebacks -that might quote them. - -Standard library only, so it runs on the bundled runtime or any system Python. -""" - -import os -import re -import socket -import subprocess -import sys -import time -from datetime import datetime - -APP_ROOT = os.environ.get('SHOPDB_APPROOT', r'C:\shopdb-flask') -ALIAS = 'shopdb' -TIMEOUT = 25 - -# Filled from .env, then scrubbed from the whole report. -SECRETS = [] - -WINDIR = os.environ.get('WINDIR', r'C:\Windows') -# Sysnative gives a 32-bit process the real 64-bit System32. Harmless on 64-bit. -APPCMD_CANDIDATES = [ - os.path.join(WINDIR, 'Sysnative', 'inetsrv', 'appcmd.exe'), - os.path.join(WINDIR, 'System32', 'inetsrv', 'appcmd.exe'), -] - - -def find_appcmd(): - for path in APPCMD_CANDIDATES: - if os.path.isfile(path): - return path - return None - - -class Report(object): - def __init__(self): - self.chunks = [] - - def head(self, title): - self.chunks.append('\n' + '=' * 72 + '\n' + title + '\n' + '=' * 72) - - def line(self, text=''): - self.chunks.append(str(text)) - - def block(self, title, body): - self.chunks.append('\n--- %s ---' % title) - if body is None or str(body).strip() == '': - self.chunks.append('(no output)') - else: - self.chunks.append(str(body).rstrip()) - - def text(self): - return '\n'.join(self.chunks) + '\n' - - -def run(cmd, timeout=60): - """Run a command, return combined output. Never raises.""" - try: - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, shell=False) - out, _ = proc.communicate(timeout=timeout) - text = out.decode('utf-8', 'replace') if out else '' - return '[exit %s]\n%s' % (proc.returncode, text) - except subprocess.TimeoutExpired: - try: - proc.kill() - except Exception: - pass - return '[TIMED OUT after %ss]' % timeout - except Exception as exc: - return '[could not run: %s]' % exc - - -def powershell(script, timeout=90): - exe = os.path.join(WINDIR, 'Sysnative', 'WindowsPowerShell', 'v1.0', 'powershell.exe') - if not os.path.isfile(exe): - exe = 'powershell.exe' - return run([exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], - timeout=timeout) - - -def load_secrets(): - """Read .env so its secret VALUES can be scrubbed from the report.""" - env_path = os.path.join(APP_ROOT, '.env') - found = {} - if not os.path.isfile(env_path): - return found, None - try: - with open(env_path, 'r', encoding='utf-8', errors='replace') as handle: - raw = handle.read() - except Exception as exc: - return found, '[could not read .env: %s]' % exc - - for line in raw.splitlines(): - line = line.strip() - if not line or line.startswith('#') or '=' not in line: - continue - key, value = line.split('=', 1) - key, value = key.strip(), value.strip().strip('"').strip("'") - found[key] = value - if not value: - continue - upper = key.upper() - if 'SECRET' in upper or 'PASSWORD' in upper or 'TOKEN' in upper or 'KEY' in upper: - SECRETS.append(value) - if upper == 'DATABASE_URL': - # mysql+pymysql://user:PASSWORD@host/db -- the password only. - match = re.match(r'^[^:]+://([^:@/]+):([^@]+)@', value) - if match: - SECRETS.append(match.group(2)) - return found, raw - - -def scrub(text): - """Remove every known secret value from the report.""" - for secret in SECRETS: - if secret and len(secret) >= 4: - text = text.replace(secret, '') - # Catch a password inside any URL that did not come from .env. - text = re.sub(r'(://[^:@/\s]+:)[^@\s]+(@)', r'\1\2', text) - return text - - -def http_probe(url): - """Fetch a URL, returning status, headers and body even for an error page.""" - import urllib.error - import urllib.request - - started = time.time() - try: - request = urllib.request.Request(url, headers={'User-Agent': 'shopdb-diagnose'}) - with urllib.request.urlopen(request, timeout=TIMEOUT) as response: - body = response.read(4000).decode('utf-8', 'replace') - return {'status': response.status, 'reason': response.reason, - 'headers': dict(response.headers), 'body': body, - 'seconds': time.time() - started} - except urllib.error.HTTPError as exc: - body = '' - try: - body = exc.read(4000).decode('utf-8', 'replace') - except Exception: - pass - return {'status': exc.code, 'reason': exc.reason, - 'headers': dict(exc.headers or {}), 'body': body, - 'seconds': time.time() - started} - except Exception as exc: - return {'status': None, 'reason': '%s: %s' % (type(exc).__name__, exc), - 'headers': {}, 'body': '', 'seconds': time.time() - started} - - -def summarise_iis_error(body): - """Pull the meaningful bits out of an IIS error page.""" - if not body: - return None - import html - - # style and script blocks first: their contents survive plain tag stripping - # and drag CSS into the summary. - flat = re.sub(r'(?is)<(script|style)[^>]*>.*?', ' ', body) - flat = re.sub(r'(?s)', ' ', flat) - flat = re.sub(r'(?s)<[^>]*>', ' ', flat) - # Any unterminated tag left by the 4000-byte body truncation. - flat = re.sub(r'(?s)<[^>]*$', ' ', flat) - flat = html.unescape(flat) - flat = re.sub(r'\s+', ' ', flat).strip() - - hints = [] - for pattern in (r'\b\d{3}\.\d+\b', r'0x[0-9a-fA-F]{8}', - r'Error Code[^.]{0,80}', r'Config (?:Error|File)[^.]{0,120}', - r'Requested URL[^.]{0,120}', r'Physical Path[^.]{0,120}'): - for match in re.findall(pattern, flat): - cleaned = re.sub(r'\s+', ' ', match).strip(' :-') - if cleaned and cleaned not in hints: - hints.append(cleaned) - return {'flat': flat[:1200], 'hints': hints} - - -def main(): - report = Report() - stamp = datetime.now().strftime('%Y%m%d-%H%M%S') - - env_values, env_raw = load_secrets() - - report.line('ShopDB-Flask stage 5 diagnostic') - report.line('generated %s' % datetime.now().strftime('%Y-%m-%d %H:%M:%S')) - report.line('host %s' % socket.gethostname()) - report.line('app root %s' % APP_ROOT) - report.line('python %s' % sys.version.replace('\n', ' ')) - report.line('process is %d-bit' % (64 if sys.maxsize > 2 ** 32 else 32)) - - # ---------------------------------------------------------------- 1. HTTP - report.head('1. WHAT IIS ANSWERS') - report.line('This is the single most important section. The status code names') - report.line('the fault: 500.19 = config locked, 502.3/503 = the app did not') - report.line('start, 404 = application or handler mapping missing.') - - hostname = socket.gethostname() - targets = [ - 'http://localhost/%s/' % ALIAS, - 'http://127.0.0.1/%s/' % ALIAS, - 'http://[::1]/%s/' % ALIAS, - 'http://%s/%s/' % (hostname, ALIAS), - 'http://localhost/', - ] - for url in targets: - result = http_probe(url) - report.line('\n%s' % url) - report.line(' status : %s %s' % (result['status'], result['reason'])) - report.line(' time : %.1fs' % result['seconds']) - server = result['headers'].get('Server') - if server: - report.line(' server : %s' % server) - summary = summarise_iis_error(result['body']) - if summary and summary['hints']: - report.line(' hints : %s' % ' | '.join(summary['hints'][:8])) - if summary and summary['flat']: - report.line(' body : %s' % summary['flat'][:600]) - - # localhost resolving to ::1 first has bitten this install before. - report.block('name resolution for localhost', run( - ['nslookup', 'localhost'], timeout=20)) - - # ------------------------------------------------------------- 2. IIS state - report.head('2. IIS STATE') - appcmd = find_appcmd() - if not appcmd: - report.line('appcmd.exe NOT FOUND - is the IIS role installed?') - else: - report.line('appcmd: %s' % appcmd) - report.block('sites', run([appcmd, 'list', 'sites'])) - report.block('applications', run([appcmd, 'list', 'apps'])) - report.block('app pools', run([appcmd, 'list', 'apppools'])) - report.block('worker processes (empty means nothing is running)', - run([appcmd, 'list', 'wp'])) - report.block('modules: httpPlatformHandler present?', - run([appcmd, 'list', 'modules'])) - # overrideMode tells us whether the unlock actually took effect. - # - # allowedServerVariables is in this list because it caused a 500.52 that - # the first two sections could not explain: it is Deny by default, so an - # block in the app's web.config is rejected - # before httpPlatformHandler runs. Checking only the sections we unlock - # would have missed the one we do not. - for section in ('system.webServer/handlers', - 'system.webServer/httpPlatform', - 'system.webServer/rewrite/allowedServerVariables', - 'system.webServer/rewrite/rules'): - report.block('lock state of %s' % section, - run([appcmd, 'list', 'config', '/section:%s' % section, - '/text:*'])) - - report.block('W3SVC / WAS services', powershell( - "Get-Service W3SVC,WAS | Format-Table Name,Status,StartType -AutoSize | Out-String")) - report.block('listeners on port 80', powershell( - "Get-NetTCPConnection -LocalPort 80 -State Listen -EA SilentlyContinue | " - "Format-Table LocalAddress,LocalPort,OwningProcess -AutoSize | Out-String")) - report.block('app pool detail', powershell( - "Import-Module WebAdministration -EA SilentlyContinue; " - "Get-Item IIS:\\AppPools\\shopdbflask -EA SilentlyContinue | " - "Select-Object name,state,managedRuntimeVersion,enable32BitAppOnWin64," - "@{n='identity';e={$_.processModel.identityType}} | Format-List | Out-String")) - - # --------------------------------------------------------- 3. app + config - report.head('3. APPLICATION AND CONFIG') - - web_config = os.path.join(APP_ROOT, 'web.config') - if os.path.isfile(web_config): - try: - with open(web_config, 'r', encoding='utf-8', errors='replace') as handle: - report.block('web.config', handle.read()) - except Exception as exc: - report.block('web.config', '[could not read: %s]' % exc) - else: - report.block('web.config', 'MISSING at %s' % web_config) - - if env_raw is None: - report.block('.env', 'MISSING at %s' % os.path.join(APP_ROOT, '.env')) - else: - # Keys and non-secret values only. Secret values are scrubbed anyway. - lines = [] - for key in sorted(env_values): - upper = key.upper() - secretish = ('SECRET' in upper or 'PASSWORD' in upper - or 'TOKEN' in upper or 'KEY' in upper - or upper == 'DATABASE_URL') - if secretish: - lines.append('%s = ' % (key, len(env_values[key]))) - else: - lines.append('%s = %s' % (key, env_values[key])) - report.block('.env (secret values withheld)', '\n'.join(lines)) - - venv_python = os.path.join(APP_ROOT, 'venv', 'Scripts', 'python.exe') - report.line('\nvenv python exists: %s' % os.path.isfile(venv_python)) - if os.path.isfile(venv_python): - # The exact failure stage 3 used to hit. Proves the app imports. - report.block('venv: import shopdb', run( - [venv_python, '-c', - 'import shopdb; print("import OK"); ' - 'app = shopdb.create_app(); print("create_app OK")'], timeout=120)) - report.block('venv: waitress present', run( - [venv_python, '-c', 'import waitress; print(waitress.__version__)'], - timeout=60)) - - # What httpPlatformHandler is told to launch, and whether it exists. - if os.path.isfile(web_config): - try: - with open(web_config, 'r', encoding='utf-8', errors='replace') as handle: - raw = handle.read() - match = re.search(r'processPath\s*=\s*"([^"]+)"', raw) - args = re.search(r'arguments\s*=\s*"([^"]*)"', raw) - if match: - path = os.path.expandvars(match.group(1)) - report.line('\nhttpPlatform processPath : %s' % match.group(1)) - report.line(' resolved : %s' % path) - report.line(' exists : %s' % os.path.isfile(path)) - if args: - report.line('httpPlatform arguments : %s' % args.group(1)) - except Exception as exc: - report.line('[could not parse web.config: %s]' % exc) - - # ------------------------------------------------------------- 4. app logs - report.head('4. APPLICATION LOGS') - log_dir = os.path.join(APP_ROOT, 'logs') - if not os.path.isdir(log_dir): - report.line('MISSING: %s' % log_dir) - else: - entries = [] - for name in sorted(os.listdir(log_dir)): - full = os.path.join(log_dir, name) - try: - entries.append((os.path.getmtime(full), full, name, - os.path.getsize(full))) - except OSError: - pass - if not entries: - report.line('%s is EMPTY.' % log_dir) - report.line('No stdout log at all means httpPlatformHandler never') - report.line('launched python - look at the pool identity and ACLs.') - for _, full, name, size in sorted(entries, reverse=True)[:5]: - if size == 0: - report.block('%s (0 bytes)' % name, - 'EMPTY - python was launched but wrote nothing.') - continue - try: - with open(full, 'r', encoding='utf-8', errors='replace') as handle: - tail = handle.readlines()[-60:] - report.block('%s (%d bytes, last 60 lines)' % (name, size), - ''.join(tail)) - except Exception as exc: - report.block(name, '[could not read: %s]' % exc) - - # ---------------------------------------------------------------- 5. ACLs - report.head('5. PERMISSIONS') - report.line('The pool runs as "IIS AppPool\\shopdbflask". It needs RX on the') - report.line('tree, Modify on logs and instance, and Read on .env.') - for target in (APP_ROOT, log_dir, os.path.join(APP_ROOT, '.env'), - os.path.join(APP_ROOT, 'venv', 'Scripts')): - if os.path.exists(target): - report.block('icacls %s' % target, run(['icacls', target], timeout=40)) - - # ------------------------------------------------------------ 6. event log - report.head('6. EVENT LOG') - report.block('recent application errors', powershell( - "Get-WinEvent -FilterHashtable @{LogName='Application';" - "StartTime=(Get-Date).AddHours(-6)} -EA SilentlyContinue | " - "Where-Object { $_.ProviderName -match 'HttpPlatform|IIS|W3SVC|WAS|\\.NET' " - "-or $_.LevelDisplayName -eq 'Error' } | Select-Object -First 25 " - "TimeCreated,ProviderName,LevelDisplayName,Message | Format-List | Out-String", - timeout=180)) - report.block('system log: WAS / W3SVC', powershell( - "Get-WinEvent -FilterHashtable @{LogName='System';" - "StartTime=(Get-Date).AddHours(-6)} -EA SilentlyContinue | " - "Where-Object { $_.ProviderName -match 'WAS|W3SVC|HTTP' } | " - "Select-Object -First 20 TimeCreated,ProviderName,LevelDisplayName,Message | " - "Format-List | Out-String", timeout=180)) - - # ------------------------------------------------------------------ write - body = scrub(report.text()) - out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'shopdb-diagnose-%s.txt' % stamp) - try: - with open(out_path, 'w', encoding='utf-8') as handle: - handle.write(body) - except Exception: - out_path = os.path.join(os.environ.get('TEMP', r'C:\Windows\Temp'), - 'shopdb-diagnose-%s.txt' % stamp) - with open(out_path, 'w', encoding='utf-8') as handle: - handle.write(body) - - print('') - print('Report written to:') - print(' %s' % out_path) - print('') - print('%d secret value(s) were scrubbed from it.' % len(SECRETS)) - print('Send that file back.') - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/deploy/windows/web.config b/deploy/windows/web.config deleted file mode 100644 index f365fcc1..00000000 --- a/deploy/windows/web.config +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docker-compose.airgap.yml b/docker-compose.airgap.yml index edaba728..a5732708 100644 --- a/docker-compose.airgap.yml +++ b/docker-compose.airgap.yml @@ -3,7 +3,7 @@ # For a site with NO internet. Nothing is built or pulled here: the images are # built on a connected box (scripts/build-offline-bundle.ps1), shipped as a # tarball, and `docker load`ed at the site. This file only RUNS pre-loaded -# images. See docs/DEPLOY-AIRGAP.md for the full runbook. +# images. See the DEPLOY-AIRGAP page in the project wiki for the full runbook. # # Differences from docker-compose.yml (the connected/build template): # - api uses `image:` (a loaded image), never `build: .` (build needs the net). @@ -32,9 +32,9 @@ x-app-env: &app-env LOG_LEVEL: ${LOG_LEVEL:-INFO} ZABBIX_URL: ${ZABBIX_URL:-} ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} - # Channel label for a distributable installer. Empty on the stable (main) - # bundle, 'DEV' on the edge (dev-be) bundle, which lights up the sidebar - # badge. Sourced from .env, which build-channel-installer.ps1 pre-fills. + # Channel label for a distributable installer. Empty on the stable bundle, + # 'DEV' on the edge bundle, which lights up the sidebar badge. Sourced from + # .env, which build-channel-installer.ps1 pre-fills. SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-} services: diff --git a/docker-compose.channel.yml b/docker-compose.channel.yml index 2f16b22e..cd06129c 100644 --- a/docker-compose.channel.yml +++ b/docker-compose.channel.yml @@ -1,7 +1,7 @@ # Channel overlay for side-by-side stable / bleeding-edge stacks. # # The base docker-compose.yml is channel-agnostic. This overlay adds only the -# pieces that differ between a stable (main) and an edge (dev-be) stack so the +# pieces that differ between a stable and an edge stack so the # two can run at once on one dev workstation without colliding: # # - SHOPDB_ENV_LABEL is passed into the api container. When non-empty the diff --git a/docker-compose.prebuilt.yml b/docker-compose.prebuilt.yml new file mode 100644 index 00000000..4024f183 --- /dev/null +++ b/docker-compose.prebuilt.yml @@ -0,0 +1,112 @@ +# Docker Compose using pre-built frontend +# Build frontend on host first: cd frontend && npm run build +# Then: docker compose -f docker-compose.prebuilt.yml build + +services: + db: + image: mysql:8.4 + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + ports: + - "127.0.0.1:3306:3306" + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: shopdb_flask + MYSQL_USER: shopdb + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # Schema, plugin bootstrap and seed data - runs once before API starts + migrate: + build: + context: . + dockerfile: Dockerfile.prebuilt + restart: "no" + depends_on: + db: + condition: service_healthy + environment: + - FLASK_ENV=${FLASK_ENV:-production} + - DATABASE_URL=mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4 + - SECRET_KEY=${SECRET_KEY} + - JWT_SECRET_KEY=${JWT_SECRET_KEY} + - CORS_ORIGINS=${CORS_ORIGINS} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + command: + - sh + - -c + - > + flask db upgrade && + /app/scripts/bootstrap-plugins.sh && + flask plugin upgrade-all && + flask seed permissions && + flask seed settings && + flask seed reference-data + volumes: + - ./plugins:/app/plugins:ro + - ./scripts:/app/scripts:ro + - instance_data:/app/instance + + api: + build: + context: . + dockerfile: Dockerfile.prebuilt + ports: + - "${API_PORT:-5001}:5001" + environment: + - DATABASE_URL=mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask + - SECRET_KEY=${SECRET_KEY} + - JWT_SECRET_KEY=${JWT_SECRET_KEY} + - FLASK_ENV=${FLASK_ENV:-production} + - FLASK_DEBUG=${FLASK_DEBUG:-0} + - CORS_ORIGINS=${CORS_ORIGINS} + - SHOPDB_ENV_LABEL=${SHOPDB_ENV_LABEL} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - ZABBIX_URL=${ZABBIX_URL:-} + - ZABBIX_TOKEN=${ZABBIX_TOKEN:-} + - COLLECTOR_API_KEY=${COLLECTOR_API_KEY:-} + - COLLECTOR_API_KEY_ENDPOINTS=${COLLECTOR_API_KEY_ENDPOINTS:-} + volumes: + - instance_data:/app/instance + - ./plugins:/app/plugins:ro + - ./scripts:/app/scripts:ro + - ./certs:/app/certs:ro + depends_on: + db: + condition: service_healthy + migrate: + condition: service_completed_successfully + restart: unless-stopped + command: + - gunicorn + - --bind + - 0.0.0.0:5001 + - --worker-class + - gevent + - --workers + - "4" + - --worker-connections + - "2000" + - --timeout + - "60" + - --access-logfile + - "-" + - --access-logformat + - '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s %(D)sus "%(f)s" "%(a)s"' + - --error-logfile + - "-" + - --certfile + - /app/certs/server.crt + - --keyfile + - /app/certs/server.key + - wsgi:app + +volumes: + db_data: + instance_data: diff --git a/docker-compose.yml b/docker-compose.yml index 8af86016..32552ca8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,10 +63,10 @@ services: ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} COLLECTOR_API_KEY: ${COLLECTOR_API_KEY:-} COLLECTOR_API_KEY_ENDPOINTS: ${COLLECTOR_API_KEY_ENDPOINTS:-} - # This file is the source-build dev stack (wilm/dev-be); stable and edge - # ship through the pull/airgap compose files, not this one. So default the - # badge label to DEV here - a raw source build is by definition not a - # release - while still allowing an override from .env. + # This file is the source-build stack. Released channels ship through + # the pull/airgap compose files, not this one. So default the badge label + # to DEV here - a raw source build is by definition not a release - while + # still allowing an override from .env. SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-DEV} ports: - "${API_PORT:-5001}:5001" diff --git a/docs/ADOPTING-AT-ANOTHER-SITE.md b/docs/ADOPTING-AT-ANOTHER-SITE.md new file mode 100644 index 00000000..c9fa5d0d --- /dev/null +++ b/docs/ADOPTING-AT-ANOTHER-SITE.md @@ -0,0 +1,291 @@ + + +# Adopting the fleet tools at another site + +Two things run on shop-floor PCs and talk to a ShopDB instance: the **asset +reporter**, which posts what a PC is to the collector API, and **EventSaver**, a +screensaver that shows a slideshow served by ShopDB. + +Neither is site-specific by design. What is site-specific is the URL, the +credential, and which PCs it runs on - and all three are inputs, not code. + +**Getting them onto a PC is your site's job, not ShopDB's.** ShopDB serves the +data; something else lays the files down and keeps them there. This page covers +the two delivery paths that do not assume a tool you may not have: Microsoft +Intune, and doing it by hand. If you already run a configuration-enforcement +tool on the floor, the end state below is what it has to produce. + +Read [FLEET-ARCHITECTURE](FLEET-ARCHITECTURE.md) first if you have not: it is one +page and it is what makes the rest of this make sense. + +## Where these files come from + +| Artifact | Where it is | +|---|---| +| `Report-AssetToShopDB.ps1` | `plugins/computers/client/`, so it versions with the collector contract it implements. It names no site: the server comes from `HKLM:\SOFTWARE\GE\ShopDB` `BaseUrl` or `-ApiUrl` | +| `Report-PrintersToShopDB.ps1` | `plugins/printers/client/`. Optional - it records what print queues a bay actually has | +| EventSaver (`EventSaver.cs`, `EventSaver.ini`) | `plugins/slides/client/`. One C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. The compiled `EventSaver.scr` is a release asset, not a file in the repository. Neither file names a site | + +--- + +## Part 1: the asset reporter + +### What it does + +Runs as SYSTEM and POSTs what the PC is - hostname, BIOS serial, PC type, +logged-in user, make and model, OS, last boot, every routable NIC, the machine +number where one is configured - to +`POST https:///api/collector/computers`. The payload contract is +[COLLECTOR-PAYLOADS](COLLECTOR-PAYLOADS.md). A field your site does not collect +is simply absent; the server upserts on `hostname` and leaves the rest alone. + +### What you must provide + +| Input | Where it comes from | +|---|---| +| Server URL | `-ApiUrl`, or `HKLM:\SOFTWARE\GE\ShopDB` value `BaseUrl`. A script downloaded from Settings has your URL already in it | +| A credential | `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey` for the shared key, or the PC's own credential once it has enrolled. Never stamped into the script. See [COLLECTOR-AUTH](COLLECTOR-AUTH.md) | +| Your routable ranges | A **server** setting, `collector_corporate_ranges`. The bay reports every NIC and takes no view; the server picks which one is the PC's address | + +The reporter still accepts `-AllowedRanges` and reads a `CollectorRanges` +registry value, and **ignores both**, logging a note when either is set. Choosing +the NIC on the bay was wrong - a bay cannot know a site's addressing - so it +moved to the server. The parameters remain only so an existing scheduled task +does not fail on an unknown argument. Set the server setting. + +**Shortcut: download it pre-configured.** Settings > Computers > Asset reporter +generates the script with your server's URL already in the parameter defaults, +and shows its SHA-256. The credential is deliberately not included. + +### The credential to start with + +Mint a managed personal access token scoped to `collector.ingest` and nothing +else, so a token recovered off a shop-floor PC cannot read your asset register. +Plan to move off it onto per-PC credentials. +[COLLECTOR-AUTH](COLLECTOR-AUTH.md) is the whole story and the only page that +describes it. + +### Deploying it + +The three paths below are alternatives - pick one. All of them end with the same +two facts on the PC: the script exists somewhere it can run from, and the +credential is in the registry. + +#### Option A: Microsoft Intune + +**A1. The key, as a platform script.** Devices > Scripts and remediations > +Platform scripts > Add > Windows 10 and later. Run as SYSTEM, do not run in the +64-bit context only. + +```powershell +# Set-ShopdbCollectorKey.ps1 (Intune platform script, runs once per device) +New-Item -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Force | Out-Null +Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name CollectorKey ` + -Value 'shopdb_pat_REPLACE_WITH_YOUR_TOKEN' +exit 0 +``` + +Putting a token in an Intune script body means anyone who can read the Intune +configuration can read the token. That is an argument for the `collector.ingest` +scope, not against Intune: the blast radius of that token is one API. Restrict +the registry key's ACL to SYSTEM and Administrators so an interactive user on the +bay cannot read it. + +**A2. The reporter, as a remediation on a schedule.** Devices > Scripts and +remediations > Remediations. A remediation is the natural fit because it re-runs +on a schedule, which is exactly what reporting is. + +*Detection script* - forces the remediation to run every cycle: + +```powershell +# Always "not compliant": reporting is not a state to converge on, it is an +# event that should happen on every schedule tick. +exit 1 +``` + +*Remediation script* - the reporter itself, with your URL: + +```powershell +& "$PSScriptRoot\Report-AssetToShopDB.ps1" ` + -ApiUrl 'https://shopdb.example.net/api/collector/computers' +exit 0 +``` + +Assign it with a daily schedule. Run as SYSTEM, 64-bit. + +**A3. Intune with DSC (Machine Configuration).** If your estate is already +governed by Azure Machine Configuration, express the *credential* as +configuration and leave the *reporting* to a scheduled task, because a report is +an event and DSC converges state: + +```powershell +Configuration ShopdbCollector +{ + Import-DscResource -ModuleName PSDesiredStateConfiguration + + Node localhost + { + Registry CollectorKey + { + Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB' + ValueName = 'CollectorKey' + ValueData = 'shopdb_pat_REPLACE_WITH_YOUR_TOKEN' + ValueType = 'String' + Ensure = 'Present' + } + + Registry CollectorUrl + { + Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB' + ValueName = 'BaseUrl' + ValueData = 'https://shopdb.example.net' + ValueType = 'String' + Ensure = 'Present' + } + + # ...and a Script resource whose SetScript registers the scheduled task + # below (option B has the three lines). Pass it no -ApiUrl: BaseUrl + # above is where the script reads the server from, and one source beats + # two that can disagree. + } +} +``` + +Do not add a `CollectorRanges` resource. That value is read and ignored; the +ranges are a server setting now. + +#### Option B: manual + +For a pilot, a single bay, or an estate with no management plane: + +```powershell +# As administrator, once per PC. +New-Item -Path 'C:\ProgramData\ShopDB' -ItemType Directory -Force | Out-Null +Copy-Item .\Report-AssetToShopDB.ps1 'C:\ProgramData\ShopDB\' + +New-Item -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Force | Out-Null +Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name CollectorKey ` + -Value 'shopdb_pat_REPLACE_WITH_YOUR_TOKEN' + +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument @' +-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1" -ApiUrl "https://shopdb.example.net/api/collector/computers" +'@ +$trigger = New-ScheduledTaskTrigger -Daily -At 7am +Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action -Trigger $trigger ` + -User 'SYSTEM' -RunLevel Highest -Force + +Start-ScheduledTask -TaskName 'ShopDB asset report' +``` + +Lock down `C:\ProgramData\ShopDB` if you put it there: a script a user can edit, +run by SYSTEM on a schedule, is a local privilege escalation. Break inheritance +and grant Administrators and SYSTEM only. + +### Verifying, and what to do when it fails + +Open Computers in the web UI and find the hostname: it should be there with its +serial, type and a last-reported timestamp within one schedule tick. The same +answer over the API needs a logged-in session, because the read side is +permissioned (`computers.view`) even though the ingest side is not: + +```powershell +Invoke-RestMethod -Uri 'https://shopdb.example.net/api/computers/by-hostname/' ` + -Headers @{ Authorization = "Bearer $jwt" } +``` + +The collector key does NOT work here - it grants `collector.ingest` and nothing +else, which is the point of it. The script also logs to `C:\Logs\Shopfloor`. +Every failure mode and its fix is the table in +[COLLECTOR-CLIENT-SETUP](COLLECTOR-CLIENT-SETUP.md). + +--- + +## Part 2: EventSaver + +### What you have to decide + +[EVENTSAVER](EVENTSAVER.md) is what EventSaver is, how it picks slides and how to +diagnose it. Read it first; adopting it adds only three decisions: + +1. **Your feed URL**, in `EventSaver.ini`: + `url=https:///api/slides/feed?surface=shopfloor`. +2. **Ship the ini with the binary.** There is no compiled-in fallback, so a + missing ini is a blank screen. Check any `.scr` you were handed rather than + built: an early build still has another site's file server baked in, which is + exactly why the fallback was removed (ADR-015). +3. **Which PCs get it.** A screensaver over a live display is a defect. Exclude + kiosks, lobby displays and metrology bays. + +If your configuration tool enforces the ini by hash, your own hand edits on a +bay revert - that is the feature working. Edit the copy your tool deploys from, +and update the hash it detects on: + +```powershell +(Get-FileHash .\configs\EventSaver.ini -Algorithm SHA256).Hash +``` + +### Deploying it + +#### Option A: Intune + +Package the three files as a Win32 app (`EventSaver.scr`, `EventSaver.ini`, and +the enable script) with this install command: + +```powershell +# install.ps1 for the Win32 app +Copy-Item .\EventSaver.scr 'C:\Windows\System32\EventSaver.scr' -Force +Copy-Item .\EventSaver.ini 'C:\Windows\System32\EventSaver.ini' -Force +exit 0 +``` + +Detection rule: file `C:\Windows\System32\EventSaver.scr` exists **and** matches +the expected SHA-256, so a changed binary reinstalls. + +Enable it per user with a configuration profile (Settings catalog > Screen +saver) or a user-context script setting the three values under option B. With +Machine Configuration/DSC, the two files are `File` resources - but DSC runs as +SYSTEM, so the per-user setting needs the logged-on user's hive or a +default-user template, which is usually more trouble than a login script. + +#### Option B: manual + +```powershell +Copy-Item .\EventSaver.scr 'C:\Windows\System32\' -Force +Copy-Item .\EventSaver.ini 'C:\Windows\System32\' -Force # with YOUR url= + +# Per user, in the user's own session: +Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name SCRNSAVE.EXE -Value 'C:\Windows\System32\EventSaver.scr' +Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '1' +Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '480' +``` + +Those three values are the whole "enable" step in every option above: a +screensaver is a per-user setting, so whatever deploys the binary still has to +reach the user hive. + +### Verifying + +Run `EventSaver.scr /c` on the PC - it reports which source it resolved and +where it is caching - then open that feed URL in a browser on the same PC. The +symptom table in [EVENTSAVER](EVENTSAVER.md) reads the answers. + +--- + +## Which path to choose + +| You have | Use | +|---|---| +| Intune across the estate | Option A. A remediation for the reporter, a Win32 app for EventSaver. | +| A configuration-enforcement tool on the floor | Its own idiom, producing the end state option B describes: the script on disk, the credential in the registry, a task as SYSTEM. | +| Neither, or a pilot of a few bays | Option B, then move to A once it earns its place. | + +These are not exclusive, and on a real floor they usually are not. Where two +tools both converge the same PC, decide which owns which layer before deploying +both: the deciding question is which one you would look at first to answer "why +is this bay wrong". diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md new file mode 100644 index 00000000..7f68594a --- /dev/null +++ b/docs/API-REFERENCE.md @@ -0,0 +1,89 @@ + + +# API reference + +**The reference is generated and served by the app. This page is a pointer to +it, plus the two things a specification cannot tell you.** + +There used to be a hand-written endpoint list here. It competed with the +generated spec, lost, and went stale in between - so it was cut rather than +maintained. Everything it listed is in the spec, derived from the code: every +operation with its auth, its query parameters, its response shape and +harvested examples. Counts are in [PROJECT-MAP](PROJECT-MAP.md), which is +generated; nothing of that kind belongs on this page. + +## Where the generated reference is + +| Reach it at | What it is | +|---|---| +| `GET /api/docs` | The reference, rendered with Redoc. The bundle is vendored in the image, so it renders with no network - which is the only reason it is usable on an air-gapped site. | +| `GET /api/docs/openapi.json` | The raw OpenAPI 3.1 spec. Also tracked as `docs/openapi.json`, so it can be read from a checkout without running anything; regenerate with `scripts/gen_openapi.py` after any API change. | +| `GET /api/docs/llms.txt` | The short version, for a machine caller. Also [llms.txt](llms.txt) in this corpus. | +| `GET /api/docs/try` | Swagger UI over the same spec, where Execute sends a real request as whoever is signed in on that browser. Off by default (`API_DOCS_TRYIT_ENABLED`) and not something a production site turns on without deciding to. | + +[llms.txt](llms.txt) is not a second spec. It is the orientation a caller needs +before the spec is useful: the response envelope, how authentication is +obtained and sent, the pagination and filtering conventions, and the handful of +recipes people get wrong. Read it first, then look operations up in the spec. + +## Which surface owns what + +The spec lists every operation; it does not say which contract governs one. +There are three surfaces, and the third is everything else. + +| Surface | Who calls it | Where its semantics live | +|---|---|---| +| **Fleet and client contracts** | Shop-floor PCs, the endpoint agent, kiosks, displays and printer installers - machines, not browsers | [COLLECTOR-INTEGRATION](COLLECTOR-INTEGRATION.md), [COLLECTOR-PAYLOADS](COLLECTOR-PAYLOADS.md), [COLLECTOR-AUTH](COLLECTOR-AUTH.md), [PRINTER-INSTALLER](PRINTER-INSTALLER.md), [EVENTSAVER](EVENTSAVER.md) | +| **Bulk import** | A script loading a site's data. There is no separate import surface: it drives the same endpoints the UI does, with a header that preserves the source rows' timestamps | [IMPORT-API](IMPORT-API.md) | +| **Core UI API** | The Vue frontend, and anything else holding a token | [CONTRACT-STABILITY](CONTRACT-STABILITY.md) for what is safe to depend on | + +## How auth works at a glance + +Four credentials, and which one an operation takes follows from its surface. +The spec states it per operation; this is the shape behind it. + +- **JWT** - a login token or a Personal Access Token. The core UI API. +- **Scoped service token** - `X-API-Key` or `Authorization: Bearer`, minted and + revoked per purpose (`collector.ingest`, `collector.ingest.endpoints`). A + token can be bound to specific resources and is refused anything outside + them. This is what the fleet uses. +- **A per-PC credential** - an enrolled endpoint holds its own secret, and + revoking one endpoint costs nothing to the rest. See [COLLECTOR-AUTH](COLLECTOR-AUTH.md) + and [ADR-019](adr/ADR-019-per-pc-endpoint-credentials.md). +- **A kiosk device token, or an address inside the kiosk's allowlist** - a + credential for a screen, not authorization for a person. It says which + cabinet is asking; the badge on the request says who is standing at it. Both + kiosks are off by default and answer 404, not 403, when they are off. + +Two rules cover almost everything else: + +- **Every read is behind a permission.** Reading is not free, and a site + decides what a signed-out visitor may see by granting the seeded `guest` role + the read permissions it chooses - in the same role grid it uses for staff. +- **Every mutation needs a JWT and a permission or role.** None are public. + +## What is reachable with no credential + +This is the inventory a firewall or a deployment reviewer asks for, and it is +the one question the spec answers badly - it is a question about the whole +surface, not about any one operation. + +Do not read it off a page somebody maintains. It is enumerated, with a reason +per entry, in `tests/test_core/test_public_read_surface.py`, which fails the +build when a new public read appears without being added to the list. That file +is the answer, and it is the answer because it cannot go stale without turning +CI red. + +## See also + +- [CONFIG](CONFIG.md) - the environment variables and settings behind these + endpoints, including the public settings allowlist. +- [CONTRACT-STABILITY](CONTRACT-STABILITY.md) - what a sister site may depend + on, and how tightly to pin. +- [DEPLOY](DEPLOY.md) - the site-exposure surface a deploy reviewer needs. diff --git a/docs/ASSET-COMPOSITION.md b/docs/ASSET-COMPOSITION.md new file mode 100644 index 00000000..d19b72d8 --- /dev/null +++ b/docs/ASSET-COMPOSITION.md @@ -0,0 +1,158 @@ + + +# Composition: several devices under one parent + +When more than one physical device answers to a single identifier, model each +device as its own asset and file it under a parent. This page is the pattern: +when to reach for it, what you get for free, and how to make the collector do it +without writing code. The mechanism itself is not new - `partof` and its +propagation rails are the ADR-001 platform contract; the recipe is. + +## The problem it solves + +A machine number is supposed to identify one thing. Sometimes it does not. + +At the reference site, several Telesis part markers serve one operation number: +0613, 0615 and PRTMK01 each have more than one. Their configurations differ, most +often by COM port. Treating the operation as the device collapsed them into a +single record, and the damage was quiet: + +- Their backups overwrote each other, so the history read as one device + flip-flopping between configurations that were really two devices. +- No question about an individual device could be asked at all - how many there + are, which port one is on, which one failed. +- Every PC driving one contested the single `controls` link to the operation, + so whichever reported last appeared to own it. + +None of that announced itself. It surfaced as duplicate backup rows, weeks +later. + +## When to use it + +Reach for composition when ALL of these hold: + +- Several physical devices share one identifier, and that is CORRECT - not a + numbering mistake. Two PCs carrying the same machine number by accident is a + fault to fix on the PC, not a model to build. `flask relationships + check-shared-machines` tells the two apart. +- The devices are individually interesting: they fail, get replaced, carry + their own configuration or calibration. +- Something already identifies each device. One device per PC is the easy case, + because the PC names it. + +Do NOT reach for it when the parent is simply a location. A room holding six +printers wants a Location, not a parent asset. + +## What you get + +Filing a device `partof` a parent buys behaviour already built: + +- **Control propagates.** `controls` propagates through `partof` (ADR-001, and + seeded by `flask seed reference-data`). A PC controlling a device therefore + controls its parent by inheritance, so the PC does NOT need - and must not + have - a direct link to the parent. That is what stops several devices + contesting a link only one can hold. +- **Position inherits.** Map-position resolution walks `partof` first, so a + device with no coordinates of its own shows at its parent's position. +- **History separates.** Anything keyed on the asset - backup revisions, + relationships, notes, audit - is per device instead of merged. + +## Making the collector do it + +A PC that drives a subordinate device declares it in +`plugins/computers/pctypemap.py`: + +```python +SUBORDINATE_DEVICE_MAP = { + 'gea-shopfloor-partmarker': { + 'assettype': 'machine', # core AssetType for the device + 'typename': 'Part Marker', # type within that vocabulary + 'description': 'Telesis part marker', + 'suffix': 'PARTMARKER', # device asset number = - + 'partof': True, # file under the reported machine number + 'label': 'collector:partmarker', + }, +} +``` + +`partof: True` is the switch that matters. It files the device under the +operation the PC reports AND stops that PC claiming the operation directly. +Leave it False for a device that does not share an identifier - a CMM is a +subordinate device too, but no two CMMs answer to one number, so it links to +its PC and nothing more. + +`label` is the relationship origin marker. Only rows carrying it are archived +by a collector push, so links made by hand are never touched. **Do not change +an existing label**: those exact strings are in production databases. + +A site adds or retargets an entry without a code change, per ADR-015: + +``` +Setting: subordinatedevice_ category: pctypemapping +Value: {"assettype": "machine", "typename": "Marking Laser", + "suffix": "LASER", "partof": true, "label": "collector:partmarker"} +``` + +A malformed override falls back to the built-in default rather than failing the +collector push - a bad setting must not stop a bay reporting its inventory. + +## Making a backup kind follow the device + +A backup posted by a PC that drives a device belongs to the DEVICE, not the +parent. `plugins/backups/services/registry.py` resolves this through +`markerforsource`: it finds the PC by the reported `sourcehostname`, follows the +PC's active device link, and files against what it finds. It falls back to the +machine number whenever the device cannot be resolved - no hostname on the +payload, a lean build without the computers plugin, or a PC that has not +reported yet - because filing under the parent beats rejecting a backup. + +Two things that matter if you add a kind: + +- **`sourcehostname` is load-bearing**, not informational. Without it a backup + files against the parent and merges two devices' histories. +- **A revision chain is (asset, kind, source hostname)**, so two devices on one + parent keep separate chains even before they become separate assets. + +## Finding the next one + +``` +flask relationships check-shared-machines +``` + +Read-only. Lists every machine number claimed by more than one PC and separates +the two cases by whether child assets exist: + +``` + 0615 11 PCs, 11 child asset(s) - modelled + 2026 2 PCs, NO child assets - PCONE, PCTWO +``` + +The first is composition working. The second is two PCs carrying one number - +fix that on the PC. + +## The parent is not disposable + +Once devices are filed under it, the parent is doing a real job even though it +may look empty: it is the thing that says those devices belong together. +Deactivating it (`isactive = 0`, which is what "delete" does in the UI) breaks +filing, because the resolver requires an ACTIVE parent - every subsequent +report warns and files nothing. + +A hard SQL `DELETE` is worse: `backuprevisions.assetid` is `ON DELETE CASCADE`, +so it destroys history still attached to the parent. + +If the parent looks wrong in a list - an operation number appearing among +machines - that is a CLASSIFICATION question, not a deletion one. + +## See also + +- ADR-001 - relationship types, propagation rails, position inheritance +- ADR-015 - why the device map is settings-overridable +- [COLLECTOR-INTEGRATION.md](COLLECTOR-INTEGRATION.md) - the collector contract + and the part-marker case end to end diff --git a/docs/BACKUP-KINDS.md b/docs/BACKUP-KINDS.md new file mode 100644 index 00000000..686ab653 --- /dev/null +++ b/docs/BACKUP-KINDS.md @@ -0,0 +1,52 @@ + + +# Adding a fleet config backup kind + +The recipe for adding a kind to the `backups` plugin - the per-PC CONFIG +backups the collector takes off shopfloor machines (NTLARS settings, +part-marker configs). Backing up the ShopDB database itself is +[BACKUP-RESTORE.md](BACKUP-RESTORE.md); why a Backup badge reads green or red +is [BACKUP-STALENESS.md](BACKUP-STALENESS.md), and you do not need it here. + +--- + +A kind is one class in `plugins/backups/services/registry.py`, added to +`REGISTRY` at the bottom of that file: + +```python +REGISTRY = {k.key: k for k in (NtlarsKind(), PartMarkerKind())} +``` + +Subclass `BackupKind` and override what applies. `NtlarsKind` is the fullest +example (parseable, renderable, has an info panel); `PartMarkerKind` is the +lean one. + +| Member | What it is | +|--------|------------| +| `key` | Wire value. This is what lands in `backuprevisions.backupkind` and what the fleet table shows on the badge. Keep it short and lowercase. | +| `displayname` | Human label for the UI. | +| `storagebackend` | `'shopdb'` (bytes live in the DB, deduped on a semantic hash) or `'share'` (file lives on the SMB share, deduped on a raw byte hash). | +| `assettypes` | Which core asset types this kind can attach to; `['*']` for any. | +| `emptytext` | Text when an asset has no revisions of this kind. `None` HIDES the panel, which is the right default - a kind applies to an asset TYPE, but whether a given machine ever has that backup is per-machine. A part-marker panel on all 144 machines is noise. | +| `parse(raw)` | Opaque kinds return `None`; parseable kinds return a projection dict. | +| `formats()` / `render(...)` | Download formats, for `shopdb` kinds. | +| `resolveassetid(payload)` | Map a collector payload to the asset the backup belongs to. | +| `infopanel()` / `buildinfo(...)` | Optional at-a-glance card (ADR-010). Declared by the KIND, not hardcoded in the plugin, so a successor technology ships its own card by adding a class. | +| `sharedir(...)` | Conventional UNC directory for a `share` kind. Advisory - the authoritative path is whatever the collector reported, because the PC is what actually wrote the file. | + +### What you do NOT have to touch + +The **Backups stopped** dashboard card needs no change. It reads +`backuprevisions` generically: newest revision per chain, whatever the kind, and +shows `backupkind` as the detail line. A new kind inherits the card with no +work. The staleness rule needs no change either - it is time-based and +kind-agnostic ([BACKUP-STALENESS.md](BACKUP-STALENESS.md)). + +Making a kind file against the DEVICE rather than the parent asset it hangs +under is [ASSET-COMPOSITION.md](ASSET-COMPOSITION.md). diff --git a/docs/BACKUP-RESTORE.md b/docs/BACKUP-RESTORE.md new file mode 100644 index 00000000..df4d0f83 --- /dev/null +++ b/docs/BACKUP-RESTORE.md @@ -0,0 +1,202 @@ + + +# Backup and restore the ShopDB database + +This is the ShopDB instance itself. For the per-PC CONFIG backups the collector +takes off shopfloor machines, see [BACKUP-KINDS.md](BACKUP-KINDS.md). + +Each site owns its own data (single-tenant, ADR-004), so backups are the site's +responsibility. A complete backup is two parts, and **a database-only backup is +not a backup**: restoring the database without the matching `instance/` +directory leaves the app pointing at floor plans and logos that no longer exist. + +| Item | Location | Why | +|------|----------|-----| +| Database | MySQL `shopdb_flask` | All asset, user, audit and settings data. | +| `instance/branding/` | `instance/` dir | Uploaded logos and favicon. | +| `instance/modelimages/` | `instance/` dir | Uploaded vendor-model photos. | +| `instance/` floor plans | `instance/` dir | Uploaded map blueprints. | +| `instance/plugins.json` | `instance/` dir | Which plugins this site enabled. | +| `.env` | repo root (offline, secured) | Secrets needed to bring the stack back up. Store separately from the data backup, in a secrets manager. | + +## The shipped Docker backup overlay + +`docker-compose.backup.yml` is an opt-in per-site overlay that automates all of +the above. Turn it on with: + +```bash +docker compose -f docker-compose.yml -f docker-compose.backup.yml \ + --profile backup up -d +``` + +It adds three cooperating services: `backup-store`, a resident restic REST +server holding every snapshot in its own volume, deliberately NOT published to +the host so backups are reachable only over the compose network; `backup-watcher`, +which actuates the "Back up now" sentinel the api drops, because the api +container cannot run docker itself; and `backup`, the same one-shot run manually +(`run --rm backup`) for sites that prefer a host scheduler. + +Each run captures one recoverable set: `db.sql.gz` (a non-blocking +`mysqldump --single-transaction`), a restic snapshot of `instance/`, and +`manifest.json` recording the core and plugin Alembic revisions plus the image +tag at capture time. That manifest is what makes a restore a CHECKED operation: +`scripts/backup/restore.sh` refuses to load a set onto code whose schema is +older than the set's, rather than leaving the site half-broken with rows +referencing columns the live schema has not applied. It does NOT roll back code +- restore the code first, run `flask db upgrade` and `flask plugin upgrade-all`, +then load the data. + +Retention is restic-native: `--keep-daily 5 --keep-weekly 4 --keep-monthly 6`. +`RESTIC_REPOSITORY` defaults to the bundled store and can point at S3, Azure +Blob or a mounted share instead; credentials come from the site's env or secret +store, never committed. + +## Backing up by hand + +### Database (Docker) + +```bash +docker compose exec -T db mysqldump \ + -u root -p"${MYSQL_ROOT_PASSWORD}" \ + --single-transaction --routines --triggers \ + shopdb_flask | gzip > shopdb-$(date +%F).sql.gz +``` + +`--single-transaction` gives a consistent dump without locking the tables (InnoDB). + +### Database (external MySQL, no container) + +```bash +mysqldump -h -u -p \ + --single-transaction --routines --triggers \ + shopdb_flask | gzip > shopdb-$(date +%F).sql.gz +``` + +### instance directory + +```bash +tar czf instance-$(date +%F).tar.gz instance/ +``` + +Recommended cadence: nightly database dump to offsite storage, 14-day +retention; `instance/` captured on the same schedule (and always right before an +upgrade). Verify a restore quarterly. + +## Restore + +Restoring replaces the current database contents. Do it into a known-empty or a +throwaway target first if you are unsure. + +### Step 1: Bring up the stack (or a fresh one) + +```bash +cp .env.example .env # or restore your saved .env +# ensure MYSQL_* and DATABASE_URL match the dump's database name (shopdb_flask) +docker compose up -d db +``` + +Wait for the `db` container to report healthy (`docker compose ps`). + +### Step 2: Load the database dump + +```bash +gunzip -c shopdb-2026-07-10.sql.gz | \ + docker compose exec -T db mysql -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask +``` + +For an external MySQL: + +```bash +gunzip -c shopdb-2026-07-10.sql.gz | mysql -h -u -p shopdb_flask +``` + +If the target database does not exist yet, create it as utf8mb4 first (matching +the schema charset): + +```sql +CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +### Step 3: Restore the instance directory + +**On Docker, `instance/` is a named volume, not the host directory.** The api +service mounts `instance_data:/app/instance`, so extracting the tarball into +`./instance` on the host restores nothing the application can see. The data has +to go into the volume: + +```bash +tar xzf instance-2026-07-10.tar.gz # unpacks ./instance on the host +docker compose up -d api # creates the container and volume +docker compose cp instance/. api:/app/instance # copy into the volume +docker compose restart api +``` + +Check it landed before going on: + +```bash +docker compose exec api ls /app/instance +``` + +`plugins.json` must be there. Without it the site comes back with its plugins +disabled and database rows pointing at uploaded files that are no longer +present. + +A site that has replaced the named volume with its own bind mount +(`- ./instance:/app/instance`) restores by unpacking the tarball on the host and +nothing else. Check `docker-compose.yml` rather than assuming which one you have. + +For a non-docker deploy, unpack the tarball over the application directory: +`instance/` is a plain directory there. + +### Step 4: Reconcile migrations + +```bash +docker compose exec api flask db upgrade +docker compose exec api flask plugin upgrade-all +``` + +For a non-docker deploy, the same two commands without the prefix. + +`flask db upgrade` is a safety net: if the dump predates the current code, this +applies only the core Alembic chain. `flask plugin upgrade-all` then applies any +newer per-plugin migrations (each bundled plugin owns its own chain, ADR-008); +without it, plugin-owned tables stay un-migrated. If the dump is at the same +version both are no-ops. + +### Step 5: Verify + +- Log in with a known account. +- Confirm the floor map renders (branding and map blueprints resolve from + `instance/`). +- Spot-check a few asset records and the audit log. +- `curl -s -X POST -H "Content-Type: application/json" -d '{}' http://localhost:5001/api/auth/login | jq .` + should return a `VALIDATION_ERROR`, not a 500. + +## What is lost if `.env` is lost + +`.env` is not in the database dump, and rebuilding it is not simply retyping it: + +| Value | If it is lost | +|---|---| +| `SECRET_KEY`, `JWT_SECRET_KEY` | Every issued token and session becomes invalid. Users log in again; managed API tokens must be reissued, which means every collector client needs its key replaced. Recoverable, but it is a fleet-wide job. | +| `DATABASE_URL` password | Recoverable: reset the MySQL user's password and write the new one in. | +| `MYSQL_ROOT_PASSWORD` | Recoverable through MySQL's own reset procedure, which requires stopping the server. | +| `RESTIC_PASSWORD` | NOT recoverable. Every restic snapshot in the repository is encrypted with it; without it the backups are unreadable by anyone, including you. | +| `ZABBIX_TOKEN` and similar integration tokens | Reissue at the far end. Nothing else breaks. | + +So back it up with the database, to somewhere as protected as the dump. A dump +without its `.env` restores the data and locks everyone out of it. + +## See also + +- [BACKUP-KINDS.md](BACKUP-KINDS.md) - the other backups: per-PC config + snapshots taken by the collector. A different subject entirely. +- [DEPLOY.md](DEPLOY.md) - first-time deploy +- [UPGRADE.md](UPGRADE.md) - upgrade procedure (back up first) +- [CONFIG.md](CONFIG.md) - environment variables and Setting keys diff --git a/docs/BACKUP-STALENESS.md b/docs/BACKUP-STALENESS.md new file mode 100644 index 00000000..a7c46505 --- /dev/null +++ b/docs/BACKUP-STALENESS.md @@ -0,0 +1,104 @@ + + +# How the backups card decides good vs stale + +The `backups` plugin's health verdict, as shown on the dashboard's **Backups +stopped** card. Read it when the card looks wrong, or before changing the rule. +Adding a new kind needs none of this - see [BACKUP-KINDS.md](BACKUP-KINDS.md). + +## The trap this is built around + +**A revision is only written when the config CHANGES.** Dedup compares against +the latest revision for the chain `(asset, kind, sourcehostname)`, so a machine +whose config has been stable for six months has a six-month-old newest revision +and is perfectly healthy. The question worth answering is not "when was the last +backup taken" - it is "is this still being checked". + +So the timestamp the card uses is `backuprevisions.lastseenat`: the last time +the collector CONFIRMED this config, whether or not anything changed. It moves +on every successful collection; the revision does not. + +It is a card of exceptions rather than a column of raw dates because a raw date +read as neglect. At the default `backups_intervalhours` of **24** the collector +only attempts once a day, so a day-old confirmation IS the healthy steady state - +and the date made the reader do arithmetic against a setting they would have to +go and look up. + +## What it does now + +`GET /api/backups/dashboard/stale` returns one row per chain that has gone +quiet, worst first: + +| Field | Meaning | +|-------|---------| +| `assetid`, `assetnumber` | The asset the backup belongs to. | +| `backupkind` | Which kind stopped. Chains fail independently, so the kind is part of the identity, not a summary. | +| `sourcehostname` | The PC that was doing the collecting. | +| `lastseenat` | When this chain was last CONFIRMED still current (ISO). | +| `quietdays` | How long it has been quiet, which is what the card sorts on. | + +`lastseenat` is reported rather than the capture time because they answer +different questions and one cannot stand for both: the first proves the check is +running, the second only says what is stored. Collapsing them was what made a +healthy machine look neglected. + +## The chain is the unit, not the host + +A marker bay runs both an NTLARS backup and a part-marker backup. Reporting one +verdict per host would let a healthy kind mask a dead one: the part-marker +capture could stop for a month and the host would look fine on the strength of +its NTLARS row. So the card is keyed on the CHAIN - asset, kind and source PC +together - because that is the unit that fails independently. + +## Silence is not a verdict + +A chain whose newest revision has never been confirmed - `lastseenat` NULL, +which means a backfilled row or one written before the column existed - is not +stale, it is UNMEASURABLE, and it is skipped rather than listed. Substituting +`collectedat` for it looked right and was wrong: on a real fleet it reported +every machine whose config had simply been stable for months as a stopped +backup, so the whole board lit up and said the site was broken when it was fine. +A chain becomes measurable the first time its PC posts, and the card says +nothing about it until then. + +The card is equally silent about an asset that has NEVER been backed up. Whether +one SHOULD be is a question the asset record cannot answer - most machines carry +no NTLARS at all - and guessing would list a hundred healthy assets, which is +the noise that makes a board worth ignoring. + +Known consequence: a device retired from a PC leaves its last revision behind, +so that chain stays listed until the revision is pruned. That is the honest +failure direction - it complains about a backup that genuinely is not running +any more, rather than going quiet about one. + +The threshold is the backups plugin's own `backups_staledays` setting (default +**3**), read through `plugins.backups.services.staleness.staledays()` rather +than re-derived, so there is ONE definition of stale. `0` disables the card. + +## Why time-based rather than per-kind + +Every kind answers the same question the same way: something confirmed this +recently, or it did not. Making the rule per-kind would mean each new kind has +to define health before it can appear, for no gain. If a kind ever needs its own +window - a weekly backup that should not be judged on a 3-day rule - add a +threshold override on `BackupKind` and have the rule prefer it; the +skip-the-unmeasurable contract stays as it is. + +## Where the code lives + +- `plugins/backups/services/staleness.py` - `staledays()` (the shared + threshold), `latestperchain()` (newest revision per chain), `stalechains()` + (the verdict) +- `plugins/backups/api/routes.py` - `dashboard_stale`, a thin route over that + rule so it stays testable without an auth layer in the way +- `plugins/backups/plugin.py` - the `backups-stale` dashboard widget + declaration, including the `backups_staledays` setting + +Adding a kind is [BACKUP-KINDS.md](BACKUP-KINDS.md); backing up the ShopDB +database itself is [BACKUP-RESTORE.md](BACKUP-RESTORE.md), a different subject. diff --git a/docs/COLLECTOR-AUTH.md b/docs/COLLECTOR-AUTH.md new file mode 100644 index 00000000..b00b33fc --- /dev/null +++ b/docs/COLLECTOR-AUTH.md @@ -0,0 +1,292 @@ + + +# Collector authentication + +**This page is the one authoritative description of how a fleet client proves +who it is.** Every other page links here rather than restating it. + +There are two credentials, and which one a PC uses is a property of that PC, not +of the site: + +| | Per-PC credential | Shared collector key | +|---|---|---| +| Status | **current** | **legacy - still works, do not build on it** | +| Header | `X-Endpoint-Credential` | `X-API-Key` (or `Authorization: Bearer`) | +| Identifies | this one PC | any holder of the key | +| Issued by | enrollment, once per PC | an admin, once per fleet | +| Revocable | per PC | only fleet-wide | +| Blast radius if it leaks | one PC's report | every PC's report | + +A PC that has enrolled sends its credential and nothing else. A PC that has not +enrolled sends the shared key, exactly as the whole fleet used to. **Never both +on one request.** + +Per-PC credentials need the `endpoints` plugin on the server and an endpoint +agent (or reporter script) that knows how to enroll. A site running older +clients is on the shared key and keeps working; that is the point of keeping +both. + +> **Both halves are in this tree.** `plugins/endpoints/api/agentauth.py` resolves +> the credential and decides the shared-key fall-through, +> `plugins/endpoints/api/enrollment.py` carries enroll, approve, reject, revoke, +> allow-reenroll and the migration waves, +> `plugins/endpoints/models/credential.py` holds the three tables, and +> `shopdb/core/api/collector.py` reads the header on the generic collector +> route. The C# agent sends it +> (`agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs`) and both shipped +> PowerShell reporters fall back to the shared key on their own. The decision +> record is [ADR-019](adr/ADR-019-per-pc-endpoint-credentials.md); the wire +> contract is `plugins/endpoints/AGENT-CONTRACT.md` section 9, and the fleet +> rollout order is `plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md`. + +--- + +## 1. Which endpoints take which + +| Endpoint | Per-PC credential | Shared key | +|---|---|---| +| `POST /api/collector/computers` | yes | yes | +| `POST /api/collector/printers` | yes | yes | +| `POST /api/collector/endpoints` (and `/api/endpoints/collect`) | yes | yes | +| `POST /api/collector/` | only if that plugin's schema declares `acceptsagentcredential` | yes | +| `POST /api/collector/pc`, `/apps`, `/heartbeat`, `/bulk` | no | yes, see section 5 | +| `GET /api/collector/status` | no | yes | +| `GET /api/collector/_schemas` | no | no - JWT only, it is for humans | + +A plugin's own unattended routes are outside this table. They authenticate with +a scoped managed service token, or with a source-IP allowlist, and they accept +neither a per-PC credential nor a collector key. That is the distinction people +get wrong: the collector credential says WHICH PC is writing a report, a service +token says WHAT an integration is allowed to do. + +## 2. How the server decides + +For a collector endpoint whose plugin schema declares `acceptsagentcredential`: + +1. `X-Endpoint-Credential` present? Resolve it to an asset. Unknown or revoked + is `401 CREDENTIAL_INVALID`. The asset the credential resolves to is the asset + the request acts for, whatever hostname the body carries. +2. Otherwise fall through to shared-key auth (section 4). +3. If the shared key authenticated but the named PC **has enrolled**, the + request is refused `401 SHARED_KEY_REFUSED`. A PC with its own secret must + use it; a shared-key call in its name is an older agent or somebody else. +4. A payload that names a different asset than the credential is + `403 ASSET_MISMATCH`. + +A PC that has NEVER enrolled has no credential row, is never refused by step 3, +and keeps reporting on the shared key indefinitely. That is what carries an +un-migrated fleet. + +### Error codes + +| HTTP | Code | Meaning | What the client does | +|---|---|---|---| +| 401 | `CREDENTIAL_INVALID` | unknown or revoked credential | delete the stored credential, retry once with the shared key, wake enrollment | +| 401 | `SHARED_KEY_REFUSED` | shared key sent for an enrolled PC, or the shared key is switched off | use the credential; if there is none, enroll | +| 403 | `ASSET_MISMATCH` | the credential's asset is not the one the request names | log and stop. Do not retry the same request | +| 401 | `Invalid API key` | the shared key is wrong or missing | fix the key | +| 500 | `Collector API key not configured` | server-side: no env key set and no managed token presented | a SERVER config gap, not a client problem | + +`ASSET_MISMATCH` also covers a hostname that is on no computer record at all. +The code and status are the same because nothing changes for the client, but the +message says which of the two it is and names the hostname. An admin fixes it by +setting the hostname on the enrolled computer. + +## 3. Per-PC credentials (current) + +### Shape and storage + +A credential is `sdbe_` plus 43 base64url characters (32 random bytes). The +server stores only its SHA-256 plus the first 12 characters for display; the full +secret exists exactly once, in the enrollment response. + +On the PC it lives in +`%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin`, DPAPI +`LocalMachine`, in a folder ACL'd to SYSTEM and Administrators only. The file +records the `baseurl` it was issued for, and the credential must never be sent +anywhere else. `enroll.json` beside it holds the enrollment nonce in plain text. + +### Enrolling + +`POST {baseurl}/api/endpoints/enroll` with `hostname` and `serialnumber` +(required), plus an optional `deviceid`, an optional `agentversion`, and a +persisted `enrollnonce` of 32 to 128 characters from `A-Za-z0-9_-`. + +The route never refuses a call for its authentication. A PC installed without the +shared key still records a request an admin can approve. The shared key only +decides whether a credential is issued on the spot. + +Because it takes no auth and writes rows, it carries the two bounds an open door +needs: a per-IP fixed window of 300 calls a minute, sized for a whole shop floor +behind one address rather than for one PC, and a cap on how many PENDING rows +may exist at once (`plugin.endpoints.maxpendingenrollments`, default 500). The +cap turns away only a hostname and serial never seen before, and it drops +abandoned rows before it refuses a real PC, so neither bound can lock out a +machine already in the queue. + +| Status | Body | What it means | +|---|---|---| +| 201 | `{"status":"enrolled","credential":"sdbe_...","assetid":123}` | issued. Store it and switch headers; no restart | +| 202 | `{"status":"pending","requestid":45,"reason":"serial mismatch"}` | queued for an admin. Retry on a backoff | +| 403 | `ENROLL_NONCE_MISMATCH` | the approved request belongs to a different nonce. An admin must reject and re-approve | + +A credential is auto-issued only when ALL of these hold: the shared key +authenticated the call, `plugin.endpoints.autoenroll` is on, the hostname matches +exactly one computer, the serial on record matches (trimmed, case-insensitive), +and the asset has no active credential. Otherwise the request is pending with one +of: `unknown hostname`, `duplicate hostname`, `no serial on record`, +`serial mismatch`, `already enrolled`, `awaiting approval`, `nonce changed`, +`rejected`, `reenroll blocked`. + +There is one request row per (hostname, serial number); repeated calls update it. +The nonce is what an approval binds to, so a second machine claiming the same +hostname and serial cannot collect the approved credential. A REJECTED row binds +to the identity instead of to the nonce: any nonce from that hostname and serial +stays rejected, so a reinstall cannot argue its way back into the queue, and +approving the request is the only way out of a rejection. + +### Revoking, rotating, rolling back + +Revoking sets `revokedat` on the asset's one credential row. Re-issuing updates +that same row, so an asset never carries two credentials. + +By default a revoked PC **falls back to the shared key**. That is deliberate: a +revoke is also how an admin rotates a credential, and how a PC is rolled back to +an older agent that only knows the shared key. Denying by default would silence +that PC instead of rolling it back. + +- `blockreenroll` on the revoke stops the PC enrolling again until an admin calls + `allow-reenroll`. That is what makes a revoke stick for a compromised PC. +- `plugin.endpoints.revokeddeniessharedkey` (default **off**) makes a revoke a + real eviction: the PC reports nothing until it enrolls again. Turn it on only + once the fleet is off the shared key, because it also closes the rollback path. + +Admin API, for reference: `GET /api/endpoints/enrollment` (needs +`endpoints.fleet`), and the audited admin-role writes +`.../requests//approve`, `.../requests//reject`, +`.../assets//revoke`, `.../assets//allow-reenroll`. + +### Server pins + +A pinned agent accepts the TLS handshake if and only if the leaf certificate's +SubjectPublicKeyInfo SHA-256 is in its pin set; with no pins it requires a clean +Windows chain validation. + +`HKLM:\SOFTWARE\GE\ShopDB` `ServerPins` (REG_MULTI_SZ, either registry view) is +authoritative whenever the value exists, **even when empty** - an empty value +means "registry authoritative, no pins". Only when it is absent from both views +do the `serverpins` entries in the agent's `appsettings.json` apply. The agent +logs which source is in effect at startup and on every change, and re-reads the +set within about a minute with no restart. + +The Server Certificate settings page shows the pin; the client kit has +`Get-ShopDBServerPin.ps1` to compute one and `Set-ShopDBServerPins.ps1` to write +the set (`-None` for an empty authoritative value, `-Clear` to delete it). + +## 4. The shared collector key (legacy) + +### What counts as one + +- A **managed API token** (`shopdb_pat_...`) scoped to ONLY `collector.ingest`, + or to `collector.ingest.` for one collector. This is the form to + use. It can be minted, rotated and revoked from Settings > API Tokens, and its + use shows in `lastusedat` and the audit log. +- An **environment key**: `COLLECTOR_API_KEY_` (per plugin, + uppercased) or `COLLECTOR_API_KEY` (shared fallback). Resolution is per-plugin + first, then shared. These stay supported as a bootstrap and legacy path. + +An unscoped token, or one scoped to some other permission, is NOT a collector +credential and is rejected. `collector.ingest` gates no normal route and is +denied on every role-gated endpoint, so a scoped token that leaks can submit +collector payloads and do nothing else - not even with an admin owner, because +scoping suspends the admin bypass. + +### Header only, fail-closed + +The key travels in `X-API-Key` (Bearer also accepted for a managed token). +**The `?api_key=` querystring form was removed on every collector +endpoint** - querystring keys leak into access logs, proxy history and browser +history. A caller still putting it in the URL gets 401. + +If neither an env key nor a managed token is available server-side, every request +is refused. An unconfigured server never silently accepts unauthenticated data. + +### Minting one + +1. Settings > API Tokens > New Token. +2. Check **Restrict permissions**, tick only **Submit collector payloads (fleet + reporting)** (`collector.ingest`). Name it, optionally set an expiry, Create. +3. Copy the `shopdb_pat_...` secret, shown once, and deliver it to the fleet + (section 6). + +If you prefer a non-admin owner, create a low-privilege user whose role holds +only `collector.ingest` and let it mint its own token; the scope ceiling then +caps anything it mints. The admin-minted route is simpler and equally contained. + +### Rotating one + +1. Mint the replacement. +2. Deliver it (section 6) - one value per site. +3. Watch the new token's `lastusedat` climb and the old one's go stale. +4. Revoke the old token. Revocation is immediate. + +The endpoint agent's key has its own kill switch: Settings > Endpoint agent key +generates a key scoped to `collector.ingest.endpoints`, revokes any previous one +in the same request, and switches off the `.env` fallback for endpoints +(`plugin.endpoints.envkeyfallback`). Revoking it stops all endpoint-agent traffic +without touching the computers or printers collectors. + +### Switching it off + +`plugin.endpoints.sharedkeyallowed` (default on) gates the shared key for the +endpoints collector. Turning it off is the last step of a migration, not the +first: every PC that has not enrolled stops reporting the moment it flips. + +## 5. The four legacy core routes + +`POST /api/collector/pc`, `/apps`, `/heartbeat` and `/bulk` predate both the +generic collector contract and per-PC credentials. They take their target +hostname from the body and rewrite that PC's logged-in user, boot time, serial +number and installed apps, so the shared key is an **any-host write key** on +them and no per-PC credential narrows it. `plugin.endpoints.sharedkeyallowed` +does not reach them. + +`collector.legacysharedkeyrefused` (default **false**, today's behaviour) closes +them to the shared key; a collector-scoped managed token still works. Every +shared-key use on these routes is logged whether the gate is on or off, so read +the "Legacy collector route used with the shared key" warnings for a while first +- whatever still calls them stops working the moment it is on. Neither shipped +reporter is affected; they post to `/api/collector/computers` and +`/api/collector/printers`. + +Per ADR-006 the legacy `/pc` path is deprecated and slated for removal before +v1.0. New integrations target `POST /api/collector/`. + +## 6. Delivering the shared key to a PC + +Do NOT hardcode a key in a script that lives on a file share: a literal key there +is published to everyone with share read. In order of preference: + +1. **`HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey`**, written at imaging + time, by DSC, or by an Intune platform script. This is + where both shipped reporters look. Restrict the key's ACL to SYSTEM and + Administrators so an interactive or kiosk user cannot read it. +2. **A per-site config value** the machine account already reads - + `collectorApiKey` in `C:\Enrollment\site-config.json`. Rotation is one value + per site, no re-image and no script redeploy. +3. **A machine-local file** staged at imaging + (`C:\Enrollment\collector-api-key.txt`), ACL'd to SYSTEM. Tighter blast radius + - the key never sits on the share at all - but rotation means touching every + PC. + +Never put a token in a scheduled task's `-Argument` string: task XML is +world-readable. Never put one in a manifest: manifests sync broadly. + +A per-PC credential needs none of this. It is issued to the PC by enrollment and +never leaves it. diff --git a/docs/COLLECTOR-CLIENT-SETUP.md b/docs/COLLECTOR-CLIENT-SETUP.md new file mode 100644 index 00000000..a06ab4f2 --- /dev/null +++ b/docs/COLLECTOR-CLIENT-SETUP.md @@ -0,0 +1,147 @@ + + +# Running the collector reporters on a fleet + +Follow this to put PC reporting on a fleet that does not have it yet, or to move +an existing fleet off a classic-ASP reporter. The payload contract is +[COLLECTOR-PAYLOADS](COLLECTOR-PAYLOADS.md) and the credential choice is +[COLLECTOR-AUTH](COLLECTOR-AUTH.md); you should not need either open to work +through this page. + +Adopting the whole tool set at a plant that has none of it is a wider job - +[ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md). + +--- + +## 1. Use the shipped reporters + +Do not write your own. Two scripts ship in this repository and version with the +contract they implement: + +| Script | Reports | Posts to | +|---|---|---| +| `plugins/computers/client/Report-AssetToShopDB.ps1` | hostname, BIOS serial, pc-type, logged-in user, make/model, OS, last boot, machine number, device id, every routable NIC | `POST /api/collector/computers` | +| `plugins/printers/client/Report-PrintersToShopDB.ps1` | every real print queue on the host | `POST /api/collector/printers` | + +Both run as SYSTEM, always exit 0 so "last run result" stays clean, and log to +`C:\Logs\Shopfloor\`. Neither names a site: ADR-015 makes a site name in product +code a defect, and these ship everywhere. + +Each reads its configuration in this order: + +1. Its own parameters (`-ApiUrl`, `-ApiKey`), which a scheduled task's arguments + can supply for testing. +2. `HKLM:\SOFTWARE\GE\ShopDB` - `BaseUrl` for the server and `CollectorKey` for + the shared key. This is the secret store every shipped reporter reads, and + whatever images or configures the bay is what provisions it. + +With neither, the script logs the reason and exits without posting. + +**Which credential it sends** is decided on the PC, not in configuration: if the +PC holds a per-PC credential it is sent as `X-Endpoint-Credential` and the shared +key is not sent at all; otherwise the shared key goes as `X-API-Key`. If the +server refuses the credential as invalid or revoked, the reporter retries the +same POST once with the shared key, which is what makes a revoke a rollback +rather than an outage. + +**Shortcut for a single site:** Settings > Computers > Asset reporter generates +`Report-AssetToShopDB.ps1` with your server's URL already in the parameter +defaults, and shows its SHA-256. The key is deliberately not included. + +## 2. Prepare the server + +1. Enable the `computers` plugin (and `printers`, if you want observed queues). +2. Mint the credential the fleet will start on - a managed token scoped to + `collector.ingest`. [COLLECTOR-AUTH](COLLECTOR-AUTH.md) section 4 has the + steps. An un-enrolled fleet needs this even if you intend to move to per-PC + credentials, because enrollment itself auto-issues only to a call the shared + key authenticated. +3. Set `collector_corporate_ranges` to this site's routable CIDRs. Leave it blank + and the server falls back to the NIC the bay says carries the default route, + which is a guess on any bay with two NICs. +4. Map your imaging pc-types. Unmapped values only warn, but every report warns + until you do. + +## 3. Deliver the key to the PCs + +`HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey`, ACL'd to SYSTEM and +Administrators. How it gets there depends on what you run - an imaging task, +Intune, DSC or by hand - and the options with their trade-offs are in +[COLLECTOR-AUTH](COLLECTOR-AUTH.md) section 6. + +Two rules regardless: never bake the key into a script that lives on a file +share, and never put it in a manifest or a scheduled task's arguments. + +## 4. Schedule the reporter + +Reporting is an EVENT on a schedule, not a state to converge on. Two placements, +pick one: + +- **As a scheduled task**, registered at imaging time, by Intune, or by hand. + SYSTEM, RunLevel Highest, at startup plus an interval. +- **As an entry in whatever enforces configuration on your floor**, run off a + share every cycle with no local copy. Order the key-provisioning step BEFORE + it so the key is present the same cycle the first report fires. + +Either way, run it ALONGSIDE whatever reports today during rollout. Do not +remove the old path until the new one is proven. + +## 5. Stage the rollout + +1. Leave the existing reporting in place. Provision `CollectorKey` and enable the + plugins. +2. Add the reporter on a handful of pilot bays, one per pc-type. It cannot break + enforcement: it never throws and always exits 0. +3. Verify on the server. Pilot PCs appear or update, `action` is + `created`/`updated`, and every warning is understood - map any unmapped + `pctype`, add missing OS strings to the `operatingsystems` vocabulary. + Cross-check the client log's `OK` lines against the audit log. +4. Roll it out fleet-wide. From the `common` scope, every pc-type gets it. +5. Once ShopDB is the system of record and has survived a full imaging cycle, + retire the old reporter. +6. Migrate to per-PC credentials, then switch the shared key off. That is its + own rollout and deliberately comes last: read + [COLLECTOR-AUTH](COLLECTOR-AUTH.md) for what a PC does to enroll and what an + admin approves, and `plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md` + for the order the shared key has to be cleared out of the places it lives. + Both shipped reporters already send a credential when one is present and fall + back to the shared key when it is not, so a PC that has not enrolled keeps + reporting and nothing has to be redeployed to start. + +## 6. Verify one PC + +```powershell +Invoke-RestMethod -Uri 'https://shopdb.example.net/api/computers/by-hostname/' +``` + +The PC should exist with its serial, type and last-reported timestamp. The +client log is `C:\Logs\Shopfloor\report-asset-YYYYMMDD.log` (printer queues: +`report-printers-YYYYMMDD.log`); it records the POST line and the server's +response verbatim, including which credential it used. + +## 7. When a bay does not report + +| Symptom | HTTP | Cause / fix | +|---|---|---| +| `Invalid API key` | 401 | The `X-API-Key` header is missing or wrong. Confirm the client key matches `COLLECTOR_API_KEY_COMPUTERS` (or the shared `COLLECTOR_API_KEY`) or is the managed token you minted. Check the client actually read it. The querystring form no longer works. | +| `Collector API key not configured` | 500 | Fail-closed: no env key set server-side and the caller presented no managed token. A SERVER config gap, not a client problem. | +| `Endpoint credential is unknown or revoked` | 401 | The PC's credential was revoked, or the database was restored past its issue. The reporter falls back to the shared key on its own; let the PC enroll again to restore it. | +| `The shared key is not accepted for this endpoint` | 401 | This PC has enrolled, so it must use its own credential - or the shared key is switched off site-wide. Check that `credential.bin` is present and readable by SYSTEM. | +| `The request names a different endpoint than the credential` | 403 | The hostname in the payload is on a different asset than the credential, or on no computer record at all. The message says which. Do not retry; fix the record. | +| `No collector registered for plugin computers` | 404 | The plugin is disabled or not loaded on that instance. Enable it. | +| `hostname is required` / `No data provided` | 400 | Empty body, non-JSON body, or missing identity field. Check `-ContentType 'application/json'`. | +| `Internal error processing collector payload` | 500 | Generic by design - the server does not leak the cause. The real error is in the server log (`Collector upsert failed for computers`). | +| `queues is required; send an empty array for a host with no queues` | 400 | The printers payload omitted `queues` entirely. Absent and empty are not the same thing here. | +| A bay's observed queues all vanished | 200 | It reported `queues: []`, which legitimately clears the host. Check the client log for `posting NOTHING` (a failed enumeration correctly sends nothing) versus an empty set. If the bay really has queues, fix the enumeration on that host. | +| Observed queues show as `unknown` | 200 | The queue matched no printer asset by port address or name. Add the printer to ShopDB with its IP and re-read - matching happens at read time, so the bay does not need to report again. | +| `warnings` present but `action` is created/updated | 200 | Soft issues only; the row WAS written. Common: unmapped `pctype`, unknown `osname`, unknown app name, `pcsubtype` not stored. No action needed unless the warning matters to you. | +| The wrong IP is recorded as primary | 200 | `collector_corporate_ranges` is blank or does not cover this bay. The bay reports every NIC and takes no view; set the ranges. | + +Client-side logs are under `C:\Logs\Shopfloor\`. Server-side, the Flask app log +carries upsert failures and per-plugin schema failures. diff --git a/docs/COLLECTOR-INTEGRATION.md b/docs/COLLECTOR-INTEGRATION.md new file mode 100644 index 00000000..aefb7fdc --- /dev/null +++ b/docs/COLLECTOR-INTEGRATION.md @@ -0,0 +1,85 @@ + + +# The collector + +The collector is how the shop-floor fleet pushes what it knows about itself into +ShopDB. It replaced the classic-ASP `api.asp?action=updateCompleteAsset` path. + +This page is the shape and the guarantees. It is short on purpose: the detail +lives in three pages below, and you almost never need more than one of them. + +## Which page you want + +| You are | Open | +|---|---| +| Rolling reporting out to a fleet, or a bay is returning an error | [COLLECTOR-CLIENT-SETUP](COLLECTOR-CLIENT-SETUP.md) | +| Writing or changing a client, or answering "what does this field do" | [COLLECTOR-PAYLOADS](COLLECTOR-PAYLOADS.md) | +| Deciding how a PC proves who it is, minting or revoking a credential | [COLLECTOR-AUTH](COLLECTOR-AUTH.md) | +| Asking why the contract is shaped this way | [ADR-006](adr/ADR-006-collector-contract.md) | + +Where all of this sits relative to EventSaver, the backup collectors and the +kiosks is [FLEET-ARCHITECTURE](FLEET-ARCHITECTURE.md). + +## The shape + +One dynamic route, `POST /api/collector/`, serves every enabled plugin +that returns a collector schema. A plugin declares its identity field and its +accepted fields; the dispatcher handles auth, parsing, the audit row and the +error envelope, and the plugin handles the upsert. Adding a collector is adding a +schema, not adding a route. + +Two plugins use it on a normal fleet: + +- `computers` - what this PC is. This is the record everything else hangs off. +- `printers` - what print queues this PC actually has. + +The `endpoints` plugin adds a third, much larger payload for sites running the +endpoint agent. + +## What it guarantees + +**Idempotent.** The same report twice changes nothing. A PC is matched by its +identity field, so re-imaging a bay updates its record instead of duplicating it, +and existing asset relationships survive. + +**Patch-style, where it says so.** On the computers payload an omitted field +leaves the stored value alone, so a report from a bay that could not read its +BIOS never blanks a serial number. The printers payload is deliberately the +opposite: the reported set REPLACES the host's queues, because "what does this +bay have right now" is not a question about history. + +**Warnings, not failures, for soft problems.** An unmapped pc-type, an unknown OS +string or an unknown application name is reported back in `warnings` and the row +is still written. The alternative - refusing the report - loses the whole PC over +one unrecognised string. + +**Observed is never assigned.** Nothing a PC reports becomes a statement of what +it SHOULD have. A bay that installed the wrong printer cannot make itself right +by reporting it; turning observed state into assigned state is always a person's +decision. + +**The report says what the PC is, not where it is on the network.** The bay +reports every NIC and takes no view of which matters. Only the server knows the +site's addressing, so only the server picks. + +**Fail-closed on authentication.** A server with no credential configured refuses +every request rather than silently accepting unauthenticated data. Credentials go +in a header, never a querystring, so they stay out of access logs and proxy +history. + +## The one thing that changed recently + +A PC used to authenticate with a key the whole fleet shared. It now +authenticates as ITSELF, with a per-PC credential issued by enrollment and +revocable one PC at a time. The shared key still works for PCs that have not +enrolled, and is refused for any PC that has. + +If you are adopting this product today, plan on per-PC credentials and treat the +shared key as the bootstrap. [COLLECTOR-AUTH](COLLECTOR-AUTH.md) is the whole +story. diff --git a/docs/COLLECTOR-PAYLOADS.md b/docs/COLLECTOR-PAYLOADS.md new file mode 100644 index 00000000..d692436a --- /dev/null +++ b/docs/COLLECTOR-PAYLOADS.md @@ -0,0 +1,399 @@ + + +# Collector payload contract + +What a fleet client may send to `POST /api/collector/` and what the +server does with each field. Authentication is a separate subject and lives in +[COLLECTOR-AUTH](COLLECTOR-AUTH.md); the rationale for the contract shape is +[ADR-006](adr/ADR-006-collector-contract.md). + +Source of truth is `get_collector_schema` in each plugin +(`plugins/computers/plugin.py`, `plugins/printers/plugin.py`). If you change a +payload, change it there and re-check the table here. + +--- + +## 1. The generic endpoint + +One dynamic route serves every enabled plugin that returns a collector schema. +For the PC fleet that is `POST /api/collector/computers`. + +Request flow inside `generic_collect`: + +1. Look up the plugin's schema. Unknown or disabled plugin -> `404 No collector + registered for plugin `. +2. Authenticate (COLLECTOR-AUTH). +3. Parse the JSON body. Missing or non-object -> `400 No data provided`. +4. Resolve the identity field the schema names (`hostname` for both collectors + below). Missing or blank -> `400 is required`. +5. Idempotent upsert: the plugin's `apply_collector_payload` finds the existing + asset by identity and updates it, or inserts a new one. The same identity on a + later submission updates the same row - re-imaging a PC does not duplicate it, + and existing asset relationships are preserved. +6. Audit: every accepted submission writes an `AuditLog` row (`action` + created/updated, entity `Collector`, `details = collector: + action=`), then commits. + +### Response + +HTTP 200, in the standard envelope, with the collector result under `data`: + +```json +{ + "status": "success", + "data": { + "status": "ok", + "action": "created", + "assetid": 12345, + "identityvalue": "SHOPPC2335", + "warnings": ["unknown operating system: Windows 11 Enterprise 23H2 (build 22631)"] + }, + "message": "computers collector created", + "meta": { "timestamp": "...", "requestid": "..." } +} +``` + +`action` is `created`, `updated` or `noop`. `warnings` is a list of soft problems +(unmapped pc-type, unknown OS, unknown app, un-stored pcsubtype) that did NOT +fail the request - the row was still written. + +Errors use the same envelope with `status: "error"` and the detail under +`data.error`: + +```json +{ "status": "error", "data": { "error": { "code": "UNAUTHORIZED", "message": "Invalid API key" } }, "meta": { } } +``` + +A plugin raises `ValueError` for controlled validation failures and its message +is returned verbatim (400). Any other exception is caught, logged server-side, +the transaction rolled back, and the caller gets a generic `500 Internal error +processing collector payload` with no internal detail - by design. Check the +server log to see what actually failed. + +### Schema discovery + +`GET /api/collector/_schemas` returns the schema for every enabled plugin. It is +JWT-protected, not key-protected: it is for humans and tooling introspecting what +payloads are accepted, not for the headless collectors themselves. + +--- + +## 2. Computers payload + +Identity field `hostname`. Every other field is optional and patch-style: an +omitted field leaves the existing value untouched, so a bare report never blanks +a column. + +| Payload field | Type | Server behaviour (`apply_collector_payload`) | +|---|---|---| +| `hostname` (required) | string | Identity. Matches `Computer.hostname` (case-insensitive), then falls back to `Asset.assetnumber`. New asset created if no match. | +| `machinenumber` | string | Identifies the MACHINE this PC drives, never the PC. A new PC always takes `assetnumber = hostname`, and an existing PC's `assetnumber` is left alone. The number resolves a machine asset and builds a PC -> machine `controls` relationship; an unknown number warns rather than creating a machine. The placeholder `9999` and empty string link nothing. See section 3. | +| `deviceid` | string | Identifies the DEVICE hanging off this PC, read from `C:\Enrollment\asset-id.txt`. It names the asset and nothing else - it does NOT say what kind of device it is, because the pc-type already does - so the same file serves a part marker, a Keyence, a Genspect or anything declared later. SEPARATE from `machinenumber` on purpose: "which bay is this" and "which device is this" are different facts, and `machinenumber` is what a site's own bay-gated configuration keys off, so naming a device there would silently stop every bay-gated rule from matching. An unknown value warns rather than minting; a value resolving to the wrong KIND of asset is refused with the asset named. See section 4. | +| `measuringtoolid` | string | The 0.12.0 name for the same thing, read from `C:\Enrollment\measuringtool-id.txt`. Still accepted as an alias so a bay already staged with that file keeps reporting; `deviceid` wins when both arrive. Prefer `asset-id.txt` for anything new. | +| `pctype` | string | `gea-shopfloor-*` imaging type -> `Computer.computertypeid` via the configurable `pctypemap` settings. Unmapped value -> warning, not error. See section 5. | +| `pcsubtype` | string | Accepted but not stored yet -> warning. | +| `serialnumber` | string | `Asset.serialnumber`. | +| `loggedinuser` | string | `Computer.loggedinuser`. (`currentuser` is also accepted as a legacy alias.) | +| `lastboottime` | ISO-8601 datetime | `Computer.lastboottime`. Unparseable -> warning. | +| `lastcheckin` | ISO-8601 datetime | Accepted (heartbeat semantics). `lastreporteddate` is set server-side on every call regardless. | +| `interfaces` | array of `{ipaddress, macaddress, adaptername, isdefaultroute}` | EVERY connected hardware NIC with a routable IPv4, in no meaningful order. One `Communication` row per NIC. See "Which NIC is the PC's address" below. | +| `ipaddress` | string | The one-address form, kept for bays on an older collector. A payload carrying both wins on `interfaces`. | +| `vendorname` | string | `Computer.vendorid`. Vendor row auto-created if missing (free vocab). | +| `modelnumber` | string | `Computer.modelnumberid`, scoped to the vendor when known. Model row auto-created if missing. | +| `osname` | string | `Computer.osid`. Controlled vocab: looked up in `operatingsystems`, NOT auto-created. Unknown value -> warning (row still written, `osid` left unset). | +| `installedsoftware` | array of `{name, version}` | `ComputerInstalledApp` rows for applications shopdb already tracks. Unknown app name -> warning, skipped. | +| `accessprotocols` | array of strings | Remote-access protocols this PC actually exposes, by catalog name (`VNC`, `WinRM`, `RDP`). PRESENCE of the key drives the sync: reported protocols are activated, catalogued ones not reported are deactivated. Omit the key entirely to leave existing rows alone - most came from the legacy `isvnc`/`iswinrm` migration. | + +`defaultprinter` and `printers` are **retired**. See section 6. + +### Which NIC is the PC's address + +The bay reports every NIC and takes no view. The server picks, in this order: + +1. The first reported address inside a CIDR in the `collector_corporate_ranges` + setting. +2. Else the NIC the bay flagged `isdefaultroute`. +3. Else the first reported. + +Step 3 is a guess, and configuring the ranges is what makes it stop being one. +Choosing on the bay was the wrong place for it: a bay cannot know a site's +addressing, so a CMM with a fast link to its controller reported the machine-side +address as the PC's and never sent the corporate one at all. + +Rows are keyed on the MAC where the bay sent one, because an address moves +between adapters and a MAC does not, and fall back to the address for a bay that +reports none. Exactly one row is flagged primary, and only among the rows this +report touched. A NIC the bay did NOT report is left alone: a NIC that is down at +check-in is not evidence the record is wrong, and hand-entered detail lives on +those same rows. + +## 3. PC -> machine links, and part markers + +The reported `machinenumber` says which machine the PC drives. It is not the +PC's own identifier, and writing it as one is a defect this contract used to +describe: `assets.assetnumber` is uniquely indexed and the machine already holds +that value, so the insert failed and the bay got a 500 on every report, forever. +A PC takes its hostname as its asset number and keeps it. + +- Link: PC -> machine as `controls`, tagged + `assetrelationships.label = 'collector:machine'`. Only tagged rows are ever + archived by a push, so a link made by hand is never touched. +- Unknown number: warns and links nothing. A machine is never invented, because + a mistyped number would create equipment nobody can account for. +- A second PC reporting a machine another PC holds is a CLAIM, not a handover. + A PC imaged for a machine carries that number before it reaches the floor, so + the PC currently running the machine keeps the link while it is still alive - + reporting within 24 hours (`MACHINE_CLAIM_QUIET_HOURS`) and still `In Use`. + The challenger is recorded as a dormant link, which also marks the claim as + already announced so it does not alert every cycle. +- Handover completes on its own once the incumbent has been quiet past that + window, or immediately when someone moves it off `In Use` - which is the + one-step way to force a swap at the moment it happens. The old link is + archived, never deleted, so "which PC ran this machine in June" stays + answerable. The retired PC's STATUS is deliberately not changed: the collector + cannot tell shelved from broken from re-imaged. +- Alerts for handover and contested claims are gated on the + `computers_machinelink_alerts` setting, which ships OFF. A site may run + several PCs on one machine number legitimately, and there the alerts fire on + correct data. Warnings ride in the collector response either way. + +A PC reporting `pctype = gea-shopfloor-partmarker` is handled differently, +because several markers can serve one machine number and an operation holds any +number of them. Such a PC gets its own Part Marker machine asset +(`-PARTMARKER`), `controls` it, and the marker is filed `partof` the +operation the PC reported. Both links are tagged `collector:partmarker`. The PC +does NOT claim the operation directly: `controls` propagates through `partof`, +so control still follows from controlling the marker, without two markers +contesting a link only one can hold. Backups from a marker PC file against the +marker rather than the operation. + +That is one instance of a general pattern - several devices under one parent. +[ASSET-COMPOSITION](ASSET-COMPOSITION.md) covers when to use it, what +propagation buys you, and how to declare a new device type without a code change. + +## 4. PC -> device links (`asset-id.txt`) + +Some pc-types mean the PC drives an attached DEVICE: a part marker, or a +metrology instrument such as a CMM, Keyence, Genspect or wax-trace unit. +`SUBORDINATE_DEVICE_MAP` in `plugins/computers/pctypemap.py` declares which, and +a site can add its own through a `subordinatedevice_` setting without a +code change. + +**On this line only the part-marker case does anything.** The four metrology +pc-types declare `assettype: measuring_tool`, and the plugin that owned that +asset class is not bundled here (ADR-018: metrology instruments are machines). +The collector detects the absence, skips the link, and says so in the response +warnings - it does not fail the report, and it does not mint anything. File a +CMM as a machine and link it by hand. + +`C:\Enrollment\asset-id.txt` names that device: one line, the device's +`assetnumber`. It does NOT record what kind of device it is - the pc-type already +does - so a bay declared later needs no new file and no client change. + +Resolution runs most-stable-identity first, and ADOPTS before it mints: + +1. **`deviceid`** - the asset named in `asset-id.txt`. +2. A prior collector link from this PC, reactivated. +3. An existing device this PC already controls that the collector did NOT + create - a legacy `MT-####` row, or one somebody made by hand. Adopting + stamps the label so it is ours from then on. +4. Mint, only when none of the above matched. + +**Why the file exists, and why it is step 1.** Every other identity here is +derived from the PC. Step 2 looks for a link from THIS PC asset; step 4 mints +`-`, and a PC's asset number is its hostname. All of them +survive a re-image and NONE of them survives a PC SWAP: a new hostname is a new +PC asset with no prior link and a predicted number that has never existed, so +the same physical device gets a second record while the first keeps its config +and backup history under a dead PC's name. That is how 43 legacy `MT-####` +tools ended up shadowed by minted twins, three records deep in places. Treat +anything minted as a placeholder to be reconciled. + +Two guards on the file's contents: + +- An **unresolvable** value warns and links nothing, rather than inventing a + phantom device nobody can account for. +- A value resolving to the **wrong kind** of asset is refused, naming the asset + it hit. A machine number pasted into `asset-id.txt` would otherwise file that + MACHINE under a device label the collector also owns - a link that reads as an + instrument, or as a marker, everywhere downstream. + +A part marker additionally stays `partof` the operation from `machinenumber`, +because several markers serve one operation. Naming the marker in `asset-id.txt` +changes which marker asset the PC controls; it does not change which operation +that marker files under. + +## 5. pc-type mapping (configurable per site) + +`pctype` (e.g. `gea-shopfloor-cmm`) maps to a shopdb Computer Type through +`pctypemap_` settings. The built-in defaults in +`plugins/computers/pctypemap.py` are seeded on plugin install and the collector +reads them, so existing enrollment keeps working; add a setting of that name to +map an imaging type of your own. Unmapped pc-types produce a warning, not a +failure, because a bay must not stop reporting its inventory over a naming +question. + +## 6. PC -> printer relationships are NOT set here (retired) + +**A collector report has never been allowed to set a printer assignment, and as +of 2026-08-24 it no longer can.** `defaultprinter` and `printers` used to be +accepted on the computers payload and written as `defaultprinter`/`connectedto` +relationship rows. That contradicted "observed is not assigned" below, and this +contract carried both claims at once while the code followed the wrong one. + +If you are still sending either key: **your push still succeeds** - they are +ignored, and the response carries a warning naming them. To report what a PC +actually has, use `POST /api/collector/printers`; to turn that into an +assignment, a person calls +`POST /api/printers/assignments/seed-from-observed/` after reviewing the +comparison. + +## 7. Printers payload (observed print queues) + +`POST /api/collector/printers`. ShopDB has always known what a bay SHOULD have - +`GET /api/printers/for-host/`, applied by `Set-ShopdbPrinters.ps1` - +and has never known what it actually has. This payload is that missing half. +Same dispatcher, same auth, same audit row; only the payload and the plugin +differ. + +- Schema + apply: `get_collector_schema` / `apply_collector_payload` in + `plugins/printers/plugin.py`. +- Client: `plugins/printers/client/Report-PrintersToShopDB.ps1` (SYSTEM context, + `Type=PS1` / DetectionMethod `Always` manifest entry, logs to + `C:\Logs\Shopfloor\report-printers-YYYYMMDD.log`, always exits 0). +- Storage: `printerobservedqueues`, one row per observed queue, owned by the + printers plugin. A separate table from the assignment on purpose. + +The identity field is `hostname`. Unlike the computers payload this one is NOT +patch-style: `queues` is required, and the reported set replaces everything +previously recorded for that host. + +| Payload field | Type | Server behaviour | +|---|---|---| +| `hostname` (required) | string | Identity of the report. `COMPUTERNAME` or its FQDN; matched case-insensitively. Rows are keyed by the NAME, so a bay that reports before its PC record exists still records everything - the report resolves to an asset the moment that record appears. An unknown hostname is a warning, never an error. | +| `queues` (required) | array of objects | Every real print queue on the host. This REPLACES the host's previous set. An empty array is a valid report meaning "this bay has no queues" and clears the host's rows. A missing key is rejected (400), because one client bug that dropped the field would otherwise erase the fleet's observed state host by host. | +| `queues[].queuename` (required) | string | Windows printer name. A queue with no name is skipped with a warning; a repeated name within one report is dropped with a warning (Windows cannot hold two queues of one name). | +| `queues[].drivername` | string | Driver name verbatim, as the INF spells it. Compared against the assignment's driver to detect drift. | +| `queues[].portname` | string | Windows port name. Also tried as a match key, since a port created outside the client is usually named after the host address. | +| `queues[].portaddress` | string | `PrinterHostAddress` of a TCP/IP port - an IP or FQDN. The PRIMARY key for matching a queue to a printer asset. Omit it for a non-TCP port (USB, WSD, redirected); such a queue is still worth reporting and matches on name alone. | +| `queues[].isdefault` | boolean | True on the one queue that is the user's default. If several arrive true, the first is kept and the rest are cleared with a warning - a bay has exactly one default, and keeping both would leave a seed picking one at random. | +| `queues[].isshared` | boolean | True when the queue is shared off this PC. Recorded, not acted on. | +| `observedat` | ISO-8601 datetime | Accepted and ignored, as is any other client timestamp. The server stamps `observedat` at ingest, one value for the whole report, so a bay with a wrong clock cannot report itself fresh or stale. | + +Response `data` is the standard collector shape: `action` is always `updated` +(this endpoint replaces rows and creates no asset - calling an identical +re-report `noop` would hide that the bay is still checking in), `assetid` is the +resolved PC or `null`, `extra.queuecount` is how many rows were stored, and +`warnings` carries the soft problems above. + +``` +POST /api/collector/printers +X-Endpoint-Credential: sdbe_... +Content-Type: application/json + +{ + "hostname": "WORKSTATION01", + "queues": [ + { + "queuename": "Bay Label Printer", + "drivername": "Generic / Text Only", + "portname": "IP_192.0.2.40", + "portaddress": "192.0.2.40", + "isdefault": true + }, + { + "queuename": "Office Laser", + "drivername": "HP Universal Printing PCL 6", + "portname": "IP_192.0.2.41", + "portaddress": "192.0.2.41", + "isdefault": false + } + ] +} +``` + +### The latest report replaces the previous one + +This is CURRENT STATE, not history. Each report deletes every row previously +recorded for that hostname and writes the reported set in its place, inside the +dispatcher's transaction. Nothing accumulates, "what does this bay have" is +never a question about time, and a queue removed from a bay disappears from +ShopDB on the next cycle without anyone tidying up. + +That has one sharp edge, and it belongs to the client: an empty `queues` array +is a legitimate report that wipes the host's rows. A client whose enumeration +FAILED must therefore send nothing at all rather than an empty list. Reporting +nothing loses one cycle; reporting `[]` after a WMI hiccup deletes real state +and reads as a bay that lost its printers. `Report-PrintersToShopDB.ps1` tracks +this with an `$enumerated` flag and exits without posting when both the cmdlet +and the WMI fallback failed. + +### Observed is not assigned + +Stated plainly, because it is the whole point of keeping two tables: + +**A collector report NEVER becomes an assignment.** Nothing on this path writes +a `usesprinter` or `defaultprinter` row. The moment a drifted bay's observed +state is treated as correct, enforcement stops meaning anything - a bay that +installed the wrong printer would make itself right simply by reporting it. + +Observed state becomes assigned state only when a person asks for it, through +`POST /api/printers/assignments/seed-from-observed/` (requires +`printers.edit`), after reviewing the comparison. That route refuses rather than +guesses: it will not seed from an ambiguous host, will not seed queues that +match no printer unless told to, and will not write an empty set. + +### Matching, and what "unknown" means + +Nothing is matched at ingest - the raw observed strings are stored as reported, +and resolution to a printer asset happens at READ time, so a printer added to +ShopDB tomorrow matches yesterday's report without the bay reporting again. + +At read time each queue is matched by PORT ADDRESS first (an IP or FQDN names +one device unambiguously), then by queue name. There is no fuzzier fallback: a +queue that matches nothing is reported as `unknown` rather than guessed, because +a wrong match seeds a wrong assignment, which is worse than no assignment. + +### Reading it back + +| Endpoint | Purpose | +|---|---| +| `GET /api/printers/observed/` | What the host last reported, every queue classified against the assignment it resolves to (`matching`, `drifted`, `extra`, `missing`, `unknown`), with `observedat`, a summary, and a `seedcandidate` preview. Read-only. Needs `printers.view`. | +| `POST /api/printers/assignments/seed-from-observed/` | The one path from observed to assigned, human-triggered. Needs `printers.edit`. Normally posted against the MACHINE, so the assignment survives a reimage. | + +Server-side the printers key resolves per-plugin first: set +`COLLECTOR_API_KEY_PRINTERS` if you scope env keys per collector, or rely on the +shared `COLLECTOR_API_KEY`. A site that has scoped the computers key per-plugin +and set no printers key gets 401 here until one of the two is in place. A +per-PC credential and a `collector.ingest` managed token both work here exactly +as they do for computers. + +## 8. Porting from classic `api.asp` + +The fleet's classic-ASP reporter posts form fields to +`api.asp?action=updateCompleteAsset`. Map them to the collector JSON as follows: + +| Classic `updateCompleteAsset` form field | Collector field | +|---|---| +| `hostname` | `hostname` | +| `machineNo` | `machinenumber` | +| `deviceId` | `deviceid` | +| `pcType` | `pctype` | +| `serialNumber` | `serialnumber` | +| `loggedInUser` | `loggedinuser` | +| `lastBootUpTime` / `lastBootTime` | `lastboottime` | +| `manufacturer` | `vendorname` | +| `model` | `modelnumber` | +| `osVersion` | `osname` | +| `installedApps` | `installedsoftware` | + +Not carried over (no current home in the computers schema): warranty fields, DNC +config, VNC/WinRM flags as booleans (report them through `accessprotocols` +instead). The classic reporter's full `networkInterfaces` array maps onto +`interfaces`. diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 00000000..5fd40f41 --- /dev/null +++ b/docs/CONFIG.md @@ -0,0 +1,352 @@ + + +# Configuration reference + +**To find one setting: search this page for its exact key.** Every key appears +in exactly one table. Nothing here has to be read in order. + +Two places, and the split is deliberate: + +- **Environment variables** hold **secrets and deploy-time wiring**: database + credentials, signing keys, CORS origins, ports, API keys. Read once at boot by + `shopdb/config.py`, which is authoritative - where this page and that file + disagree, the file wins. +- **The Settings table** holds **site preferences**: branding, ServiceNow links, + search toggles, facility identity. Seeded by `flask seed settings`, edited in + the UI under Settings or the setup wizard, changeable at runtime with no + restart. `build_default_settings` in `shopdb/core/api/settings.py` is + authoritative for the defaults. + +Rule of thumb: if leaking it would be a security incident, it is an environment +variable. Never put a secret in the Settings table. + +**Part 1, environment variables:** [Flask core](#flask-core) | +[Database](#database) | [Auth rate limiting](#authentication-rate-limiting) | +[Collector ingest](#collector-ingest-adr-006) | [Zabbix](#zabbix-printer-supply-monitoring) | +[Employee directory DB](#employee-directory-database-optional-read-only) | +[docker-compose only](#docker-compose-only) + +**Part 2, Settings keys:** [site](#site) (facility name, timezone, base URL, +retention) | [branding](#branding) | [printing](#printing) (QR and label +targets) | [map](#map) (superseded) | [integrations](#integrations) +(ServiceNow, Zabbix, Dell warranty) | [email](#email) | [audit](#audit) | +[auth](#auth) (SAML placeholders) | [tools](#tools) (Secure Send) | +[identifiers](#identifiers-dynamic) | [search](#search-dynamic) | +[Custom fields](#custom-fields) + +--- + +## Part 1: Environment variables + +Copy `.env.example` to `.env` and fill in values. In `production` +(`FLASK_ENV=production`), `ProductionConfig.validate()` refuses to boot if +`SECRET_KEY`, `JWT_SECRET_KEY`, `DATABASE_URL`, or `CORS_ORIGINS` are missing or +set to the dev defaults. + +### Flask core + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `FLASK_APP` | No | `wsgi.py` | Entry point for the `flask` CLI. | +| `FLASK_ENV` | Yes | `development` | `production` for live sites (triggers `validate()`). Other values: `development`, `testing`. | +| `SECRET_KEY` | Yes (prod) | dev default | Flask session/signing key. Generate: `python -c "import secrets; print(secrets.token_urlsafe(64))"`. | +| `JWT_SECRET_KEY` | Yes (prod) | dev default | JWT signing key. Different value from `SECRET_KEY`. | +| `JWT_ACCESS_TOKEN_EXPIRES` | No | `3600` | Access-token TTL in seconds. | +| `JWT_REFRESH_TOKEN_EXPIRES` | No | `2592000` | Refresh-token TTL in seconds (30 days). | +| `CORS_ORIGINS` | Yes (prod) | `http://localhost:5173` | Comma-separated explicit origins. Wildcard `*` is rejected in production. | +| `LOG_LEVEL` | No | `INFO` | Logging verbosity. | + +### Database + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `DATABASE_URL` | Yes (prod) | dev localhost URL | `mysql+pymysql://:@:/?charset=utf8mb4`. Keep `?charset=utf8mb4`. | + +### Authentication rate limiting + +IP-based fixed-window limit on the login endpoint, defense-in-depth atop the +per-account lockout. Uses the cache extension, which is per-process, so the +limit is approximate across multiple gunicorn workers. + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `AUTH_RATELIMIT_ENABLED` | No | `True` | Set `False` to disable (TestingConfig disables it). | +| `AUTH_RATELIMIT_MAX` | No | `30` | Max login attempts per source IP per window before 429. | +| `AUTH_RATELIMIT_WINDOW_SECONDS` | No | `300` | Window length in seconds. | + +### Collector ingest (ADR-006) + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `COLLECTOR_API_KEY` | No | (empty) | Shared key for `/api/collector/*`. Endpoint fails closed (denies) when unset and no managed token is presented. Sent as the `X-API-Key` header. | +| `COLLECTOR_API_KEY_` | No | (empty) | Per-plugin override, e.g. `COLLECTOR_API_KEY_COMPUTERS`. Checked before the shared key. | + +These env keys are the bootstrap/legacy fallback. A managed API token scoped to +`collector.ingest` is preferred and is minted, rotated and revoked from +Settings > API Tokens; see +[COLLECTOR-INTEGRATION.md](COLLECTOR-INTEGRATION.md) (Managed collector tokens). + +### Zabbix (printer supply monitoring) + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `ZABBIX_ENABLED` | No | `false` | Enable the Zabbix integration. | +| `ZABBIX_URL` | No | (empty) | Zabbix API URL. | +| `ZABBIX_TOKEN` | No | (empty) | Zabbix API bearer token. | + +These are the boot-time wiring; the Settings entries (`zabbix_enabled`, +`zabbix_url`, `zabbix_token`) are preferred for runtime changes. + +### Employee directory database (optional, read-only) + +Separate HR lookup DB, read-only. This is where staff names come from: global +search resolves the employee domain through it, and the audit log uses it to +show a name instead of a bare SSO. Nothing here is required - leave it unset and +both surfaces fall back to SSOs. There is no safe default for the password; an +unset one fails loud rather than trying a guessed credential. + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `EMPLOYEE_DB_HOST` | No | `localhost` | HR DB host. | +| `EMPLOYEE_DB_USER` | No | (empty) | HR DB user. | +| `EMPLOYEE_DB_PASSWORD` | No | (empty) | HR DB password. No safe default. | +| `EMPLOYEE_DB_NAME` | No | `wjf_employees` | HR DB name. | + +### docker-compose only + +Read by `docker-compose.yml`, not by the Flask app directly. + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `MYSQL_ROOT_PASSWORD` | Yes | (none) | Root password for the bundled MySQL container. | +| `MYSQL_PASSWORD` | Yes | (none) | App-user password; must match the `DATABASE_URL` password. | +| `MYSQL_PORT` | No | `3306` | Host port for MySQL. Bound to `127.0.0.1` only. | +| `API_PORT` | No | `5001` | Host port for the API container. | + +The opt-in backup overlay adds `RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, +`SHOPDB_SITE` and optional cloud credentials - see +[BACKUP-RESTORE.md](BACKUP-RESTORE.md). + +--- + +## Part 2: Settings table keys + +Seeded by `flask seed settings` (idempotent; re-running adds anything missing). +Values are stored as strings and typed by `valuetype`. Secrets in this table +(anything whose key contains `password`, `token`, or `secret`) are masked when +read back through the API. + +### site + +| Key | Default | Notes | +|-----|---------|-------| +| `setup_complete` | `false` | Set true once the first-run wizard finishes; gates the `/setup` route. | +| `employee_directory_mode` | `selfhosted` | `external` reads a separate HR database (see `EMPLOYEE_DB_*`). No plugin on this line keeps a self-hosted directory, so `selfhosted` resolves no names - set `external`, or accept bare SSOs. | +| `audit_retentiondays` | `0` | Delete audit entries older than this many days. 0 keeps everything forever. Saving a period deletes nothing by itself: purge from this page or run `flask audit purge --apply`. | +| `site_base_url` | (empty) | Public base URL for QR codes and absolute links. Scheme + host, optionally including the subpath a site is served under (`https://host/shopdb`). Blank = use the browsing origin. | +| `facility_name` | (empty) | Facility name in the dashboard header. Blank = frontend falls back to `ShopDB`. | +| `pc_access_domain` | `device.geaerospace.net` | Domain appended to a PC hostname for remote-access links. Blank = hostname as-is. | +| `employeeid_pattern` | `^\d{9}$` | Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never 500s. | +| `printer_hostname_template` | `Printer-{ip}.printer.geaerospace.net` | Printer hostname template. `{ip}` is the dash-separated IP address. | +| `contact_email_domain` | `geaerospace.com` | Email domain appended to a support contact's SSO to build email (`sso@domain`) and Teams-chat links. Blank hides the contact action buttons. | +| `dualpath_single_machine` | `true` | Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in the machines list, dashboard/report counts, and the floor map (the secondary bay is hidden). The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. `false` lists and counts both bays separately. | +| `site_timezone` | `America/New_York` | IANA timezone for the site. Notification times are stored UTC and converted for display by `frontend/src/utils/datetime.js`, so they are entered and shown in THIS zone, not the viewer's browser zone; daily-reset notification expiry (`expirymode=dailytime`) is computed here too. Public-readable so kiosks can resolve it. | + +### branding + +Blank values fall back to the shipped GE default asset, so an un-reconfigured +install still renders. Upload replacements at Settings > Branding, which saves +them under `instance/branding/`. + +| Key | Default | Notes | +|-----|---------|-------| +| `site_logo` | `/ge-aerospace-logo.svg` | Header and login-page logo. | +| `qr_logo` | `/ge-monogram.svg` | Logo composited in printer QR labels. Blank = no overlay. | +| `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the machine badge print page. | +| `site_favicon` | (empty) | Browser tab favicon. Blank = shipped `/favicon.svg`. | +| `brand_primary_color` | (empty) | Primary brand color as a CSS color value (maps to `--primary`). Blank = built-in theme color. | +| `brand_primary_dark_color` | (empty) | Primary hover/active color (maps to `--primary-dark`). Blank = auto-derived by darkening the primary color ~15%. | +| `brand_accent_color` | (empty) | Accent color for secondary buttons and badges (maps to `--secondary`). Blank = built-in theme color. | +| `brand_sidebar_color` | (empty) | Sidebar background color (maps to `--sidebar-bg`). Blank = built-in theme color. | + +### printing + +| Key | Default | Notes | +|-----|---------|-------| +| `qr_target_printer` | (empty) | Custom URL template for printer QR labels. Blank = link to the printer page on this instance. Placeholders: `{printerid}`, `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{ip}`, `{hostname}`. | +| `qr_target_machine` | (empty) | Custom URL template for machine labels. Blank = link to the machine page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. | +| `qr_target_computer` | (empty) | Custom URL template for computer labels. Blank = link to the computer page. Same placeholders as machine. | +| `qr_target_network_device` | (empty) | Custom URL template for network-device labels. Blank = link to the device page. Same placeholders as machine. | +| `label_default_style` | `card` | Default asset-label layout when a label first opens: `card` (badge with image and identity) or `plain` (just the code and a caption). | +| `label_default_codetype` | `qr` | Default asset-label code type when a label first opens: `qr` or `barcode` (CODE128). | +| `label_default_encodes_machine` | `assetnumber` | What a machine label encodes by default. | +| `label_default_encodes_computer` | `assetpage` | What a computer label encodes by default. | +| `label_default_encodes_printer` | `assetpage` | What a printer label encodes by default. | +| `label_default_encodes_network_device` | `assetpage` | What a network-device label encodes by default. | + +Values for those four `label_default_encodes_*` keys: `assetpage`, +`assetnumber`, `serialnumber`, or `custom` (the matching `qr_target_` +template). All of it is overridable on the label page itself. + +There is no `measuring_tool` label type on this branch. Metrology instruments +are machines (ADR-018), so use the machine label keys, and the measuring-tools +plugin is not bundled here - `shopdb/core/api/settings.py` declares four +`qr_target_*` and four `label_default_encodes_*` keys to match. + +| Route | What it does | +|---|---| +| `/print/asset-label//` | One label. Public, like the other `/print/*` pages. `` is `machine`, `computer`, `printer` or `network_device`; `` is the asset's plugin id. | +| `/print/asset-label-batch/` | A multi-selection of one type onto ULINE sheets: 6-up 3 in x 3 in, or a dense 72-up mini-label, with a start-cell offset for reusing partial sheets. Reached from the "Print Labels" button on each asset list page. | + +### map + +**SUPERSEDED as of 0.11.0. Editing these four keys changes nothing on the map.** +A blueprint and its pixel dimensions are properties of a LEVEL now (ADR-017). +They are retained only so a downgrade finds the blueprint it had; the levels +migration copied their values onto the first level. Manage plans in +**Settings > Buildings and levels** - see [FLOOR-MAP.md](FLOOR-MAP.md). + +| Key | Default | Superseded by | +|-----|---------|-------| +| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | `maplevels.blueprintlight` | +| `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | `maplevels.blueprintdark` | +| `map_width` | `3300` | `maplevels.mapwidth` | +| `map_height` | `2550` | `maplevels.mapheight` | + +### integrations + +| Key | Default | Notes | +|-----|---------|-------| +| `servicenow_enabled` | `true` | Enable ServiceNow ticket recognition and links. Disabled = tickets render as plain text. | +| `servicenow_search_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. | +| `servicenow_ticket_prefixes` | `GEINC,GECHG,GERIT,GESCT` | Comma-separated prefixes recognized as ServiceNow tickets. | +| `servicenow_incident_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. Replace with a direct incident URL if your instance has one. | +| `servicenow_change_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. Replace with a direct change URL if your instance has one. | +| `zabbix_enabled` | `false` | Enable Zabbix for printer supply monitoring. | +| `zabbix_url` | (empty) | Zabbix API URL. | +| `zabbix_token` | (empty) | Zabbix API token (masked). | +| `warranty_dell_enabled` | `false` | Enable Dell warranty (service-tag) lookups. | +| `warranty_dell_clientid` | (empty) | Dell TechDirect API client id. | +| `warranty_dell_clientsecret` | (empty) | Dell TechDirect API client secret (masked). | +| `warranty_dell_tokenurl` | (empty) | Dell OAuth token URL. Blank = Dell default. | +| `warranty_dell_apiurl` | (empty) | Dell warranty API URL. Blank = Dell default. | + +### email + +| Key | Default | Notes | +|-----|---------|-------| +| `smtp_enabled` | `false` | Enable email notifications and alerts. | +| `smtp_host` | (empty) | SMTP server hostname. | +| `smtp_port` | `587` | SMTP port (587 TLS, 465 SSL, 25 plain). | +| `smtp_username` | (empty) | SMTP auth username. | +| `smtp_password` | (empty) | SMTP auth password (masked). | +| `smtp_use_tls` | `true` | Use TLS for the SMTP connection. | +| `smtp_from_address` | (empty) | From address for outgoing email. | +| `smtp_from_name` | `ShopDB` | From name for outgoing email. | +| `alert_recipients` | (empty) | Default alert/report recipients (comma-separated). | + +The mailer (`shopdb/utils/mailer.py`) reads these settings-first, falling back to +matching `SMTP_*` environment variables only when at least one of them is set. +When `smtp_enabled` is false or `smtp_host` is blank, every send is a graceful +no-op that logs a warning, so an unconfigured site never crashes. The password +is never logged. + +Three flows send mail, and **there is no scheduler in this app** - sending is +always on-demand: + +- **Welcome email**, on `POST /api/users`. Best-effort: the account is created + even if the send fails (the response carries a `warning`). The new account is + flagged `mustchangepassword`, so the frontend forces `/change-password` before + the app; changing it clears the flag and resets lockout counters. Opt out with + `sendwelcome: false` in the create body. +- **Test email**, `POST /api/settings/test-email` (`settings.edit`), behind the + "Send Test Email" button. A real SMTP error is surfaced with the password + scrubbed out. +- **Alerts and report delivery**, `POST /api/reports/email` (`reports.export`), + taking `{subject, columns, rows, intro?, to?}` and mailing the rows as an HTML + table. Recipients default to `alert_recipients`. To automate a recurring send, + point an external cron job at this endpoint with a PAT scoped to + `reports.export`. + +### audit + +Retention is `audit_retentiondays`, in the [site](#site) section. There is only +one such key; anything writing `audit_retention_days` is writing to nothing. + +### auth + +**SAML is not implemented yet.** These keys exist so a site can be configured +ahead of the feature, and the settings page says so on screen. Nothing reads +them: there is no SAML login route and no assertion is accepted, so sign-in is +local accounts only whatever is set here. In particular `saml_allow_local_login` +does NOT disable password login - the toggle is locked on until there is another +way in, because a control that appears to close the local door while it stays +open is worse than no control. + +| Key | Default | Notes | +|-----|---------|-------| +| `saml_enabled` | `false` | Reserved. Enable SAML SSO once it ships. | +| `saml_idp_metadata_url` | (empty) | Reserved. SAML IdP metadata URL. | +| `saml_entity_id` | (empty) | Reserved. SAML SP entity id. | +| `saml_acs_url` | (empty) | Reserved. SAML Assertion Consumer Service URL. | +| `saml_allow_local_login` | `true` | Reserved, and locked on. Local login cannot be turned off while it is the only way in. | +| `saml_auto_create_users` | `true` | Reserved. Auto-create users on first SAML login. | +| `saml_admin_group` | (empty) | Reserved. SAML group name that grants the admin role. | + +**Personal API tokens (PATs) have no configuration here**, and +`JWT_ACCESS_TOKEN_EXPIRES` does not apply to them. They are created and revoked +from Settings > API Tokens; the token model is documented once, in +[IMPORT-API.md](IMPORT-API.md#1-authentication). + +### tools + +| Key | Default | Notes | +|-----|---------|-------| +| `securesend_allow_guests` | `false` | Let people with no account create Secure Send links. Public (readable before login) so the create page can show a guest the form instead of a sign-in wall. Off by default: a guest secret has no creator, so the audit trail can say when and from where but never who. Receiving a secret never needs an account either way. | + +[SECURE-SEND.md](SECURE-SEND.md) is authoritative for the rest of it - guest +rate limiting, why the read fails closed, and why the size limit is not a tuning +knob. + +### identifiers (dynamic) + +One boolean key per asset identifier per asset type, keyed +`identifier___enabled` (default `true`). Admins choose which +optional identifiers show on which asset types. See ADR-001. The exact set is +generated from `IDENTIFIER_LABELS` x `IDENTIFIER_ASSETTYPES` in +`shopdb/core/api/settings.py`. + +### search (dynamic) + +One boolean key per search domain, keyed `search__enabled` (default +`true`). Toggles whether a domain appears in global search results. The set is +generated from `SEARCH_DOMAINS` in `shopdb/core/api/settings.py`. + +Search terms are matched word-wise: a multi-word query returns rows containing +EVERY word, each word anywhere in the searched fields, in any order ("CSF Roles" +matches a row with "CSF" and "Roles" in different columns). Quoting does not +force a contiguous phrase. + +## Custom fields + +Site-defined extra attributes per asset type (Settings > Custom Fields, table +`customfields`). Each field has a `searchable` flag (default off). When on, the +field's stored values are matched by global search and a hit routes to the +owning asset's detail page. The asset's `search__enabled` domain toggle +still applies, so a custom-field hit on a computer only shows when the computer +search domain is enabled. Inactive or non-searchable fields are never matched. + +--- + +## See also + +- [DEPLOY.md](DEPLOY.md) - per-site deployment runbook +- [UPGRADE.md](UPGRADE.md) - upgrading an existing site +- [BACKUP-RESTORE.md](BACKUP-RESTORE.md) - backup and restore +- `GET /api/docs` - what endpoints exist and their auth, generated from the code diff --git a/docs/CONTRACT-STABILITY.md b/docs/CONTRACT-STABILITY.md new file mode 100644 index 00000000..94915549 --- /dev/null +++ b/docs/CONTRACT-STABILITY.md @@ -0,0 +1,149 @@ + + +# Path to contract 1.0 + +This page is an honest read of how stable the plugin contract is today, for a +sister site deciding how much to build on it. It is derived from the ADRs and +the live code, not aspiration. It does NOT define the contract - what each hook +takes and returns is in [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), and the versioning +rules are in [ADR-002](adr/ADR-002-plugin-versioning.md). What it adds is a +judgement about how much weight each part will bear. + +## Current version + +The plugin contract version is declared in `shopdb/__init__.py` as +`__contract_version__`, and the current value is in +[PROJECT-MAP.md](PROJECT-MAP.md), which is generated. It is pre-1.0, which under +semver means any 0.x minor bump is allowed to break the contract, and this +project has used that latitude - see 0.19.0 below. + +The product release version (`__version__`) is a separate series with its own +bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md). +Do not pin against it for compatibility - pin against `__contract_version__`. + +### 0.x history + +Recorded in the comment block in `shopdb/__init__.py`: + +| Version | Change | Kind | +|---------|--------|------| +| 0.3.0 | `shopdb.api` expanded to the full plugin import surface (db, cache, model bases, core models, response + pagination helpers, `employee_connection`) so plugins stop importing internal core paths | additive (minor) | +| 0.4.0 | Removed the never-implemented `get_searchable_fields` hook (search is a core concern over the asset model) and wired `get_dashboard_widgets` to a real consumer (`/api/dashboard/widgets`) | pre-1.0 contract reduction | +| 0.6.0 | Added the `get_reports` hook, consumed by `GET /api/reports` to merge plugin report cards into the Reports hub | additive optional hook (minor) | +| 0.7.0 | Added the four ADR-010 frontend-contribution hooks (`get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`), consumed by the `GET /api/pluginui/*` endpoints | additive optional hooks (minor) | +| 0.9.0 | Exposed the dualpath pair-resolution helpers on `shopdb.api` for the machines plugin | additive surface (minor) | +| 0.10.0 | Added the `get_permissions` hook so plugins declare their own RBAC permissions; the catalog is resolved dynamically from core + enabled plugins | additive optional hook (minor) | +| 0.11.0 | Added `service_token_authorized(scope)` to `shopdb.api` so a plugin's unattended endpoints can authorize a scoped managed service token without importing core token internals | additive surface (minor) | +| 0.12.0 | Added the mailer helpers (`send_email`, `send_alert`) to `shopdb.api` | additive surface (minor) | +| 0.13.0 | Added the `User` model to the `shopdb.api` surface | additive surface (minor) | +| 0.14.0 | Added `send_webhook` to the `shopdb.api` surface | additive surface (minor) | +| 0.15.0 | Added `authorized_service_token` / the `SupportTeam` model to the `shopdb.api` surface | additive surface (minor) | +| 0.16.0 | Added the `get_settings_defaults` hook so a plugin declares the Setting rows it owns; the framework seeds them at install, at enable, and on `flask plugin upgrade-all`, files a first-time write under the declared category, and honours `public: True` for pages that render before login | additive optional hook (minor) | +| 0.17.0 | Added `DashboardDefault` to the `shopdb.api` surface, so a plugin can resolve a display without reaching into core | additive surface (minor) | +| 0.18.0 | Added `DISPLAY_ROLES`, `DISPLAY_ROLE_PATHS` and `normalize_display_role`, and finally exported `DashboardDefault`, which 0.17.0 imported but left out of `__all__`. The role vocabulary became the kiosk's own - `Dashboard`, `Lobby`, `3DPrintRoom` - so a plugin holding its own copy of that map reads core's instead of drifting from it | additive surface (minor) | +| 0.19.0 | **BREAKING.** `get_dashboard_widgets` returns DATA AND SHAPE, not a component name. The old shape (`name` + `component` + `size`) named a Vue component per widget, which cannot survive a lean build - a plugin's component may never be staged into the frontend bundle (ADR-013) - and five plugins were declaring widgets that pointed at components nobody had written. A card now declares `id` / `title` / `endpoint` / `render` / `severity` / `permission` / `empty` / `position`; see PLUGIN-HOOKS.md. Also added `send_upload` so a plugin serving user-supplied bytes gets the headers that keep an SVG from running as script | **contract change (minor, pre-1.0)** | +| 0.20.0 | The `Asset` model gained `levelid` (which floor plan its `mapx`/`mapy` are pixels of) and `mapverifiedat`; `Location` gained `levelid` too, and `resolve_asset_position` now returns the `levelid` belonging to whichever source supplied the coordinates. A plugin that writes a map position MUST write the level with it - a position without one renders as "level unknown" rather than being drawn on the default drawing, because a marker on the wrong floor plan looks correct (ADR-017) | additive surface (minor) | +| 0.21.0 | Added the `get_guest_grantable_permissions` hook: a plugin names the WRITE permissions a site may grant to the seeded `guest` role, which is who anyone without a token now holds. Reads (`.view`/`.read`) are grantable without a plugin saying anything; anything else is refused. Also exposed `require_any_permission` on `shopdb.api` for a read two different rights should reach | additive optional hook + surface (minor) | +| 0.22.0 | Added the `register_import_hook` surface: a plugin owns a VIRTUAL COLUMN on an importable CSV table - a header the bulk importer accepts but never writes - plus a post-write row hook that turns the value into a plugin concept. The computers plugin uses it for `machineassetnumber` on `computers.csv`, which builds the PC->machine controls link, so a site writes the machine number in the PC's own row instead of hand-building a separate relationships sheet. Core never learns what the column means. `RowWarning` is exported with it so a hook can report a soft failure without aborting the import | additive surface (minor) | +| 0.23.0 | Added `mint_service_token`, `list_service_tokens` and `revoke_service_tokens` so a plugin can provision, list and revoke a single-scope managed service token without importing core token internals. The endpoints plugin uses them for a UI-managed, regenerable agent key scoped to `collector.ingest.endpoints` alone - narrow on purpose, so rotating or revoking it cannot disturb the computers collector, and revocation is an instant kill switch on agent ingest | additive surface (minor) | +| 0.24.0 | Added `collector_authorized(pluginname)` so a plugin can gate an agent-facing route that lives OUTSIDE the generic collector blueprint on the same per-plugin key and kill switch the ingest route uses. The endpoints command channel needs it: the agent long-polls `/api/endpoints/commands/next` and posts results back, and those are agent callbacks rather than JWT routes. Without it a plugin would reach into core collector internals to authorize them | additive surface (minor) | +| 0.25.0 | Added `caller_holds(permission)`, which answers whether the current caller holds a permission WITHOUT refusing the request - the complement to `require_permission` for a route that serves everyone but should only offer what the caller can act on. The endpoints services panel is the case: it emitted Start/Stop row actions to every reader, because the emission never consulted the caller. Clicking them was always refused server-side, so nothing was exploitable, but offering a read-only viewer controls over a shop-floor PC is the wrong signal, and it becomes a real problem once a site grants that panel to guests. Guests are included in the answer, since a signed-out visitor holds whatever the guest role was granted | additive surface (minor) | +| 0.26.0 | Dashboard cards may declare `shortname`. The dashboard merged its per-source cards into one worst-first queue, and a card's `title` is written as a heading ("Endpoints with crashes or shutdowns") which as a row badge ran wider than the hostname it qualified. Optional, defaults to the title, so a plugin that does not declare one is unaffected | additive field (minor) | +| 0.27.0 | Added `Script` so a plugin can read the ShopDB-hosted script library that backs endpoint `run_script` delivery without importing core model internals. The endpoints plugin resolves a delivered script by name and serves its body by digest to the agent | additive surface (minor) | +| 0.29.0 | Added an optional `group` to `get_asset_panels`, naming which detail-page tab a panel belongs to: `details` (the default) / `monitors` / `software` / `activity`. The detail pages are tabbed, and a page that placed panels by knowing their ids would defeat the hook. Additive: an absent or unrecognised group resolves to `details` server-side, so every existing plugin keeps working with no change | additive surface (minor) | +| 0.28.0 | Added `client_ip` and `strip_port` so a plugin does not hand-roll caller-IP extraction. Two plugins had, and both read the first `X-Forwarded-For` hop, which the caller supplies - so a rate limit keyed on it was defeated by rotating the header. The helper resolves from `remote_addr`, which ProxyFix has already filled from the header when `TRUSTED_PROXY_COUNT` says a proxy is really in front | additive surface (minor) | +| 0.31.0 | The asset-panel vocabulary a plugin may declare grew four optional things: two more tab groups, `serviceprocess` and `reports`, beside the 0.29.0 set; a `format: 'datetime'` on a column or field, so a panel stops shipping pre-formatted date strings the browser cannot re-localise; a `map.body` key, so a keyvalue field can name where its value lives in a nested payload; and a `tabs` section that carries `columns`/`rows`/`empty` and renders as a table, so one card can hold two tables that share no key (volume capacity and physical drive health are the case). All optional and all resolved server-side when absent, so a plugin declaring none of them is unaffected | additive surface (minor) | + +The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, +0.11.0, 0.16.0, 0.18.0 and 0.19.0; 0.12.0 through 0.15.0 and 0.17.0 are recorded +in this table only. + +**0.19.0 is the one to read before pinning.** It is the only entry in this +series that took something away, and it shipped before it was recorded here - +which is precisely the reason to pin tight rather than to trust that a minor +bump is safe. Earlier points +(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback +`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the +0.2 baseline. + +## Settled surface + +These are unlikely to break before 1.0. A plugin can depend on them with +reasonable confidence; a breaking change to any would be a major bump and would +land with a new or amended ADR. What each one does is in +[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md); this is only the stability judgement. + +- The required hooks and the manifest schema: `meta` / `PluginMeta`, + `get_blueprint`, `get_models`. +- Plugin plumbing: `init_app`, `get_cli_commands`, `get_services`, the settings + helpers (`get_setting` / `set_setting`), and the four lifecycle hooks. +- The UI-contribution hooks: `get_navigation_items`, `get_dashboard_widgets`, + `get_reports`, `get_permissions`, and the ADR-010 set (`get_settings_cards`, + `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`). +- The collector pair, `get_collector_schema` + `apply_collector_payload`, per + [ADR-006](adr/ADR-006-collector-contract.md). +- The setup-wizard declarations: `get_provisioning_note`, `get_config_schema`. +- The `shopdb.api` import surface itself: adding a name is minor, removing one + is major. + +The asset model itself - `Asset`, `AssetType`, `AssetStatus`, +`AssetRelationship`, and the shared reference models - is the platform contract +locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md). + +## Expected churn before 1.0 + +Known-unstable areas. Building on these means expecting rework. + +| Area | Status | Reference | +|------|--------|-----------| +| Frontend renderers (residual) | The four data-only hooks and their `/api/pluginui/*` consumers are settled (0.7.0). The generic core renderers are landing incrementally: the settings-cards rail/landing renderer ships with 0.7.0; the asset-panel, map-overlay, and search-presentation renderers are wired opt-in per the ADR adoption plan. Real component-backed panels (bespoke charts, custom overlays) remain deferred to Option C (build-time glob discovery). | [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) (ACCEPTED) | +| Per-plugin migrations | Brand new. The per-plugin Alembic engine exists and every bundled plugin now carries a chain, but the pattern has one release of production mileage, not years. | [ADR-008](adr/ADR-008-plugin-migration-ownership.md) (2026-07-10) | +| Pip distribution | Deferred to v2. External plugins install by clone / submodule / symlink; there is no entry-point discovery and no automatic update path yet. | [ADR-003](adr/ADR-003-plugin-distribution.md) | + +## Bump rules + +From [ADR-002](adr/ADR-002-plugin-versioning.md), applied to `__contract_version__`: + +| Bump | Trigger | +|------|---------| +| major | Breaking change to the `BasePlugin` ABC, the `PluginMeta` schema, or any model in the platform contract (`Asset`, `AssetType`, `AssetStatus`, `AssetRelationship`, `Vendor`, `Location`, `BusinessUnit`, `Model`, `OperatingSystem`). Removing a name from `shopdb.api` is major. | +| minor | Additive change: a new optional hook, a new field on a contract model with a default, a new name added to `shopdb.api`. | +| patch | Bug fix with no change to the contract surface. | + +### Deprecation policy + +ADR-002 defines the bump classification above but is **silent** on any +deprecation window or notice period for pre-1.0 removals. In practice a removal +is simply a major (or, pre-1.0, a breaking minor) bump: the hook or name is +gone, and the loader fails loud in dev or excludes the mismatched plugin in prod +(see the load-time table in [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md)). +Because the ADR says nothing about a grace period, this document proposes none; +the honest guidance for sister sites is the mitigation that already exists - pin +a tight `core_version` range and re-test before widening it. + +## Criteria for declaring 1.0 + +Provisional. This is the maintainer's working list, not a committed checklist, +and it will move. ADR-002's own open question ("When does the framework declare +1.0.0?") ties 1.0 to the `Machine` retirement from ADR-001 and the framework +being "ready for sister sites"; the items below expand that intent. + +- Frontend hook contract defined via its own ADR (asset-detail panels, map + markers, search results), closing the biggest churn item above. +- `Machine` / legacy-model retirement complete per ADR-001, so the asset model + is the only contract. +- At least two external plugins running in production at a second site - the + bar ADR-003 already sets before pip distribution and sister-site readiness + are considered justified. +- The per-plugin migration pattern (ADR-008) proven across real upgrades, not + just fresh installs. +- No contract bump needed for N consecutive product releases (N to be fixed + when the list is firmed up), showing the surface has actually settled. + +Until then: pre-1.0, pin tight, re-test on every framework bump. diff --git a/docs/CSV-IMPORT.md b/docs/CSV-IMPORT.md new file mode 100644 index 00000000..a0f926ae --- /dev/null +++ b/docs/CSV-IMPORT.md @@ -0,0 +1,139 @@ + + +# Loading a site's data from spreadsheets + +**Four import pages.** Spreadsheet and no developer: this page. The HTTP wire +contract: [IMPORT-API](IMPORT-API.md). How to shape a per-site loader around +it: [IMPORT-ADOPTION](IMPORT-ADOPTION.md). Source is the classic-ASP shopdb: +[IMPORT-LEGACY-ASP](IMPORT-LEGACY-ASP.md) on top of the contract. + +--- + +## The short version + +```bash +cd C:\shopdb-flask # or your install directory +venv\Scripts\flask csv templates --out csv-templates +``` + +Fill in the templates. Then: + +```bash +venv\Scripts\flask csv import --dir csv-templates +``` + +That **checks only** and changes nothing. It tells you what it would create and +what is wrong. When you are happy: + +```bash +venv\Scripts\flask csv import --dir csv-templates --commit +``` + +--- + +## Write names, not numbers + +This is the part that makes the difference. Every column that points at another +table accepts the **name** of the thing: + +``` +assetnumber,name,assettypeid,statusid,locationid +CMM-01,Zeiss Contura,Machine,Active,Gage Lab +MILL-07,Haas VF-2,Machine,Active,Bay 3 +``` + +`assettypeid` gets `Machine`. `locationid` gets `Gage Lab`. You never +have to import a file, read back the numbers it generated, and paste them into +the next one. + +The column keeps its database name so it matches the rest of the system, but +the value is whatever you actually know. Numeric ids still work if you have +them - useful when re-importing something this system exported. + +Names resolve **across files in the same run**, so `assets.csv` can reference a +location that only exists because `locations.csv` was loaded moments earlier. + +## Nothing is half-imported + +Every row is checked before anything is written. If one row is wrong, nothing is +written at all - you fix the file and run it again. A mistake on line 400 never +leaves 399 rows loaded. + +## Running it twice is safe + +Each file is matched on a natural key - `assetnumber` for assets, `locationname` +for locations, and so on. Re-importing an edited file **updates** those rows +rather than creating second copies. Correcting a spreadsheet and re-running is +the expected workflow, not a mistake. + +## What the errors look like + +``` + assets: 0 new, 0 updated, 1 problem(s) + line 4, column 'locationid': nothing in locations is named 'Bay 9' + - add it to locations.csv, or import that file first +``` + +Line, column, value, and what to do. Not a foreign key constraint violation. + +--- + +## What you can import + +Fourteen tables. `flask csv templates` writes one file per table and `--dir` +imports them in dependency order, which is the order the generated folder lists +them in: assetstatuses, assettypes, locationtypes, modeltypes, computertypes, +machinetypes, businessunits, locations, vendors, models, operatingsystems, +assets, computers, machines. The authoritative list, with each table's natural +key, is `IMPORTABLE` in `shopdb/core/services/csvimport.py`. + +You only need the files you have; skip any you do not care about. Use `--file` +with `--table` for a single file out of order. + +**User accounts are deliberately not importable.** Passwords do not belong in a +spreadsheet, in either direction. Create the first administrator through the +first-run page and the rest in the application. + +## The templates are generated, not maintained + +`flask csv templates` builds them from the live database schema each time, so +every column offered exists, every required one is marked, and every foreign key +says which file it refers to. Do not hand-maintain a template set: the one that +was tried had invented columns on seven of eleven tables and named a table that +does not exist, while looking entirely plausible. A test fails the build if the +generated set ever drifts from the schema. + +## Editing the files + +- **UTF-8**, no BOM. Excel: "CSV UTF-8 (Comma delimited)". +- Lines starting with `#` are ignored, so the notes at the top of each template + can stay where they are. +- **The example rows are not comments. Delete them.** Each template carries a + worked example set as LIVE rows, so that a freshly downloaded folder imports + cleanly as a demo. Import it with your own data still in it and you get + `PC-001`, `CNC-014`, `Dell` and `Bay 3` in your database. The template says so + in its own header. +- Booleans are `1` or `0`. +- Dates are `YYYY-MM-DD` (`2026-08-04`). `YYYY-MM-DD HH:MM:SS` also works, as do + `DD/MM/YYYY` and `MM/DD/YYYY`. +- Leave a cell **empty** for "no value". Not `NULL`, not `N/A`. +- Quote anything containing a comma: `"Bay 3, North"`. + +## If it will not run + +**"the 'assets' table does not exist in this database"** - the schema has not +been created. Run `flask db upgrade` first, and check `DATABASE_URL` points at +the site you meant. + +**"unknown column(s): ..."** - a column that does not exist, usually from an +older template. Regenerate with `flask csv templates`; the message lists what +the table does accept. + +**"required column(s) missing: ..."** - a column that must be present has been +deleted from the header. Regenerate and copy your data across. diff --git a/docs/DEPENDENCY-SECURITY.md b/docs/DEPENDENCY-SECURITY.md new file mode 100644 index 00000000..ab18106e --- /dev/null +++ b/docs/DEPENDENCY-SECURITY.md @@ -0,0 +1,108 @@ + + +# Dependency security + +Two scripts, one question each. + +| question | script | output | +|---|---|---| +| what are we running | `scripts/generate_sbom.py` | `sbom.cdx.json`, CycloneDX 1.6, shipped with every release | +| are we exposed | `scripts/scan_sbom.py` | a report, and an exit code | + +The SBOM was already generated and installed onto every server. Nothing read +it, so "are we exposed to this advisory" was still a person going and looking. +The scanner closes that. + +## Running it + +```bash +venv/bin/python scripts/scan_sbom.py # report, exit 0 +venv/bin/python scripts/scan_sbom.py --gate # exit 1 on a shipped finding +venv/bin/python scripts/scan_sbom.py --json out.json # machine-readable too +``` + +With no `--sbom`, it generates one first, so there is never a stale input. + +It needs outbound HTTPS to `api.osv.dev`. **Run it where the repository lives, +not on a customer's server** - a deployed instance is frequently air-gapped and +has nothing to query. That is also why the SBOM ships as a file: it is the +artefact a security team scans on their own terms. + +## Why OSV + +It takes a purl and returns the advisories that apply. No account, no API key, +no agent installed anywhere, no vulnerability database to keep current. The +SBOM is already the input it wants, so this is a lookup against what shipped +rather than a second scan of the tree that could disagree with it. + +## Shipped and build-only are not the same finding + +Two thirds of the components in the SBOM are build tooling - bundlers, test +runners, their transitive dependencies. They never reach a server, and the SBOM +already marks them `scope: excluded`. + +Both are reported. **Only shipped components can fail a build.** + +This is deliberate. At the time of writing, the highest-severity match in the +whole tree is a CRITICAL against the test runner, which is not installed +anywhere a release reaches. A gate that fails on that teaches everybody to pass +`--no-verify` within a month, and then it catches nothing at all. + +## Severity is taken, not computed + +Where an advisory records a plain word - GHSA gives LOW, MODERATE, HIGH, +CRITICAL - that word is used. Where there is only a CVSS vector, the report says +UNKNOWN rather than parsing the vector into a score it might get wrong. + +UNKNOWN is shown and never gates. A guessed score is worse than an honest gap, +because somebody would act on it. + +## The allowlist is a record, not a mute button + +`scripts/vulnerability-allowlist.json` holds advisories that were reviewed and +accepted. An entry carries the advisory id, the reason, who accepted it, the +date, and what would invalidate the reasoning. + +A good reason names the precondition that is absent. The file has held exactly +one entry so far, and how it ended is the point. + +A PyJWT advisory rated HIGH - algorithm confusion, forged HS256 tokens - needs a +verifier configured with both symmetric and asymmetric algorithms AND a raw JSON +JWK passed as the key. This application configures neither: there is no +`JWT_ALGORITHM` or `JWT_DECODE_ALGORITHMS` anywhere in the tree, so +flask-jwt-extended runs HS256-only against a string secret. That was written +down, with `invalidatedby` recording what would make it untrue - SSO issuing +tokens verified against a JWK. + +Then PyJWT was upgraded, the advisory stopped matching, and the entry was +deleted. The allowlist is empty again, which is the state to aim for. + +`invalidatedby` is the field that earns its keep. A reason stops being true when +the code that made it true changes, and nobody re-reads an allowlist looking for +that. Write down what would break it. + +Upgrading is almost always cheaper than defending a decision to skip it. Do not +add an entry to make a build pass. + +## What the report tells you to do + +The report ends with a per-component upgrade rollup: the smallest upgrade that +clears every advisory against that component, and how many it clears. + +"Smallest" is doing real work there. An advisory patched on several release +branches lists a fix per branch - nanoid's is fixed in both 3.3.18 and 5.1.16 - +and taking the highest recommends a major upgrade when a patch on the current +branch would do. Where the package is transitive that major may not even be +reachable: nanoid arrives under postcss, pinned `^3.3.11`, a range 5.x cannot +satisfy. So the target is the lowest published fix above the version in use. +Fixes on older branches are ignored rather than offered as a downgrade. + +A target on a different major version is still labelled `MAJOR`, but now that +label means something: no fix exists on the current branch, so the upgrade is +genuinely a different job - it can be ESM-only, or drop an API. diff --git a/docs/DEPLOY-AIRGAP.md b/docs/DEPLOY-AIRGAP.md new file mode 100644 index 00000000..fda3d0a7 --- /dev/null +++ b/docs/DEPLOY-AIRGAP.md @@ -0,0 +1,180 @@ + +# Air-gapped Docker deploy + +> **Topology: Docker, at a site with no internet at all.** The connected +> equivalent is [DEPLOY](DEPLOY.md). +> **Where this sits:** **install** -> operate -> upgrade. +> Later versions: [Upgrading to a new version](#upgrading-to-a-new-version) on +> this page, not [UPGRADE](UPGRADE.md), which assumes a checkout to pull. + +The site cannot `pip install`, `npm ci`, or `docker pull`, so nothing is built +or pulled there. You build a fully self-contained image on a **connected** box, +ship it as one tarball, and `docker load` + run it at the site. + +`docker-compose.airgap.yml` is the counterpart to `docker-compose.yml` and +differs in four ways that matter: + +- **`image:` not `build: .`** - the site runs a loaded image; it never builds. +- **No `./plugins` bind mount** - the image already carries every plugin baked + in. Binding a host `./plugins` (which does not exist at an image-only site) + would mask the baked plugins with an empty dir and load **zero** plugins. +- **A one-shot `migrate` service** runs `db upgrade` + `plugin upgrade-all` + + seed before `api` starts, so `up -d` alone brings up a working site. +- **No `./certs` mount and no TLS flags** - `api` runs the image's own gunicorn + command and serves plain http on 5001. Put TLS in front of it, or in the + container the way `docker-compose.yml` does. + +**A ready-made package exists too.** `scripts/build-channel-installer.ps1` +wraps this same tarball with `docker-compose.airgap.yml`, a pre-filled `.env` +and an `Install-ShopDB.ps1`, for an operator who has no repository at all. Read +the script's own header for what it expects on the build box. + +--- + +## 1. Build the bundle (connected box) + +On a box with clean access to Docker Hub + PyPI + npm, from the repo root: + +```powershell +pwsh scripts/build-offline-bundle.ps1 -Version +``` + +This builds `shopdb-flask:` (frontend + all Python deps baked in), pulls +`mysql:8.4`, and writes: + +- `shopdb-stack-.tar.gz` - both images in one archive +- `shopdb-stack-.tar.gz.sha256` - checksum to verify after transfer + +The image is self-contained: **no pip/npm/registry access is needed at the +site.** + +### Building behind an inspecting proxy + +If the build box is itself behind an inspecting corporate proxy, the in-build +`pip`/`npm` fails with `CERTIFICATE_VERIFY_FAILED`: the build container has its +own certificate store, and the host's `PIP_CERT` / `NODE_EXTRA_CA_CERTS` do NOT +carry into a `docker build`. Easiest fix is to build from a box with clean +internet. Otherwise the corporate root CA must be trusted **inside** the build, +which is a Dockerfile change and is not wired today - see +[DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md) section 0b for the all-roots PEM +bundle. + +--- + +## 2. Transfer + verify + +Carry `shopdb-stack-.tar.gz` (+ the `.sha256`), plus +`docker-compose.airgap.yml` and `.env.example`, to the site on approved media. +Verify the archive survived the trip: + +```powershell +# PowerShell +(Get-FileHash shopdb-stack-.tar.gz -Algorithm SHA256).Hash.ToLower() +# compare against the .sha256 file +``` + +```bash +# Linux site +sha256sum -c shopdb-stack-.tar.gz.sha256 +``` + +--- + +## 3. Load + run (air-gapped site) + +```bash +# 1) Load both images into the local Docker. +docker load -i shopdb-stack-.tar.gz +docker image ls | grep -E 'shopdb-flask|mysql' # confirm both present + +# 2) Configure the site. +cp .env.example .env +# Edit .env - REQUIRED: +# IMAGE_TAG= # MUST match the loaded image tag +# MYSQL_ROOT_PASSWORD=... # strong, unique +# MYSQL_PASSWORD=... # strong, unique (the app's db user) +# SECRET_KEY=... # 32+ random bytes +# JWT_SECRET_KEY=... # 32+ random bytes, different from SECRET_KEY +# CORS_ORIGINS=https://shopdb.site.example # the site's browser origin(s) +# Optional: API_PORT (default 5001), LOG_LEVEL, ZABBIX_URL/ZABBIX_TOKEN. + +# 3) Bring it up. The migrate one-shot runs the schema + seed, then api starts. +docker compose -f docker-compose.airgap.yml up -d + +# 4) Watch the one-shot finish (it exits 0 when the schema + seed are done). +docker compose -f docker-compose.airgap.yml logs -f migrate +``` + +`IMAGE_TAG` in `.env` must equal the loaded tag; otherwise compose looks for an +image that was never loaded and `api`/`migrate` will not start. +`docker-compose.airgap.yml` falls back to `0.7.0` when `IMAGE_TAG` is unset, +which is almost certainly not the version you just built - so set it, rather +than relying on the default. + +--- + +## 4. Create the first admin + +Seeding creates permissions, settings, and reference data, but not a login. Make +one admin (choose the password; it is not automatable): + +```bash +docker compose -f docker-compose.airgap.yml exec api \ + flask seed admin --username --email --password +# Omit --password to have a strong one generated and printed once. +``` + +--- + +## 5. Verify + +```bash +docker compose -f docker-compose.airgap.yml ps # db + api "running", migrate "exited (0)" +docker compose -f docker-compose.airgap.yml logs api # gunicorn started, no tracebacks +curl -sf http://localhost:${API_PORT:-5001}/api/dashboard/health # or browse the site origin +``` + +Then log in at the site origin with the admin created in step 4. + +--- + +## Upgrading to a new version + +Build a new bundle on the connected box (`-Version `), transfer, then at the +site: + +```bash +docker load -i shopdb-stack-.tar.gz +# set IMAGE_TAG= in .env +docker compose -f docker-compose.airgap.yml up -d +``` + +The `migrate` one-shot re-runs `db upgrade` + `plugin upgrade-all` + seed (all +idempotent) against the existing data before the new `api` starts. Back up first +([BACKUP-RESTORE](BACKUP-RESTORE.md)); the `db_data` volume persists across +upgrades. + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +| --- | --- | +| `service api is not running` / `manifest ... not found` | The image was not loaded, or `IMAGE_TAG` in `.env` does not match a loaded image. `docker image ls`, fix `IMAGE_TAG`. Also: never use `docker-compose.yml` here - its `build: .` needs internet. | +| Site loads but **no plugins / empty nav** | You used the wrong compose file. `docker-compose.yml` bind-mounts `./plugins` (absent here) over the baked plugins. Use `docker-compose.airgap.yml`. | +| `api` never starts, `migrate` shows an error | Read `logs migrate`. A DB-connection error means `db` is not healthy yet (`logs db`) or `MYSQL_PASSWORD` in `.env` differs from what the `db` volume was first initialised with. A fresh site with a stale `db_data` volume needs the volume removed (`docker compose ... down -v` - DESTROYS data). | +| `SECRET_KEY must be set` (and similar) on `up` | A required `.env` var is empty. Fill every REQUIRED key in step 3. | +| Build fails on the connected box at `pip`/`npm` | Proxy certificate - see "Building behind an inspecting proxy" above. | + +## See also + +- [DEPLOY](DEPLOY.md) - the connected stack, the environment variables, and the + reverse-proxy and client-address reference +- [BACKUP-RESTORE](BACKUP-RESTORE.md) - back up before every image swap +- [CONFIG](CONFIG.md) - what each `.env` value means diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 00000000..f83788f7 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,500 @@ + +# Deploy a site with Docker (connected facility) + +> **Topology: Docker, at a site with network access. This is the path a new +> site takes,** and the only one this line ships. No internet at all -> +> [DEPLOY-AIRGAP](DEPLOY-AIRGAP.md). +> **Where this sits:** **install** -> operate -> update -> upgrade. +> Forward: [UPGRADE](UPGRADE.md) for later versions, +> [BACKUP-RESTORE](BACKUP-RESTORE.md) for day-to-day custody of the data. + +shopdb-flask is single-tenant per ADR-004. Each adopting facility runs its own +stack: own database, own users, own enabled plugins, own secrets. This page is +the runbook for a fresh site deploy that has network access, and it is also the +reference for the environment variables and for how the application is published +and learns who is calling it - other pages link here rather than restate it. + +### Which compose file + +| File | For | +|---|---| +| `docker-compose.yml` | this page: a site that can build the image from source | +| `docker-compose.prebuilt.yml` | the same stack built from an already-compiled `frontend/dist/`, for a box where npm cannot reach the registry. See [DOCKER-PREBUILT-APPROACH](deployment/DOCKER-PREBUILT-APPROACH.md) | +| `docker-compose.airgap.yml` | [DEPLOY-AIRGAP](DEPLOY-AIRGAP.md): runs a loaded image, never builds, and migrates itself with a one-shot `migrate` service | +| `docker-compose.pull.yml` | dev and pilot workstations that pull a published channel image instead of building | +| `docker-compose.channel.yml` | overlay adding `SHOPDB_ENV_LABEL` so a stable and an edge stack can run side by side | +| `docker-compose.backup.yml` | opt-in backup overlay (restic repository, watcher, one-shot). See [BACKUP-RESTORE](BACKUP-RESTORE.md) | + +## Prerequisites + +- Docker 24+ and Docker Compose v2 (or equivalent container runtime) +- A reverse proxy with TLS termination (nginx, traefik, Caddy, a corporate load balancer), or the certificate pair described in Step 2 - the application itself is not a TLS terminator by design +- A MySQL backup destination (offsite recommended) +- A clone of the repository (the product is distributed as source, not as a public image) + +## Step 1: Clone and configure + +```bash +git clone /shopdb-flask.git +cd shopdb-flask +cp .env.example .env +``` + +Edit `.env`: + +| Variable | Required | Notes | +|----------|----------|-------| +| `FLASK_ENV` | Yes | `production` for live sites | +| `SECRET_KEY` | Yes | `python -c "import secrets; print(secrets.token_urlsafe(64))"` | +| `JWT_SECRET_KEY` | Yes | Same generation, different value | +| `DATABASE_URL` | venv only | `mysql+pymysql://shopdb:PASSWORD@db:3306/shopdb_flask?charset=utf8mb4`. Under this compose file the services build their own from `MYSQL_PASSWORD` and never read this line - see the note below | +| `CORS_ORIGINS` | Yes | Comma-separated explicit origins. Wildcard rejected. | +| `MYSQL_ROOT_PASSWORD` | Yes | Container only | +| `MYSQL_PASSWORD` | Yes | Container only, must match `DATABASE_URL` password | +| `MYSQL_PORT` | No | Default 3306 | +| `API_PORT` | No | Default 5001 | +| `LOG_LEVEL` | No | Default INFO | +| `ZABBIX_URL`, `ZABBIX_TOKEN` | No | Only if printers plugin uses Zabbix | +| `COLLECTOR_API_KEY` | No | Shared key for `/api/collector/*` ingest. Required only if unattended collectors push data. Endpoint fails closed (denies) when unset. | +| `COLLECTOR_API_KEY_` | No | Per-plugin override (e.g. `COLLECTOR_API_KEY_COMPUTERS`), checked before the shared key (ADR-006) | +| `EMPLOYEE_DB_HOST/USER/PASSWORD/NAME` | No | Read-only HR directory for notifications + kiosks. No safe default for the password. | +| `TRUSTED_PROXY_COUNT` | No | venv/IIS only. Under compose it is pinned to 1 for the bundled nginx. See [Step 7](#step-7-tls-and-the-proxy-that-is-already-there) | + +[CONFIG](CONFIG.md) and `shopdb/config.py` are authoritative for every other +variable and its default. + +**`.env` is not handed to the container wholesale.** None of the compose files +use `env_file:`; compose substitutes `.env` values into the `environment:` block +it declares, and only the variables named there reach the application. As +shipped, the `api` service forwards `SECRET_KEY`, `JWT_SECRET_KEY`, +`CORS_ORIGINS`, `LOG_LEVEL`, `ZABBIX_URL`, `ZABBIX_TOKEN`, `COLLECTOR_API_KEY`, +`COLLECTOR_API_KEY_ENDPOINTS` and `SHOPDB_ENV_LABEL`. Anything else in this +table - a per-plugin collector key for some other plugin, the `EMPLOYEE_DB_*` +set, the `CMMC_USB_DB_*` set, `PLUGIN_TRUSTED_KEYS` - needs a line adding to +that block as well, or it is set in a file the application never reads. + +Two entries are set by the compose file itself and ignored from `.env`: + +- **`DATABASE_URL`** is composed in-file as + `mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4`, + on both `api` and `migrate`. `MYSQL_PASSWORD` is therefore the value that + matters; editing `DATABASE_URL` in `.env` changes nothing under compose. +- **`TRUSTED_PROXY_COUNT`** is pinned to 1 because the stack contains its own + nginx. See [Step 7](#step-7-tls-and-the-proxy-that-is-already-there). + +`SHOPDB_ENV_LABEL` defaults to `DEV` here on purpose: this file is the +source-build template, and a raw source build is by definition not a release. +Set it to the site's label for a production stack. + +## Step 2: Bring up the stack + +```bash +docker compose build +docker compose up -d +``` + +### What comes up + +Four services, and knowing which is which saves most of the debugging: + +| Service | Does | Listens | +|---|---|---| +| `db` | MySQL 8.4, utf8mb4 server-wide, volume `db_data` | `127.0.0.1:${MYSQL_PORT:-3306}` - loopback only, for `mysqldump` and a local client. `api` reaches it over the compose network regardless | +| `migrate` | schema, plugin bootstrap and seeds, then exits. `restart: "no"` | nothing | +| `api` | gunicorn, gevent workers, serves the compiled SPA | `127.0.0.1:5002` - debugging bypass only | +| `nginx` | **terminates TLS** and proxies to `api` | `${API_PORT:-5001}` -> 443 | + +So the address a browser uses is `https://:${API_PORT}`, served by nginx, +and `5002` is a back door for `curl` on the host. + +**TLS lives in nginx, not in gunicorn.** `nginx.conf` reads +`/etc/nginx/certs/server.crt` and `server.key`, and `./certs` is mounted there +read-only. The repository ships a self-signed pair and `certs/openssl.cnf`; +replace both files with the site's own certificate and +`docker compose restart nginx`. The gunicorn command carries no `--certfile`, +by design - one TLS terminator, one place to rotate a key. + +The reason there is TLS at all rather than plain http: browser APIs that require +a secure context, `navigator.clipboard` among them, refuse to run over plain +http on a non-localhost origin, so a stack reached by hostname needs real TLS +somewhere. + +**Why gevent and not threads.** The endpoint agent long-polls +`/api/endpoints/commands/next` and re-polls immediately on a 204, so every agent +holds a connection essentially continuously and fleet size IS the concurrency. +Under threads that was 4 x 8 = 32 slots for the whole site, and a worker's +threads filled with sleeping agents while the CPU sat idle. A held connection +under gevent is a greenlet, so idle is nearly free. Before changing `--workers`, +read the connection arithmetic in the comment beside it: workers x (pool size + +overflow) has to stay under the MySQL `max_connections`, and at the shipped 4 +workers it is already about 90 of 151. + +### Building where npm cannot reach the registry + +The image builds the Vue frontend in a first stage and copies the compiled SPA +in, so `docker compose build` needs no separate Node step. Behind a proxy that +re-signs HTTPS (Zscaler and the like), that first stage is where the build dies: +npm inside the container does not trust the corporate root CA, and the failure +reads `npm ci failed` or `Exit handler never called`. + +Two ways out, in order of preference: + +1. **Give the build the corporate root CA.** Export it to `corp-root-ca.pem` in + the repository root - [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md#0b-corp-network-ssl-cert---if-you-are-behind-a-gezscaler-proxy) + has the one-line PowerShell that bundles every trusted root - then + `docker compose build --no-cache`. +2. **Build the frontend on the host and skip npm in the image entirely.** That + is `docker-compose.prebuilt.yml` plus `Dockerfile.prebuilt`, and it is also + two to three minutes instead of fifteen. See + [DOCKER-PREBUILT-APPROACH](deployment/DOCKER-PREBUILT-APPROACH.md). + +(For a bare-metal venv install instead: `cd frontend && npm ci && npm run build`, +which writes `frontend/dist/` for Flask to serve.) + +The MySQL container initializes its volume on first run; its healthcheck probes +over TCP rather than the socket, because the socket answers during init while +the server is still about to restart. Watch the bring-up with +`docker compose logs -f migrate api`: if `ProductionConfig.validate()` raises, +the container exits with the offending env-var named in the log. Fix `.env` and +`docker compose up -d` again. + +## Step 3: The schema, and what the `migrate` service already did + +**On this stack you do not run these by hand.** The `migrate` service runs them +on every `up`, in one place and one order, and `api` will not start until it +exits 0: + +``` +flask db upgrade && /app/scripts/bootstrap-plugins.sh && flask plugin upgrade-all + && flask seed permissions && flask seed settings && flask seed reference-data +``` + +`up -d api` still migrates first: compose honours +`service_completed_successfully` even when a single service is named. A failing +migration exits once and leaves the error as the last line of +`docker compose logs migrate`, rather than crash-looping under a restart policy +and burying it. + +The rest of this section says what each command covers, because you will need +that when one of them fails, when you are on a venv install with no `migrate` +service, or when you are pruning a lean site (below). To run one by hand: +`docker compose exec api flask `. + +`flask db upgrade` applies the core Alembic chain: the baseline migration plus +every later migration, which together create all core AND bundled-plugin tables +through the chain head. `flask plugin upgrade-all` then stamps each bundled +plugin's own migration chain (the `alembic_version_` tables) and applies +any plugin-specific migrations added after the ownership cutover. Both commands +are idempotent, so re-running them is safe. See ADR-008 for why plugin schema +splits into per-plugin chains from the cutover forward. + +**Lean sites (ADR-014):** the core chain creates every bundled plugin's tables, +so a site that ships only some plugins still has the others' (empty) tables. To +carry only core + chosen-plugin tables, prune the rest once, at initial +provisioning, after the two commands above: + +```bash +docker compose exec api flask plugin prune-schema # dry-run, review +docker compose exec api flask plugin prune-schema --yes --force +``` + +It drops the tables of every plugin not installed on this site. `--force` is +needed because the core chain seeds a few plugin reference tables (default +access protocols, etc.); at first provisioning those hold only seeded defaults, +before any site data. It refuses to drop a table that holds rows without +`--force`, so it is safe to leave out of routine upgrades - run it only when +provisioning a lean site or after deliberately removing a plugin. Installing a +pruned plugin later recreates its tables automatically. + +**Charset:** the schema is utf8mb4 (`utf8mb4_unicode_ci`). The docker-compose `db` service sets `--character-set-server=utf8mb4`, so the auto-created `shopdb_flask` database is utf8mb4. If you point at an external MySQL instead of the bundled container, create the database as utf8mb4 first, or it inherits the server default (often latin1) and the schema silently drifts: + +```sql +CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +`DATABASE_URL` must keep `?charset=utf8mb4` so the connection matches. On MySQL older than 5.7 also enable `innodb_large_prefix=ON` + `innodb_file_format=Barracuda`, or the utf8mb4 indexes exceed the 767-byte prefix limit (error 1071). MySQL 5.7+ and 8.0 need no extra config. + +## Step 4: Seed permissions, settings, and reference data + +The `migrate` service runs all three on every `up`, and they are idempotent, so +this is a no-op once a site is current and is how a later release's new +permission or new default setting reaches an existing site. They are listed here +because you need to know what they cover, and because a venv install runs them +itself: + +```bash +docker compose exec api flask seed permissions +docker compose exec api flask seed settings +docker compose exec api flask seed reference-data +``` + +- `seed permissions` - creates the RBAC permission rows and the default roles + the app checks with `@require_permission`. Run this before anyone logs in or + permission checks have nothing to match. +- `seed settings` - writes the default Setting rows (branding, ServiceNow + integration, floor-map placeholders, search toggles, site identity). A site + overrides these later in Settings or the setup wizard. +- `seed reference-data` - creates default `ModelType`, `AssetStatus`, + `LocationType`, `CommunicationType`, `OperatingSystem`, `RelationshipType` rows + seeded with the platform contract values (`partof`, `controls`, `connectedto`). + (`Vendor`, `Location`, and `BusinessUnit` are not seeded here; they come from + `seed demo`.) + +## Step 5: Pick plugins to enable + +The image bundles every plugin in the tree. [PROJECT-MAP](PROJECT-MAP.md) lists +them with their versions, generated from the code; [PLUGINS](PLUGINS.md) is the +page for deciding which ones a site should carry. Only enabled plugins are +loaded. + +`scripts/bootstrap-plugins.sh`, inside the `migrate` service, has already +installed a starting set on a site with no `instance/plugins.json`: backups, +computers, endpoints, guides, knowledgebase, machines, network, notifications, +printers, slides, tools, warranty. It installs them **disabled**, and it skips +itself entirely once the registry file exists, so it never fights a site's own +choices. + +That is every bundled plugin, so on this line the starting set and the shelf +are the same list - a site turns one on rather than installing it: + +```bash +docker compose exec api flask plugin list +docker compose exec api flask plugin enable warranty +``` + +Or put the whole list in a profile and keep that in version control, which is +the thing to do when more than one site is involved: + +```bash +docker compose exec api flask plugin apply-profile deploy/site-profile-universal.json +``` + +`deploy/site-profile.example.json` is the template to copy for a site of your +own. Enabling a plugin needs `docker compose restart api` to take effect. + +To install a sister-site or third-party plugin (per ADR-003), drop its directory into `/plugins//` (the docker-compose mounts this read-only into the container) and run `flask plugin install `. + +## Step 6: Create the first admin (setup wizard) + +The primary path is the first-run setup wizard. Once the stack is up and the DB +is seeded, browse to the site (through the reverse proxy configured in Step 7, +or directly at the API port during bring-up) and go to `/setup`. The wizard +creates the first admin account and captures site identity (facility name, +optional logo). It runs only while `setup_complete` is false; after it finishes +the route redirects to the app. + +Headless alternative (no browser, e.g. automated provisioning): + +```bash +docker compose exec api flask seed admin --username admin --email admin@facility.example.com +# Password is generated and printed once. Store in your password manager. +``` + +Subsequent users are managed through the UI. + +## Step 7: TLS, and the proxy that is already there + +**The stack ships its own reverse proxy.** The `nginx` service terminates TLS on +`${API_PORT:-5001}` and proxies to `api` over the compose network, forwarding +`X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto` and `Host`, with 90-second +proxy timeouts so an agent's long poll is not cut mid-hold. For most sites +Step 7 is therefore: drop your certificate into `./certs`, set `API_PORT`, and +stop. + +Put a corporate load balancer or an existing nginx in FRONT of that only if the +site already has one. The shape then is proxy -> nginx -> api, which is two +proxies, and `TRUSTED_PROXY_COUNT` has to say so (below). A site that would +rather terminate TLS in its own proxy can instead remove the `nginx` service and +publish `api` directly - a configuration like this one: + +```nginx +server { + listen 443 ssl; + server_name shopdb.facility-a.example.com; + + ssl_certificate /etc/ssl/certs/shopdb.crt; + ssl_certificate_key /etc/ssl/private/shopdb.key; + + location / { + proxy_pass http://localhost:5001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +(That block proxies to plain http, so it belongs on the same host as the +container or on a trusted network segment.) + +### Real client addresses: `TRUSTED_PROXY_COUNT` + +Behind a proxy the application sees the proxy's connection, not the browser's. +`TRUSTED_PROXY_COUNT` is the number of proxies **actually** in front of this +instance, and the application then enables ProxyFix, which resolves the client +address, scheme, host and prefix from the `X-Forwarded-*` headers. + +**`docker-compose.yml` already sets it to 1 on the `api` service**, because the +bundled nginx is that one proxy. It is not read from `.env` there - the value +belongs to the stack's shape, not to the site. Change it in the compose file, +not in `.env`, and only when you change the shape: 2 if you put a corporate load +balancer in front of nginx, 0 if you delete the nginx service and publish `api` +directly with nothing ahead of it. + +- **It is 0 by default, deliberately.** ProxyFix makes the application trust + those headers, and any caller can send them. Enabled with nothing in front, + a caller can claim a different source address or scheme - and the login rate + limit and the account lockout are keyed on the client address. +- **Leave it at 0 behind a proxy** and the application is not wrong about the + client so much as blind to it: every request looks like it came from the + proxy. The whole site then shares one rate-limit bucket, and the audit log + records the proxy for every action. +- Getting the scheme wrong has its own symptom: an absolute redirect built from + the proxy's view sends an https page to an http URL the browser refuses. That + is what made a redirect on the endpoints list unfollowable behind IIS. +- `.env.example` also carries `TRUSTED_PROXY_COUNT`, defaulting to 0. That line + is for a venv or IIS install, where nothing sets it for you; under this + compose file the compose value wins and the `.env` line is inert. + +`CORS_ORIGINS` is an exact origin match (scheme + host + port, no path), so it +has to name the address the browser actually uses, not the container's. + +## Step 8: Backups + +Per-site backups are the site's responsibility: nightly dump to offsite storage, +14-day retention, a restore rehearsed quarterly. The database is not the whole +backup - `instance/` holds uploaded floor plans, branding, `plugins.json` and +tokens that are not in MySQL, and under compose it is the `instance_data` named +volume. [BACKUP-RESTORE](BACKUP-RESTORE.md) is the procedure for both, including +the compose backup overlay. + +```bash +docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask | gzip > backup-$(date +%F).sql.gz +docker compose run --rm -v "$PWD:/backup" api tar czf /backup/instance-$(date +%F).tar.gz -C /app/instance . +``` + +## Step 9: Updates + +Moving this site to a newer version is [UPGRADE](UPGRADE.md) - back up, pull, +rebuild, migrate, re-seed, restart, plus the version-specific notes. One thing +belongs here rather than there, because it is about how this stack is built: + +### The `instance_data` volume, and stacks that predate it + +`up -d api` REPLACES the container. Everything the site has written lives in the +`instance_data` volume for exactly this reason: the enabled-plugin list +(`plugins.json`), uploaded floor plans and branding, model and application +images, employee photos, warranty proofs, slides, printed-part files, and the +Dell OAuth token. + +**`docker compose down` is safe; `docker compose down -v` is not.** The `-v` +deletes the named volumes, which is both `instance_data` and `db_data`: the +site's files and its database in one command. There is no confirmation prompt. + +If your stack predates that volume, those files are in the old container's +writable layer and an update discards them. Move them across ONCE, before the +next rebuild: + +```bash +docker compose cp api:/app/instance ./instance-rescued # BEFORE pulling new code +docker compose up -d api # creates the volume +docker compose cp ./instance-rescued/. api:/app/instance +docker compose exec -u root api chown -R shopdb:shopdb /app/instance +docker compose restart api + +docker compose exec api flask plugin list # the site's plugins, enabled again +``` + +Two things decide whether this works. + +- **The `chown` is not optional.** `docker compose cp` writes the files with the + copying user's numeric uid, which is only `shopdb` by coincidence if your host + account happens to be uid 1000. Get it wrong and the site reads its restored + files fine and cannot write new ones. Every copy into a container in these + pages ends with this same `chown`, for this reason. +- **Restore `instance/` BEFORE `plugin upgrade-all`.** That command works from + `plugins.json`, so against an empty instance directory it upgrades nothing and + reports success. + +The symptom of having missed it is a site that comes back with its plugins +disabled and image URLs that 404: the MySQL rows survived, the files did not. +Re-enabling by hand works, but `flask plugin apply-profile ` puts +the same list back in one command and is the thing to keep in version control. + +## Importing an existing database + +A site coming from an earlier ShopDB, or a test stack loading a copy of +production, restores the dump and then brings the schema forward: + +```bash +docker compose exec -T db mysql -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask < dump.sql +docker compose up -d migrate # re-runs upgrade, bootstrap and the seeds +docker compose restart api +``` + +The order matters and so does the second line. A restored dump carries plugin +TABLES but not `instance/plugins.json`, which is where the enabled-plugin list +lives (ADR-008 and the note in Step 9). Without a bootstrap pass the site comes +up with a full schema and no plugins registered, which reads as "all my data is +gone". `flask plugin apply-profile` puts a known list back in one command. + +Restore `instance/` at the same time if you have it - see Step 9. + +## Common issues + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `ConfigError: SECRET_KEY is required in production` | `.env` missing or blank | Set `SECRET_KEY` in `.env`, re-up | +| `ConfigError: CORS_ORIGINS must be a comma-separated allowlist` | `.env` has `*` | Set explicit origins | +| `PluginVersionError: requires core_version X but framework is Y` | Plugin pinned a too-narrow range | Update `manifest.json` `core_version` or pin framework version | +| 500s after `flask db upgrade` | Migration ran but app cached old schema | `docker compose restart api` | +| Cannot reach API after restart | Reverse proxy not pointing at the container's exposed port | Confirm `API_PORT` and proxy config | +| `npm ci failed` / `Exit handler never called` during build | A proxy re-signs HTTPS and npm in the build stage does not trust the corporate root CA | Stage `corp-root-ca.pem` in the repo root and `docker compose build --no-cache`, or build the frontend on the host ([DOCKER-PREBUILT-APPROACH](deployment/DOCKER-PREBUILT-APPROACH.md)) | +| `database is unhealthy`, or `db` never goes green | MySQL still initializing, or the published port is taken on the host | `docker compose logs db`; if the port clashes, set `MYSQL_PORT=3307` in `.env`. The mapping is loopback-only and does not affect `api` | +| `Access denied for user 'shopdb'` | `MYSQL_PASSWORD` changed after the `db_data` volume was initialized | MySQL sets the app user's password only on first init. Change it in MySQL, or drop the volume on a site with nothing in it yet | +| `Could not connect to database` from `api` or `migrate` | Something is pointing at `localhost` instead of the `db` service | Inside the stack the host is `db`. `localhost` in a container is that container | +| `ERR_CERT_AUTHORITY_INVALID` in the browser | The shipped `certs/server.crt` is self-signed | Expected on a first bring-up. Replace the pair in `certs/` with the site's own certificate and `docker compose restart nginx` | +| A plugin is installed but nothing appears in the nav | Installed is not enabled, and enabling needs a restart | `flask plugin enable ` then `docker compose restart api` | +| The first build takes 15 minutes | npm install and the Vite build inside the image | Expected once; later builds hit the layer cache. To avoid npm in the image entirely see [DOCKER-PREBUILT-APPROACH](deployment/DOCKER-PREBUILT-APPROACH.md) | + +## Health check + +```bash +# Through nginx, on API_PORT. -k because the shipped certificate is self-signed +# until you replace it. +curl -sk -X POST -H "Content-Type: application/json" \ + -d '{}' https://localhost:5001/api/auth/login | jq . +# Expect: {"status": "error", "data": {"error": {"code": "VALIDATION_ERROR", ...}}} + +curl -sk https://localhost:5001/api/dashboard/health # no authentication needed +``` + +`api` has its own healthcheck over plain http inside the stack, and the +debugging bypass on the host is `curl -s http://127.0.0.1:5002/api/dashboard/health`. +Comparing the two separates "the app is broken" from "nginx or the certificate +is broken": bypass green and `:5001` red means the problem is TLS or the proxy. +If both are red or return no JSON, `docker compose logs api`. + +## References + +- [UPGRADE](UPGRADE.md) - moving this site to a newer version +- [DOCKER-PREBUILT-APPROACH](deployment/DOCKER-PREBUILT-APPROACH.md) - building + the image where npm cannot reach the registry +- [DEV-BUILD-TEST](DEV-BUILD-TEST.md) - pulling a prebuilt stack onto a Windows + box to look at it +- [DEPLOY-AIRGAP](DEPLOY-AIRGAP.md) - the same stack where nothing can be pulled +- [BACKUP-RESTORE](BACKUP-RESTORE.md) - backup and restore procedure +- [CONFIG](CONFIG.md) - every environment variable and every Setting key +- [PLUGIN-QUICKSTART](PLUGIN-QUICKSTART.md) - building a custom plugin for your + site +- [ADR-004](adr/ADR-004-deployment-topology.md) - per-site instances rationale; + [ADR-003](adr/ADR-003-plugin-distribution.md) bundled vs external plugins; + [ADR-006](adr/ADR-006-collector-contract.md) per-plugin collector endpoints +- [shopdb/config.py](../shopdb/config.py) - all the env vars in one place diff --git a/docs/DEV-BUILD-TEST.md b/docs/DEV-BUILD-TEST.md new file mode 100644 index 00000000..eb298a04 --- /dev/null +++ b/docs/DEV-BUILD-TEST.md @@ -0,0 +1,56 @@ + + +# ShopDB dev build - quick test + +Two dev builds you can stand up on a Windows box with Docker Desktop. You run +one PowerShell script: it checks your dependencies (offers to winget-install +anything missing), then PULLS a prebuilt image from GHCR and starts the stack in +Docker. Nothing is compiled on your machine - no Node, no npm, no source build - +so it works on a box that has only Docker installed. + +This is for LOOKING at a build, not changing one. To edit the code, you want a +real dev environment: [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md). + +## What you need + +- Windows with Docker Desktop running. +- Git for Windows (`winget install Git.Git`). +- A GitHub login for this private repo/registry already stored on the box. If + you have cloned this repo before, or signed in to GitHub in Visual Studio or + VS Code, you already have it - the script uses that stored credential + automatically. If Docker cannot pull the image, sign in once with + `docker login ghcr.io` (use a GitHub username and a personal access token that + has `read:packages`), then re-run the launcher. + +## Get the launcher + +You will be sent `Start-ShopDBDev.ps1` directly. Save it anywhere (for example +your Downloads folder) and open PowerShell in that folder. + +## Bleeding edge (main, the trunk) + + powershell -ExecutionPolicy Bypass -File .\Start-ShopDBDev.ps1 -Channel edge + +Comes up at https://localhost:5011. Red DEV badge in the sidebar. Pulls the +`edge` image. + +## Stable (newest release tag) + + powershell -ExecutionPolicy Bypass -File .\Start-ShopDBDev.ps1 -Channel stable + +Comes up at https://localhost:5001. No DEV badge. Pulls the `stable` image. + +## Notes + +- Both stacks can run at the same time - they use different ports and separate + Docker volumes, so they do not collide and each keeps its own database. +- Each launch pulls the latest image for the channel, so re-running the launcher + is how you pick up a new build. +- First page load shows a self-signed cert warning (expected for a dev box). +- Stop a stack: `docker compose -p shopdb-stable down` (or `shopdb-edge`). diff --git a/docs/DEVELOPMENT-SETUP.md b/docs/DEVELOPMENT-SETUP.md new file mode 100644 index 00000000..2eb275f7 --- /dev/null +++ b/docs/DEVELOPMENT-SETUP.md @@ -0,0 +1,390 @@ + + +# Development setup: from clone to first change + +The goal of this page: a new developer clones the repo and has a working dev +site plus a change they can see in the browser, in one sitting. Reference +material lives elsewhere - naming rules in `CONTRIBUTING.md`, every config +variable in the CONFIG guide, plugin authoring in the PLUGIN docs - this is +just the on-ramp. + +**Most developers here are on Windows in VS Code** - commands below are +PowerShell first, with the bash equivalent in a comment where they differ. +Install **Git for Windows** (it ships Git Bash, which VS Code and the git +hooks use to run the shell-based naming check) and **VS Code** with the +extensions this repo recommends (you'll be prompted - section 2c). + +Two ways to run it. **Docker** (Docker Desktop on Windows) is the fastest to a +working site. **Manual (venv + Node)** is the daily driver - frontend +hot-reloads, backend restarts on save. Do Docker once to confirm the box is +sane, then use manual for day-to-day work. + +If you only want to LOOK at a running site and are not going to change the +code, do not do any of this: [DEV-BUILD-TEST](DEV-BUILD-TEST.md) pulls a +prebuilt image and needs nothing but Docker. + +--- + +## 0. Prerequisites + +| Need | Version | Check | +| --- | --- | --- | +| Python | 3.14 (64-bit) - matches CI, the container image and the Windows installer wheelhouse | `python --version` | +| Node.js | 18+ | `node --version` | +| MySQL | 8.4 LTS (or Docker, below) | `mysql --version` | +| Git | any recent | `git --version` | + +On Windows, install all of them with winget (accept each license, then +reopen the terminal so PATH updates): + +```powershell +winget install Git.Git # includes Git Bash (naming hook needs it) +winget install Python.Python.3.14 +winget install OpenJS.NodeJS.LTS # LTS; may install v24, fine for this SPA +winget install Microsoft.VisualStudioCode +winget install Oracle.MySQL # or Docker.DockerDesktop for the DB +``` + +CI runs Node 20; the LTS package may be newer. This Vite/Vue frontend builds +identically across 20-24, so it does not matter. To pin exactly: +`winget install CoreyButler.NVMforWindows` then `nvm install 20; nvm use 20`. + +--- + +## 0b. Corp network (SSL cert) - if you are behind a GE/Zscaler proxy + +A proxy that inspects HTTPS (Zscaler on GE PCs) re-signs every connection +with a corporate root CA. `git`, `npm`, `pip`, and Node each keep their own +trust store and do not trust that CA by default, so downloads fail: + +| Tool | Symptom | +| --- | --- | +| npm | `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` | +| git | `SSL certificate problem: unable to get local issuer certificate` | +| pip | `SSLError` / `CERTIFICATE_VERIFY_FAILED` | + +Fix once - export the corp root CA, point every tool at it. PowerShell +mangles multi-line pastes, so each step below is **one physical line**: paste +it, press Enter, then the next. Do not paste both at once. + +```powershell +# 1) Bundle EVERY trusted root into one PEM (one line). Guessing which single cert is the proxy's is fragile; bundling all always includes it. +$sb = New-Object System.Text.StringBuilder; Get-ChildItem Cert:\LocalMachine\Root | ForEach-Object { [void]$sb.AppendLine("-----BEGIN CERTIFICATE-----"); [void]$sb.AppendLine([Convert]::ToBase64String($_.RawData,'InsertLineBreaks')); [void]$sb.AppendLine("-----END CERTIFICATE-----") }; [IO.File]::WriteAllText("$HOME\corp-root-ca.pem", $sb.ToString()) +``` + +Confirm it has many certs (dozens, not 1): +`(Select-String "BEGIN CERTIFICATE" $HOME\corp-root-ca.pem).Count` + +```powershell +# 2) Point every tool at it (one line, persistent). NODE_EXTRA_CA_CERTS also fixes Vite / npm run dev. +git config --global http.sslCAInfo "$HOME\corp-root-ca.pem"; npm config set cafile "$HOME\corp-root-ca.pem"; setx NODE_EXTRA_CA_CERTS "$HOME\corp-root-ca.pem"; setx PIP_CERT "$HOME\corp-root-ca.pem" +``` + +Reopen the terminal so `setx` takes effect. Quick unblock if you cannot +export right now (skips verification - use briefly, then set back): +`npm config set strict-ssl false`, `git config --global http.sslVerify false`. + +--- + +## 1. Get the code + +```powershell +git clone https://github.com/ge-aero/shopdb-flask.git +cd shopdb-flask +``` + +Never work on `main`. Branch for your change: + +```powershell +git checkout -b feat/ +``` + +--- + +## 2a. Fast path - Docker (a working site in one command) + +```powershell +copy .env.example .env +# Edit .env: set SECRET_KEY, JWT_SECRET_KEY, and the MYSQL_* passwords. +# Generate a secret: python -c "import secrets;print(secrets.token_urlsafe(64))" + +docker compose up -d --build # MySQL + the app (frontend built in-image) + +# Schema + platform data (idempotent, safe to re-run): +docker compose exec api flask db upgrade +docker compose exec api flask plugin upgrade-all +docker compose exec api flask seed permissions +docker compose exec api flask seed settings +docker compose exec api flask seed reference-data +docker compose exec api flask seed admin --username admin --email you@example.com +docker compose exec api flask seed demo # OPTIONAL: sample data (undo: flask seed demo-clear) +``` + +The app is on the port the compose file maps (see `docker-compose.yml`). Good +for a smoke test; for active development use the manual path so the frontend +hot-reloads. + +--- + +## 2b. Manual path - venv + Node (the daily driver) + +### Database + +Either point at an existing MySQL 8, or bring one up with just the db service +from compose: + +```powershell +docker compose up -d db # MySQL on 127.0.0.1:3306 +``` + +The mapping is loopback-only and `MYSQL_PORT` moves it. If the box already runs +a local MySQL on 3306, set `MYSQL_PORT=3307` in `.env` and put the same 3307 in +`DATABASE_URL` - a mismatch here is the single most common "cannot connect" +in development, because the error names 3306 and looks like the container is +down when it is simply somewhere else. + +Create the database + app user (skip if compose already did via env): + +```sql +CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'shopdb'@'%' IDENTIFIED BY 'devpassword'; +GRANT ALL PRIVILEGES ON shopdb_flask.* TO 'shopdb'@'%'; +FLUSH PRIVILEGES; +``` + +### Backend + +```powershell +python -m venv venv +venv\Scripts\Activate.ps1 # bash/mac: source venv/bin/activate +# If PowerShell blocks the activate script (execution policy), run once: +# Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +pip install -r requirements-dev.txt + +copy .env.example .env # bash/mac: cp .env.example .env +# Edit .env - set SECRET_KEY, JWT_SECRET_KEY, and +# DATABASE_URL=mysql+pymysql://shopdb:devpassword@127.0.0.1:3306/shopdb_flask?charset=utf8mb4 +# CORS_ORIGINS=http://localhost:5173 + +$env:FLASK_APP = "shopdb" # bash/mac: export FLASK_APP=shopdb +flask db upgrade # core schema +flask plugin upgrade-all # per-plugin schema (ADR-008) +flask seed permissions +flask seed settings +flask seed reference-data +flask seed admin --username admin --email you@example.com # password printed once +flask seed demo # OPTIONAL: sample assets across the plugins (undo: flask seed demo-clear) +``` + +Enable the plugins you want visible. To turn on everything for development: + +PowerShell: + +```powershell +foreach ($p in "backups","computers","endpoints","guides","knowledgebase", + "machines","network","notifications","printers","slides", + "tools","warranty") { + flask plugin install $p; flask plugin enable $p +} +``` + +(bash/mac: a `for p in ...; do flask plugin install "$p"; ...; done` loop.) + +Run the backend ON PORT 5001 - the frontend dev server proxies `/api` and +`/static` there (a bare `flask run` uses 5000 and nothing will load). Use the +launcher in the repository root rather than `flask run`: + +```powershell +python run_dev.py +``` + +**Use `run_dev.py`, not `flask run`.** `flask run` reloads by spawning a +subprocess, and the subprocess does not inherit what `python-dotenv` loaded from +`.env` - so the reloaded backend comes up with no `DATABASE_URL` and tries +127.0.0.1:3306 with default credentials. `run_dev.py` loads `.env` BEFORE +importing Flask, then runs `flask run --host=0.0.0.0 --port=5001 --no-reload`. +It prints the `DATABASE_URL` and `SHOPDB_BACKUPS_ROOT` it resolved, which is the +fastest check that `.env` was actually read. The cost is that backend edits need +a manual restart. + +**Leave this running.** It does not return to a prompt - that is correct, not a +hang. The server holds this terminal until you stop it. Do NOT press Ctrl+C to +move on; that kills the backend. Open the frontend in a separate terminal (next +section) and leave this one alone. Ctrl+C only when you are done for the day. + +### Frontend (a second terminal - leave the backend running) + +```powershell +cd frontend +npm install +npm run dev # http://localhost:5173 +``` + +Open http://localhost:5173, log in as `admin` with the printed password. + +To point the dev proxy at a backend on another machine, set the environment +variable rather than editing `vite.config.js`: + +```bash +VITE_DEV_API_TARGET=https://10.0.0.5:5001 npm run dev +``` + +Certificate verification is off for an `https` target, because those boxes +normally carry a self-signed certificate; `VITE_DEV_SECURE=true` turns it back +on. + +> Convenience: instead of two terminals you can run both under a process +> manager (pm2, honcho, foreman). Keep the backend on 5001. + +### Working from a copy of a site's database + +Restoring a dump instead of seeding is the fastest way to reproduce a real bug, +and it has two traps: + +```powershell +docker exec -i mysql -u root -p"" shopdb_flask < dump.sql +flask db upgrade +flask plugin upgrade-all +``` + +1. **The dump has plugin tables but no plugin registry.** The enabled-plugin + list lives in `instance/plugins.json`, not in MySQL, so a restored database + comes up with every table and an empty nav. Install and enable the plugins + (above), or `flask plugin apply-profile deploy/site-profile-universal.json`. +2. **You will not know the admin password.** Mint a fresh hash and write it + over the row, clearing the lockout counters at the same time: + + ```powershell + python -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('admin'))" + ``` + + ```sql + UPDATE users SET passwordhash = '', failedlogins = 0, lockeduntil = NULL + WHERE username = 'admin'; + ``` + + Do this only against a development copy. Never paste a hash from a document + into a real site - one that has been published is a password everybody has. + +If the backups feature is in play, `SHOPDB_BACKUPS_ROOT` has to name a path that +exists on the development box. Unset, it defaults to a POSIX path and Windows +answers `Access is denied: '/backups'`. + +--- + +## 2c. VS Code (turnkey) + +The repo ships shared VS Code config in `.vscode/` (personal `settings.json` +stays git-ignored): + +- **Recommended extensions** - on first open VS Code offers to install them + (Python + Pylance, Vue Volar, ESLint, Docker). Accept. +- **Run the dev site** - Command Palette > "Tasks: Run Task" > + **Dev site (backend + frontend)** starts both servers in parallel (backend + on 5001, frontend on 5173). Individual tasks exist too. If the backend task + invokes `flask run` rather than `run_dev.py`, expect the `.env` problem + described in 2b and start the backend by hand instead. +- **Debug the backend** - the Run panel's **Flask API (:5001)** config runs + the app under the debugger (breakpoints in routes/services, full + stepping); **Pytest (current file)** debugs the open test file. +- **The CI gate** - task **Check: naming + tests + build** runs the same + three checks CI runs, before you commit. + +Prerequisite: the venv and `npm install` from 2b must be done first (the +tasks call `venv/` and `frontend/node_modules`). + +--- + +## 3. The development loop + +1. Make a change. Backend: `flask run` auto-reloads. Frontend: Vite hot-reloads. +2. Before committing, run the three gates. Easiest: in VS Code, Command + Palette > "Tasks: Run Task" > **Check: naming + tests + build**. By hand + in PowerShell: + ```powershell + venv\Scripts\python -m pytest tests/ -q # backend + cd frontend; npx vitest run; npm run build; cd .. + bash scripts/check-naming-and-style.sh # naming - runs via Git Bash + ``` + CI runs all three on every push and pull request, so running them locally + only saves the round trip. No hook is installed for you; to have the naming + check run on every `git commit`, enable the shipped one once per clone with + `git config core.hooksPath .githooks`. The naming check is a shell script, + so it needs Git Bash (installed with Git for Windows) on Windows. +3. Commit in small, working steps. Subject: short, present tense, plain + English; body says WHY. Read `CONTRIBUTING.md` before naming anything - the + naming hook will reject snake_case DB columns, banned shorthand, and + non-ASCII. + +Seeing a change in the real app (not just tests) is the bar for "done" - +drive the actual flow in the browser. + +--- + +## 4. Your first change (suggested) + +Add a field to an existing list page. Or build a plugin end to end, starting at +[PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md). + +--- + + +## 5. Contributing back + +This is the whole contributor workflow, for a core change and for a plugin +alike. + +1. **Branch, never work on `main`** (section 1), and commit in working steps - + each commit a checkpoint that passes its own tests. +2. **Run the three gates before every push** (section 3). +3. **Push and open a Pull Request against `main`.** + + ```powershell + git push -u origin feat/ + ``` + + In the description: what changed, which plugin hooks it implements, any + contract additions (these need a `__contract_version__` bump plus a + `docs/PLUGIN-HOOKS.md` update in the SAME pull request - a docs-drift test + fails the build otherwise), and any deliberate security posture. An endpoint + that is open on purpose needs a decision record, not a comment. + +4. **What a reviewer looks for:** + - contract purity - core imported only via `shopdb.api` and + `shopdb.plugins.base` (there is a guard test) + - the naming convention, per `CONTRIBUTING.md` + - a per-plugin migration chain, its `PLUGIN_TABLE_OWNERS` entry, and its + expected-head declaration in `tests/test_plugin_migrations.py` + - permissions both DECLARED and ENFORCED + - tests for the invariants, not just the happy path + - `default_enabled` correct for the plugin's nature + +5. **After approval**, the maintainer lands the change on the mainline and the + next published release commit includes it; your pull request is then closed + as merged. Day-to-day history lives on the development mainline and the + published repository carries the release line, so do not be surprised when + your commits arrive squashed or folded into a release commit. + +--- + +## Common setup problems + +| Symptom | Cause / fix | +| --- | --- | +| Frontend loads but every API call fails / CORS error | backend not on 5001 (`flask run --port 5001`), or `CORS_ORIGINS` missing `http://localhost:5173`. | +| App refuses to boot in production config | a required `.env` var (`SECRET_KEY`, `JWT_SECRET_KEY`, `DATABASE_URL`, `CORS_ORIGINS`) missing or a dev default. | +| `flask db upgrade` / `flask plugin upgrade-all` error 1071 (key too long) | MySQL 5.6 on the default Antelope file format, where an index prefix caps at 767 bytes - one utf8mb4 `VARCHAR(255)` is 1020. Start mysqld with `--innodb-file-format=Barracuda --innodb-large-prefix=ON` (both are also settable live with `SET GLOBAL`), keep `?charset=utf8mb4` on `DATABASE_URL`, and note that tables CREATED before those flags were on are silently `ROW_FORMAT=Compact` rather than `DYNAMIC` - 5.6 downgrades the row format with a warning instead of an error, so they keep the 767-byte cap until rebuilt with `ALTER TABLE ROW_FORMAT=DYNAMIC`. MySQL 5.7+ and 8.0 need none of this. | +| Nav missing Machines/PCs/... | plugins not installed/enabled (step 2b), or the backend not restarted after enabling. | +| "No time zone found with key America/New_York" | `tzdata` not installed - `pip install -r requirements.txt` includes it. | +| npm/git/pip SSL error (`UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `unable to get local issuer certificate`) | corp proxy (Zscaler) intercepts HTTPS - point each tool at the corp root CA. See section 0b. | +| Naming hook rejects a commit | you used snake_case on a DB-mirrored field or a banned acronym - see `CONTRIBUTING.md`. | +| Plugin toggle throws an internal error | app cannot write `instance/` (the plugin registry lives there) - fix directory permissions. | +| `Can't connect to MySQL on 127.0.0.1:3306` while the container is clearly up | the backend never read `.env` (started with `flask run` instead of `run_dev.py`), or `MYSQL_PORT` moved the container and `DATABASE_URL` still says 3306. | +| `Access is denied: '/backups'` on Windows | `SHOPDB_BACKUPS_ROOT` unset, so the backups code fell back to a POSIX path. Point it at a directory on this box and create it. | +| Backups can be requested but never run | expected outside Docker. The API queues a backup with a sentinel file; the work is done by a sidecar container, so a venv-only box queues and nothing executes. | diff --git a/docs/EVENTSAVER.md b/docs/EVENTSAVER.md new file mode 100644 index 00000000..852e9695 --- /dev/null +++ b/docs/EVENTSAVER.md @@ -0,0 +1,144 @@ + + +# EventSaver + +A rotating-image screensaver for shop-floor PCs. Idle bays show the slides +managed in ShopDB, so a safety notice or a shift message reaches the floor +without anyone walking to each machine. + +It is a small C# WinForms screensaver (`.scr`), not part of the ShopDB server. +It is documented here because ShopDB serves its content and because a site +adopting the fleet tools needs to know how it is configured. + +For how to deploy it at a new site, see +[ADOPTING-AT-ANOTHER-SITE.md](ADOPTING-AT-ANOTHER-SITE.md). This page is what it +is, how it decides what to show, and how to diagnose it. + +## Two sources, one of them preferred + +EventSaver reads `EventSaver.ini` from the directory it lives in, **on every +launch**. Retargeting it never needs a recompile. + +```ini +# HTTP mode: pull the playlist from ShopDB and cache it locally. +url=https://shopdb.example.net/api/slides/feed?surface=shopfloor + +# Folder mode: used only when url is blank. SMB or local path. +# folder=\\fileserver\shopfloor\tv + +interval=10 # seconds per image, unless a slide carries its own time +shuffle=0 # 1 = random order, 0 = ordered +fadems=600 # crossfade length in ms, reserved (v1 hard-cuts) +``` + +**HTTP mode is the one to use.** Slides are managed in ShopDB (Slides), the +feed is public so the screensaver needs no credentials, and each PC caches what +it fetched under its own `LocalApplicationData`. A bay that cannot reach the +server keeps showing the last set it saw instead of going black - which matters, +because the failure would otherwise be visible to the whole floor. + +**Folder mode** predates the server and stays as a fallback for a site with no +ShopDB instance yet, or for content nobody wants in the database. + +> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, there is +> no source to read and the screensaver shows nothing. That is deliberate: the +> compiled-in fallback used to be the path of the site it was first built for, so +> a missing ini silently pointed a new site at someone else's file server. It is +> now empty, and failing visibly beats displaying the wrong site's slides. Ship +> the ini. + +## What decides the running order + +| Situation | Behaviour | +|---|---| +| No `order.txt` | Every image shows, sorted by filename. Number them `001_`, `002_` to sequence. | +| `shuffle=1` | Random order, ignored when a playlist is present. | +| `order.txt` present | ONLY the listed files show, in the listed order, looped. | + +`order.txt` is the strict-sequence option and is the whole playlist - a file not +listed does not appear. Per-slide duration is appended with a pipe: + +``` +# Lines starting with # or ; are ignored. +001_welcome.jpg +002_safety_week.jpg|15 +003_quality_board.jpg +``` + +In HTTP mode you do not write `order.txt` by hand: ShopDB's feed carries the +order and each slide's own seconds, and EventSaver writes the equivalent +`order.txt` into its cache so the same playlist logic runs either way. + +A running screensaver rescans about every 30 seconds, so a slide change reaches +the floor without touching any PC. + +## Command-line switches + +Windows calls a screensaver with these, and EventSaver answers all three: + +| Switch | Meaning | +|---|---| +| `/s` | Show fullscreen. What Windows uses on idle. | +| `/c` | Configuration. Reports the resolved source and cache directory, and points at the ini. | +| `/p ` | Preview pane. Deliberately a no-op - it keeps the Settings dialog happy without drawing a thumbnail nobody looks at. | + +`EventSaver.scr /c` is the first diagnostic: it says which source it resolved +and where it is caching, which answers most "why is it showing the wrong thing" +questions immediately. + +## Where it should and should not run + +A screensaver over a live display is a defect, not a feature. The reference +fleet excludes: + +- **Kiosks and lobby displays**, which show a live dashboard. +- **Metrology bays** - CMM, Genspect, Keyence, wax-trace - where an operator + watches a measurement run. +- **Bays that sleep**, where the screensaver never wins anyway. + +Targeting is by whatever converges shop-floor state at your site - a group +assignment in Intune, a DSC configuration, or an imaging-time task. Decide it +deliberately: the cost of getting it wrong is a screensaver +covering something someone needed to see. + +## Building it + +The source is `plugins/slides/client/EventSaver.cs`. No SDK required - it +compiles with the in-box .NET Framework compiler on any Windows 10 or 11 +machine: + +``` +C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^ + /target:winexe /out:EventSaver.scr ^ + /reference:System.dll,System.Drawing.dll,System.Windows.Forms.dll ^ + EventSaver.cs +``` + +Ship `EventSaver.scr` and `EventSaver.ini` together. A `.scr` is an executable +with a different extension, so it goes to `C:\Windows\System32\`. + +## Diagnosing it + +| Symptom | Cause to check first | +|---|---| +| Blank screen, no images | Source unreachable and cache empty. Run `/c` and open the `url` in a browser on that PC. | +| Old slides only | Feed reachable at first run, not since. The cache is doing its job; fix the network path. | +| Wrong content | Pointing at another surface, or another site's server. `/c` reports which. | +| Local ini edits revert | Correct behaviour if the config is enforced by hash. Edit the copy your configuration tool deploys from, and update the hash it detects on. | +| Screensaver never starts | Per-user setting missing. Check `HKCU:\Control Panel\Desktop` for `SCRNSAVE.EXE`, `ScreenSaveActive` and `ScreenSaveTimeOut`. | + +The feed is a plain public endpoint, so it can always be checked from the PC +itself: + +``` +https://shopdb.example.net/api/slides/feed?surface=shopfloor +``` + +An empty `slides` array means no slides have been uploaded for that surface - +the PC is configured correctly and there is nothing to show. diff --git a/docs/FLEET-ARCHITECTURE.md b/docs/FLEET-ARCHITECTURE.md new file mode 100644 index 00000000..d2fbe85c --- /dev/null +++ b/docs/FLEET-ARCHITECTURE.md @@ -0,0 +1,144 @@ + + +# How the pieces fit together + +ShopDB is a server. Around it are the things that run on shop-floor PCs, each +documented in its own page, and each of those pages assumes you already know how +it relates to the others. This page is that relationship. + +Read this one first, then open ONE of the pages in the table below. They are +written to be read alone once you have this page's shape in your head. + +## The shape + +``` + +-------------------------------+ + | ShopDB (one per site) | + | Flask API + Vue UI + MySQL | + +-------------------------------+ + ^ ^ ^ | + reports | | | | serves + | | | v + +----------------+ | | | +------------------+ + | asset reporter |---+ | | | kiosks, displays | + | on every PC | | | | (browser, HTTPS) | + +----------------+ | | +------------------+ + | | + +----------------+ | | +------------------+ + | backup |----------+ +----| EventSaver | + | collectors | config revisions | screensaver | + +----------------+ +------------------+ + pulls the slide feed + +----------------+ + | endpoint agent | enrols, reports health, runs queued commands + | service, SYSTEM| + +----------------+ +``` + +Every arrow is one direction on purpose. **Nothing on the server reaches out to +a PC.** The server holds state and answers questions; the fleet asks. That is +what lets a bay behind a firewall, asleep, or on a different network segment be +merely out of date rather than broken - and it is why every one of these tools +degrades to "stale" instead of "failed". + +## What each piece is for + +| Piece | Direction | What it does | Page | +|---|---|---|---| +| **Asset reporter** | PC to server | Says what this PC is: hostname, serial, type, user, NICs, machine number. Creates the PC record the rest of the system hangs off. | [COLLECTOR-INTEGRATION](COLLECTOR-INTEGRATION.md) | +| **Endpoint agent** | PC to server | Enrols the PC for its own credential, reports health, and runs the commands the server has queued for it. | [COLLECTOR-AUTH](COLLECTOR-AUTH.md) | +| **Backup collectors** | PC to server | Post machine configuration (NTLARS/DNC, CMM, part marker, UDC) as revisions, so a controller can be restored and a change can be seen. | [BACKUP-RESTORE](BACKUP-RESTORE.md) | +| **EventSaver** | PC to server | Pulls the slide playlist and shows it when a bay is idle. | [EVENTSAVER](EVENTSAVER.md) | +| **Kiosks and displays** | PC to server | A browser in kiosk mode on a ShopDB route. No client software beyond the browser. | [EVENTSAVER](EVENTSAVER.md) | +| **Printer installer** | PC to server | Reads the printer map and installs the network printers a bay should have. Laid down by a manifest entry. | [PRINTER-INSTALLER](PRINTER-INSTALLER.md) | + +**ShopDB does not lay software on a bay.** Getting the reporters and the +screensaver onto a PC in the first place is the job of whatever already +converges shop-floor state at your site - an imaging task, Intune, DSC, or a +configuration-enforcement tool of your own. ShopDB serves the data those tools +and those clients read; it never pushes. + +Setting all of this up at a plant that does not have it yet is +[ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md). + +## The three ways a PC talks to the server + +**The collector API**, `POST /api/collector/*`. Used by the asset reporter, the +printer-queue reporter and the endpoint agent. A PC authenticates as ITSELF with +a per-PC credential where it has enrolled, and with a shared fleet key where it +has not. Idempotent by design: the same report twice changes nothing. +[COLLECTOR-AUTH](COLLECTOR-AUTH.md) is the one page that defines that choice; +[COLLECTOR-PAYLOADS](COLLECTOR-PAYLOADS.md) is the payload contract, and ADR-006 +is the rationale. + +**Scoped service tokens.** An unattended integration that is not a single PC - +a build job, a site tool, another system - authenticates with a managed token +limited to named permissions, optionally bound to specific resources. This path +does NOT take per-PC credentials: it is a scope talking to the server, not a PC. +Minting, binding and revoking one is Settings > API Tokens. + +**Plain public reads.** The slide feed, the display-role lookup and the kiosk +dashboard routes need no credentials at all, because a screensaver and a wall +display have nowhere to keep one. They are read-only and carry nothing a floor +visitor should not see. + +## Where the credentials live + +Both ends of every credential, in one table, because a credential described from +only one end is the kind that gets provisioned twice. The collector row is +defined in full by [COLLECTOR-AUTH](COLLECTOR-AUTH.md). + +| Credential | On the server | On the PC | Rotate by | +|---|---|---|---| +| Per-PC endpoint credential | One row per asset, sha256 only. Issued by enrollment, revocable per PC | `%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin`, DPAPI LocalMachine, ACL'd to SYSTEM and Administrators | Revoking it and letting the PC enroll again. One PC at a time, no fleet touch | +| Shared collector key (legacy) | A managed token scoped to `collector.ingest` only. `COLLECTOR_API_KEY` in `.env` also works and is simpler for a pilot, but cannot be revoked per fleet | `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey` | Replacing the value the fleet reads from, so every PC picks it up on its next cycle. Never put it in a manifest - manifests sync broadly | +| Scoped service token | A managed token limited to named permissions, optionally resource-bound | Wherever the integration keeps its secrets; on a PC, `HKLM:\SOFTWARE\GE\ShopDB` value `ApiToken` | Minting a bound replacement and revoking the old one | +| Server URL | n/a | `HKLM:\SOFTWARE\GE\ShopDB` value `BaseUrl` | Setting it once at install; nothing should overwrite a bay's own value | +| Server pins | The leaf certificate's SPKI SHA-256, shown on the Server Certificate settings page | `HKLM:\SOFTWARE\GE\ShopDB` value `ServerPins` (REG_MULTI_SZ) | Writing the new pin set; the agent re-reads it within about a minute | +| Application secrets | `.env`: `SECRET_KEY`, `JWT_SECRET_KEY`, `DATABASE_URL`, integration tokens | n/a | See [BACKUP-RESTORE](BACKUP-RESTORE.md) - losing these invalidates every issued token | + +Two rules behind that table. A shop-floor PC is not a trusted place, so what it +holds is scoped to exactly what it does: a credential that leaks buys the reader +the ability to file ONE PC's report, not to read the asset register. And a +credential is delivered, never typed - a value that has to be entered per machine +is a value that is wrong on some machine. + +## What owns what + +The question that decides most arguments: **who is allowed to change this?** + +- **ShopDB owns the record.** What a PC is, what it drives, what it should have. +- **Your configuration tool owns the PC's configuration.** Whatever converges + shop-floor state at your site puts back anything edited on the bay, on its own + schedule. ShopDB records; it does not enforce. +- **The PC owns its own identity.** Serial, hostname, installed software: the + server records what it is told rather than deciding. The one exception is + which NIC is the PC's address, which moved to the server because only the + server knows the site's addressing. + +Where two systems both converge state - Intune DSC and a shop-floor enforcement +tool, say - decide which owns which layer before deploying both, or they will +fight in a way that is very hard to see. +[ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md) has the recommended +split. + +## When something looks wrong + +| Symptom | The piece to look at first | +|---|---| +| A PC is missing from ShopDB entirely | Asset reporter: never ran, or no credential and no collector key | +| A PC is there but its details are old | Asset reporter: stopped running. The dashboard's "PCs not reporting" card is this | +| A PC reports 401 after an admin action | Its credential was revoked. See [COLLECTOR-AUTH](COLLECTOR-AUTH.md) | +| A PC has the wrong software | Not ShopDB: check whatever converges configuration at your site. ShopDB records what the PC reported, it does not decide it | +| A bay shows the wrong thing on screen | EventSaver config, or the display-type dashboard default - both are per PC type | +| A machine has no recent config backup | Backup collector on the controlling PC. "Backups that have stopped" on the dashboard | +| Two PCs claim one machine number | Neither: it is a claim in progress, or a typo. The dashboard card names both | + +Each row is a different program on a different schedule. Knowing which one to +open is most of the diagnosis. diff --git a/docs/FLOOR-MAP.md b/docs/FLOOR-MAP.md new file mode 100644 index 00000000..c2b150dc --- /dev/null +++ b/docs/FLOOR-MAP.md @@ -0,0 +1,137 @@ + + +# The floor map: buildings, levels and marker positions + +The map shows where things physically are. This page is the operator's view for +whoever maintains it: loading a floor plan, adding a second floor or a second +building, and moving the markers when the plan changes. The reasoning behind the +model is [ADR-017](adr/ADR-017-buildings-and-levels.md). + +## The model in three sentences + +A **building** holds one or more **levels**. A level owns a floor-plan image (one +per theme) and the pixel size of that image. A marker's position is an X and Y in +the pixels of ONE level, so every position names the level it belongs to. + +That last point is the whole design. Coordinates alone are meaningless: `1200,900` +is a spot on the ground floor and a different spot on the second floor. A marker +whose level is unknown shows as **level unknown** and is not drawn, because +drawing it on the default plan would look completely correct while pointing at +the wrong part of the building. + +## Loading a floor plan + +**Settings > Buildings and levels.** + +1. Add a building if you do not have one. A single-building site needs exactly + one, and it can be called anything. +2. Add a level. Give it a name people use out loud - "Ground floor", "Mezzanine", + "Second floor" - and a sort order that puts the list in the order someone + would walk it. Name and order are separate on purpose: levels are not reliably + numbered, and leaving gaps in the order lets you insert one later without + renumbering everything. +3. Upload the plan, per theme. A light-theme image is enough to start; if there + is no dark version, the light one is used in both. + +**The level takes its pixel size from the image you upload, while the level is +still empty.** Once markers are on it, an upload whose size does not match reports +the mismatch and changes nothing. That is deliberate - see below. + +Supported: PNG, JPEG, GIF, WEBP and SVG. An SVG is served with headers that stop +it executing as script, so a plan exported from a CAD tool is safe to use. + +## Placing markers + +Two ways, both fine: + +- **One at a time**, from the asset's own form: the map-position picker opens on + the level the marker is already on, and you can switch level inside the picker. + The saved position states which drawing it is on. +- **Many at once**, in the map editor. Filter to what you are working through - + unplaced, needs-review, or one level - search for a specific thing, place it, + and confirm. Placing a marker by hand counts as confirming it. + +A location can carry a position too, which acts as the default for assets in it. + +## When the floor plan changes + +This is the case that needs care, and the reason the tooling exists. + +**A new plan of the SAME dimensions**: upload it and you are done. Markers keep +their coordinates and still line up. + +**A new plan of DIFFERENT dimensions, or a real move**: the coordinates are pixels +of the old image, so every marker is now in the wrong place relative to the +drawing. Do NOT fix this by editing the level's width and height - that just +reinterprets every existing position against a new scale. + +Use **Recalibrate** in the map editor: + +1. Pick two or more **landmarks** - features you can find on both the old and the + new plan. Corners of the building, a doorway, a column. Two is the minimum; + three or four spread across the plan is better. +2. For each, give the old coordinates and the new ones. +3. Review the **dry run**. It lists every marker's old and new position and flags + anything that would land off the drawing. Nothing has changed yet. +4. Apply it. The previous positions are snapshotted first. + +The transform is worked out separately for the X and Y axes from your landmarks, +and never guessed from the image dimensions. That matters in the exact case this +was built for: a plan that gained a floor below it got taller without anything +being rescaled, and a size-derived scale would have stretched everything +vertically. + +After a recalibration every affected marker is marked **needs review**, because a +transform is a good starting guess and nothing in the coordinates can tell you +which machines actually moved. Work the needs-review filter, correct what is off, +and confirm as you go. + +## Undo + +Every bulk change snapshots the positions first, and a restore snapshots before +it restores, so an undo is itself undoable. The history is in the map editor: +what caused each snapshot, when, and whether it has been restored. A restore puts +back the level and the review state, not only the coordinates. + +Assets deleted since a snapshot are reported and skipped rather than failing the +whole restore. + +## Adding a second building or a second floor + +Add it, upload its plan, then move the markers that belong on it. In the map +editor, markers on other levels are badged, so you can see what still needs +moving. + +Do not stack two floors onto one tall image. It looks simpler and it makes the +level implicit in the Y coordinate - "above 2550 means upstairs" - which nothing +can validate and which breaks the moment either plan is redrawn. + +## Things that are not levels + +- **Locations** are where something is in words ("Cell 4", "Gage lab"). A level is + which drawing. A location can name a level, but they are different things. +- **Business units** are organisational, not physical. + +## Known limits + +- The printer-installer map (the standalone program that installs printers by + clicking a floor plan) has one plan compiled into it and does not know about + levels. It is accurate for the default level only. See + [PRINTER-INSTALLER](PRINTER-INSTALLER.md) section 6. +- The first-run setup wizard still asks for one site-wide blueprint; those + settings no longer drive the map. Use Settings > Buildings and levels. +- Map PDF export covers the level you are looking at. + +## For integrators + +Reading levels is public and writing them needs an admin; repositioning markers +needs `assets.edit`. The rules and the parameters are both in the generated +spec at `/api/docs`. If you import positions +from another system, send `levelid` with every coordinate - see +[IMPORT-API](IMPORT-API.md). diff --git a/docs/GUIDES.md b/docs/GUIDES.md new file mode 100644 index 00000000..ae5fe0aa --- /dev/null +++ b/docs/GUIDES.md @@ -0,0 +1,237 @@ + + +# Guides: writing procedures in ShopDB, and generating them from elsewhere + +A guide is a step-by-step procedure stored in ShopDB: images, working +checklists, version history, and printable output. This is the reference for the +data model and the API, aimed at somebody generating guides programmatically - +from a script, an importer, or a model. + +Not the **knowledgebase** plugin, which is a link registry pointing at documents +held somewhere else. That one references documents; this one holds them. They +share only a tag vocabulary and the global search box. + +--- + +## 1. The shape of a guide + +A guide is an ordered list of typed **blocks**, stored as JSON. Not HTML, and +that is load-bearing: + +- a tickable step needs a **stable id** to be remembered between visits +- **dark mode** needs semantic markup, not pasted colours +- **print** needs to know where a step begins so it does not split one across a + page + +Free-form HTML gives none of those, and pasted styling fights the theme. + +### Block types + +| type | Fields | Notes | +|---|---|---| +| `heading` | `text` | Section heading | +| `paragraph` | `text` | Body text | +| `step` | `title`, `text` | Numbers itself by position among steps | +| `checklist` | `items[]` | The tickable one. Items need their own ids | +| `bullets` | `items[]` | Unordered list | +| `numbers` | `items[]` | Ordered list | +| `callout` | `text`, `title`, `variant` | `variant`: `info`, `warning`, `success` | +| `code` | `text`, `language`, `caption` | Never autocorrected; wraps rather than clips | +| `image` | `url`, `alt`, `caption` | `url` comes from the image upload endpoint | +| `table` | `header[]`, `rows[][]` | Simple grid | + +Every block needs: + +```json +{ "id": "b1", "type": "paragraph", "text": "..." } +``` + +**Ids must be unique and stable.** They are what a reader's tick is stored +against, so a save without them is refused, and changing one loses that item's +progress. Checklist items carry their own ids for the same reason: + +```json +{ "id": "b4", "type": "checklist", + "items": [ { "id": "i1", "text": "Confirm the Verified date" } ] } +``` + +An unknown block type renders as nothing rather than an error, so a guide +written against a newer version stays readable on an older one. + +### Inline text + +Block text supports a deliberately small subset: `**bold**`, `*italic*`, +`` `code` ``, `[links](https://example.com)`, and line breaks. Nothing else - +no fonts, sizes or colours. Emphasis is semantic and the theme decides how it +looks, which is what makes dark mode work and keeps a guide written next year +matching one written today. + +Text is escaped **before** formatting is applied, so no markup in the source can +reach the DOM. `javascript:` and `data:` links are dropped. + +--- + +## 2. Creating a guide from a script + +Every endpoint is listed in `docs/api-inventory.json`; this is the workflow. + +```bash +# 1. Create - always starts as a draft +curl -X POST http://localhost:5001/api/guides \ + -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \ + -d '{ + "title": "Replace a part marker controller", + "summary": "Restoring patterns and settings after a swap", + "tags": "partmarker, backups", + "blocks": [ + {"id":"b1","type":"paragraph","text":"The new unit arrives **empty**."}, + {"id":"b2","type":"callout","variant":"warning","title":"Before you start", + "text":"Check the marking head."}, + {"id":"b3","type":"checklist","items":[ + {"id":"i1","text":"Copy the snapshot path"}, + {"id":"i2","text":"Confirm the pattern count"}]} + ] + }' + +# 2. Publish - readers see nothing until this happens +curl -X POST http://localhost:5001/api/guides/12/publish \ + -H "Authorization: Bearer $TOK" +``` + +`$TOK` is a login JWT or a scoped API token; they work the same way here. + +### What the server rejects + +Validation happens at the door rather than at render time, because a malformed +guide stored is a page that fails every time somebody opens it, long after the +client bug that caused it: + +- a block with no `id`, or a duplicate `id` +- an unknown block `type` (the error lists the known ones) +- a checklist item with no `id` +- more than 500 blocks, or more than 2 MB of blocks +- text longer than 20,000 characters in one block + +Errors name the offending block by position: `block 3: needs a stable id`. + +--- + +## 3. Drafts, versions and publishing + +Three separate things, and the distinction is what stops a live procedure +changing under somebody following it: + +| | What it is | +|---|---| +| **Working copy** | The `guides` row. What the editor loads and saves | +| **Revisions** | Immutable snapshots in `guiderevisions`, written when content changes | +| **Published pointer** | Which revision READERS get | + +- `PUT /api/guides/` saves the working copy and writes a revision **only if + the content hash changed**. An autosave that changed nothing records nothing, + so the history stays readable. The response carries `saved: false`. +- The hash covers title, summary, tags **and** blocks - renaming or retagging is + a change a reader notices. +- `POST /api/guides//publish` **promotes an existing revision**. It does not + snapshot, so a publish cannot ship unsaved edits nobody reviewed. +- `POST /api/guides//restore/` brings old content back as a + **new** revision. History is never rewritten. +- Drafts are invisible to readers, to `GET /api/guides/` (404, not 403), + and to global search. + +--- + +## 4. Images + +```bash +curl -X POST http://localhost:5001/api/guides/images \ + -H "Authorization: Bearer $TOK" -F file=@screenshot.png -F guideid=12 +``` + +Returns `{"url": "/api/guides/images/5", ...}` for use as an image block's +`url`. PNG, JPEG, GIF and WebP, up to 8 MB. `guideid` is optional because an +image is pasted before a new guide has an id. + +Bytes live in the database, not on disk: a row travels with a database backup, +whereas a file under `instance/` has been lost to a container rebuild on this +deployment before. + +**Known gap:** images uploaded to a guide that is then abandoned are never +pruned. + +--- + +## 5. Proofing + +The editor runs checks continuously and warns rather than blocking. They exist +because none of these are spelling mistakes: + +- a **smart quote or long dash in a code block** - autocorrect does this, the + command then fails, and it still looks right to the author (one-click fix) +- an empty checklist item, which reads as a step somebody forgot to write +- a heading with nothing under it, left behind by a reorganise +- an image with no alt text +- a leftover `TODO`, `TBD` or `FIXME` +- no summary or tags, which makes a guide hard to find + +Publishing with unresolved errors asks for confirmation; it does not refuse. A +procedure that exists imperfectly beats one nobody wrote. + +--- + +## 6. Printing + +Print always renders **light**, whatever the screen theme, because a dark page +lays a full sheet of toner down and knocks the text out in white. Buttons and +progress bars are dropped, steps and code blocks are kept whole across page +breaks, code wraps rather than clips, and checkboxes print **empty** even where +ticked on screen - a printout is a worksheet somebody ticks by hand. The footer +carries the version and the print date, so a stale copy on a noticeboard is +spottable. + +--- + +## 7. Permissions + +| Permission | Allows | +|---|---| +| `guides.view` | Read guides | +| `guides.create` | Create, and edit your OWN guide while it is unpublished | +| `guides.edit` | Edit any guide, including published ones | +| `guides.publish` | Publish or withdraw | +| `guides.delete` | Delete | + +The split between `edit` and `publish` is deliberate: a technician can write a +procedure up while somebody else decides it is fit for the floor. + +--- + +## 8. Converting existing documents + +There is no importer; conversion is a script you write against section 2. What +to expect from a source format: + +- **Structured HTML and Markdown convert cleanly** - headings, fenced code, + lists, tables and callout divs all have matching block types. The GE Aerospace + KB template's `alert alert-warning` divs map to warning callouts, `code-block` + to code blocks, `step` divs to steps. +- **Plain-text runbooks convert poorly.** ASCII underlines and hand-indented + lists carry no structure, so everything lands as paragraphs and a person marks + up the steps afterwards. Worth doing anyway - the text is then searchable and + versioned - but budget the cleanup. +- Generate ids yourself and keep them stable across re-imports, or every + re-import resets readers' progress. + +--- + +## See also + +- `docs/api-inventory.json` - every endpoint with auth, params and an example +- `plugins/guides/services/blocks.py` - the validation rules, and why each exists +- `plugins/guides/services/authoring.py` - the single write path diff --git a/docs/IMPORT-ADOPTION.md b/docs/IMPORT-ADOPTION.md new file mode 100644 index 00000000..ba4e4baa --- /dev/null +++ b/docs/IMPORT-ADOPTION.md @@ -0,0 +1,96 @@ + + +# Building a per-site import loader + +**Four import pages.** Shaping the loader: this page. The HTTP wire contract it +drives: [IMPORT-API](IMPORT-API.md), which this page assumes you have read and +does not restate. Spreadsheet and no developer: [CSV-IMPORT](CSV-IMPORT.md). +Source is the classic-ASP shopdb: [IMPORT-LEGACY-ASP](IMPORT-LEGACY-ASP.md). + +## Two layers, and why the split holds + +Every adopting site has its own source database, and it will not match another +site's schema. So the import is split: + +1. **The import API is the stable contract.** Whatever your source looks like, + you create records through the same documented REST endpoints. This layer is + the product; it is schema-agnostic and documented once, in IMPORT-API. +2. **A per-site loader is thin glue.** It reads *your* source database and POSTs + to those endpoints. Nobody runs another site's loader - you copy the pattern. + +Keeping the two apart is what stops one site's judgement calls from becoming +everyone's contract. + +## The shape of a loader + +The reference implementation lives under `scripts/site_imports//`. Read it +alongside this page. + +- `harness.py` - builds the app against the target `DATABASE_URL`, mints an + unscoped admin PAT in-process, and drives the real endpoints through the app + test client with `Authorization: Bearer ` + `X-Import-Mode: true`. This + exercises the same routes, authorization and validation an HTTP client would, + with no running server needed. It also holds read-only access to the source DB + and a JSON `IdMap` of legacy-id to new-id crosswalks. +- `run.py` - ordered `stage_*` functions. Each reads a slice of the source, + POSTs it, and records the crosswalk later stages resolve foreign keys against. + +Post-import fixups that re-point existing assets (for example +`scripts/reclassify_servers_to_network.py`, which moved servers imported as PCs +to network devices in place) belong in the site loader's verify stage, never in +the API layer. + +### The crosswalk is the keystone + +Reference and lookup tables first so foreign keys resolve, then the entity hub, +then dependents, then links: + +``` +reference -> catalog -> assets (persist the source-id -> assetid crosswalk) + -> dependents (installs, warranties, notifications, ...) -> relationships +``` + +Capture every legacy id to new id as you create rows, and resolve foreign keys +through it in later stages. New autoincrement ids will not match the source's. +The full ordering rule is section 4 of [IMPORT-API.md](IMPORT-API.md). + +## Producing the mapping + +You do not have to hand-derive the source-to-target mapping. Point an +agent-assisted workflow at a source database plus the API contract and it emits +a per-table mapping (source columns to endpoint fields, transforms, what is +importable and what is out of scope) plus a loader skeleton. That is the +repeatable onboarding path, and +[IMPORT-LEGACY-ASP.md](IMPORT-LEGACY-ASP.md) is a finished example of the output. + +## Running it, against a THROWAWAY import database + +1. Build a fresh target: `flask db upgrade`, `flask plugin upgrade-all`, then + `flask seed permissions/settings/reference-data`. Enable every bundled plugin + you need - some ship disabled, and a plugin's routes only register when it is + enabled at app start. +2. Load your source dump into a scratch database the loader can read. +3. Run the loader stages in order, dry-running and spot-checking as you go. +4. Verify: row-count and foreign-key-resolution audit against the source, then a + UI spot-check - log in and eyeball the lists, the map, a detail page. +5. Only then point a real instance at the imported database. + +## What a mature loader ends up handling + +From the reference implementation, as a checklist of what to expect: + +- Fanning one source "machine" table out to several asset types + (computer/machine/network/measuring-tool) by a routing rule, with the + duplicate, placeholder and skip decisions applied. +- Synthesizing a natural key where the source lacks one (printers to `PRN-{id}`). +- Folding a primary IP onto an asset, pairing a check-in/out event log into + checkouts, deduping colliding names, reversing an inverse relationship type. +- The handful of narrow gaps the API cannot cover (there is no bulk + communications endpoint, for instance) handled as documented direct-ORM + writes. diff --git a/docs/IMPORT-API.md b/docs/IMPORT-API.md new file mode 100644 index 00000000..6ffb9acf --- /dev/null +++ b/docs/IMPORT-API.md @@ -0,0 +1,283 @@ + + +# Import API: loading a site's data through HTTP + +**Four import pages.** The HTTP wire contract: this page. Spreadsheet and no +developer: [CSV-IMPORT](CSV-IMPORT.md). How to shape a per-site loader around +this contract: [IMPORT-ADOPTION](IMPORT-ADOPTION.md). Source is the classic-ASP +shopdb: [IMPORT-LEGACY-ASP](IMPORT-LEGACY-ASP.md) on top of this page. + +This page is schema-agnostic: it says how to authenticate, how to preserve a +legacy row's real timestamps, and how to make a run rerunnable. It does NOT map +anybody's source columns - that is the loader's job. + +Every row is created through a documented endpoint, so authorization, +validation, auditing and plugin hooks run exactly as they do for a human +operator. No direct writes to the application database are needed or wanted. + +Contents: [1 Authentication](#1-authentication) | +[2 Import mode](#2-import-mode-the-x-import-mode-header) | +[3 Reference-data seed](#3-reference-data-seed) | +[4 Order of operations](#4-order-of-operations) | +[5 The idempotent upsert recipe](#5-the-idempotent-upsert-recipe) | +[6 Verifying a run](#6-verifying-a-run) + +--- + +## 1. Authentication + +Examples on this page use `http://localhost:5001`, a development instance. +Substitute your own base URL; every import call targets `/api/...` on it. + +Every write needs authentication, and import mode additionally needs an admin. + +A large import can outlast a login JWT: `access_token` expires after one hour, +so a long run dies mid-import with 401s. Use a **personal API token (PAT)** +instead. A PAT never expires (unless you set an expiry), acts as the user that +created it, and is sent exactly like a JWT: `Authorization: Bearer +shopdb_pat_...`. Create one as an admin, from Settings > API Tokens or the API: + +```bash +# Bootstrap: a short login JWT is fine just to mint the long-lived PAT. +JWT=$(curl -s http://localhost:5001/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"","password":""}' | jq -r '.data.access_token') + +# The full secret (shopdb_pat_...) is returned ONCE. Save it now. +curl -s http://localhost:5001/api/apitokens \ + -H "Authorization: Bearer $JWT" \ + -H 'Content-Type: application/json' \ + -d '{"name":"legacy import runner"}' | jq -r '.data.secret' +``` + +Revoke it from the same Settings page (or `DELETE /api/apitokens/`) when +the import is done. Minting one needs the `apitokens.create` permission, which +admins have by default. + +Use an **unscoped** token. A token may optionally carry a scopes list that +limits it to specific permissions; a scoped token suspends the admin bypass and +is denied on role-gated endpoints AND on import mode, so it cannot run an +import. Leave the "Restrict permissions" option off, which is the default. + +## 2. Import mode: the `X-Import-Mode` header + +By default the server stamps `createddate`/`modifieddate` to "now" on every +create and update, which would erase a migrated row's real history. To preserve +it, send the request header: + +``` +X-Import-Mode: true +``` + +When (and only when) the caller is an admin AND that header is present: + +- create/update endpoints on timestamped entities accept optional + `createddate` and `modifieddate` fields in the JSON body and store them + verbatim (naive UTC). Both `2020-01-05T12:00:00` (ISO) and the legacy + `2020-01-05 12:00:00` (MySQL) forms are parsed. A bare `2020-01-05` works too. + +Without the header, or for a non-admin caller, those fields are silently ignored +and the server behaves exactly as it does normally. This is enforced centrally +by `shopdb/utils/import_mode.py` (`import_mode_active`, `apply_import_timestamps`, +`parse_import_datetime`), exposed on the plugin contract surface `shopdb.api`. + +Timestamped entities that honor `createddate`/`modifieddate`: assets (every +type plugin), vendors, models, modeltypes, businessunits, locations, operating +systems, applications, knowledge base, asset relationships. + +Entities that carry history in domain fields instead (createddate passthrough is +a no-op there, by design): notifications (`starttime`/`endtime`), warranties +(`startdate`/`enddate`/`lastcheckeddate`). Set those fields directly in the +payload; they are already accepted. + +## 3. Reference-data seed + +Before importing, seed the reference tables that have no CRUD endpoint of their +own (communication types such as IP/Serial/USB, default statuses, canonical +relationship types, permissions, settings): + +```bash +flask seed permissions +flask seed settings +flask seed reference-data +``` + +`communicationtypes` is populated here, so a primary-IP mapping can resolve +`comtype='IP'`. There is no CRUD endpoint for it; the seed is the only way in. + +--- + +## 4. Order of operations + +Import in dependency order so foreign keys always resolve. Each step is a +lookup-then-upsert loop (section 5); rerunning any step is safe. + +1. **Reference and lookup data first** + 1. Vendors (`/api/vendors`) + 2. Model types (`/api/modeltypes`) + 3. Models (`/api/models`) - needs vendors and model types + 4. Business units (`/api/businessunits`) + 5. Location types, then locations (`/api/locations/types`, `/api/locations`) + 6. Operating systems (`/api/operatingsystems`) + 7. Asset statuses (`/api/assets/statuses`) + 8. Relationship types (`/api/assets/relationshiptypes`) + 9. Notification types (`/api/notifications/types`) + 10. Per-plugin subtypes: computer, machine, printer and network-device types + 11. Support teams and their contacts (`/api/supportteams`, + `/api/supportteams/{id}/contacts`) - BEFORE applications, because + `applications.supportteamid` points at a team + 12. Applications (`/api/applications`) and their versions +2. **Assets, per type.** Each create makes the core Asset row plus its + extension: computers, machines, printers, network devices. +3. **Communications.** The primary IP is set through the asset payload's + `ipaddress` field in step 2. There is no bulk-communications endpoint, so + secondary interfaces have no target; carry them as custom fields or defer + them. +4. **Relationships** (`/api/assets/relationships`): needs both endpoint assets + and the relationship types to exist already. +5. **Installed applications** (`/api/computers/{id}/apps`): needs computers and + applications. +6. **Knowledge base, notifications and warranties**, including backdated + history. +7. **Custom fields** for any source column with no home in the target schema: + `POST /api/customfields` once per field, then + `PUT /api/customfields/asset/{assetid}`. Custom-field values are not + timestamped, so they carry no history. + +### A map position needs its level + +`mapx`/`mapy` are absolute pixels in the native coordinate space of ONE level +(ADR-017), so an import that sends coordinates without `levelid` produces +markers the map renders as "level unknown" rather than drawing them on the +default level. A source that predates levels has one floor plan, so the right +value is that site's default level: + +```bash +curl -s http://server/api/maplevels | python -c "import json,sys; print(json.load(sys.stdin)['data']['defaultlevelid'])" +``` + +Send that `levelid` on every row carrying coordinates. If an import already ran +without it, the levels migration's backfill is not retroactive - assign the rows +with `POST /api/mappositions/positions`, which requires `levelid` per row for +exactly this reason. Loading and recalibrating the plans themselves is +[FLOOR-MAP.md](FLOOR-MAP.md). + +--- + +## 5. The idempotent upsert recipe + +The endpoints are NOT upserts. The idempotent unit is a two-step recipe that +composes with import mode: + +1. **Look up** the row by its natural key using the exact-match list filter. +2. If found, **PUT** to update it; if not, **POST** to create it. + +Each import-relevant list endpoint has an exact-match filter for its natural key +(added for exactly this purpose): + +| entity | lookup | +|---|---| +| assets (all 5 plugins) | `GET /api/{plugin}?assetnumber=` | +| vendors | `GET /api/vendors?vendor=` | +| models | `GET /api/models?modelnumber=&vendor=` | +| model types | `GET /api/modeltypes?modeltype=` | +| business units | `GET /api/businessunits?businessunit=` | +| locations | `GET /api/locations?locationname=` | +| operating systems | `GET /api/operatingsystems?osname=` | +| applications | `GET /api/applications?appname=` | +| knowledge base | `GET /api/knowledgebase?linkurl=` | +| warranties | `GET /api/warranty?servicetag=&vendor=` | +| notifications | `GET /api/notifications?ticketnumber=` | + +### Worked example + +The loop, with the two headers that matter and a dry run. A single bad row +reports and keeps going: aborting a multi-hour run on row 4000 is worse than +finishing with a list of failures. + +```python +import os +import requests + +BASE = "http://localhost:5001" + + +class ImportClient: + def __init__(self, token=None, dryrun=False): + self.session = requests.Session() + self.dryrun = dryrun + self.session.headers.update({ + # A PAT (shopdb_pat_...) does not expire like a login JWT, so it + # survives a long import. X-Import-Mode makes the createddate / + # modifieddate passthrough take effect. + "Authorization": f"Bearer {token or os.environ['SHOPDB_TOKEN']}", + "X-Import-Mode": "true", + }) + + def lookup(self, path, params): + resp = self.session.get(f"{BASE}{path}", params=params) + resp.raise_for_status() + rows = resp.json().get("data") or [] + return rows[0] if rows else None + + def upsert(self, path, idfield, lookupparams, payload): + """Lookup by natural key; PUT if found, else POST. Returns the row.""" + existing = self.lookup(path, lookupparams) + if self.dryrun: + print(f"[dry-run] {'PUT' if existing else 'POST'} {path} {lookupparams}") + return existing or payload + if existing: + resp = self.session.put(f"{BASE}{path}/{existing[idfield]}", json=payload) + else: + resp = self.session.post(f"{BASE}{path}", json=payload) + if resp.status_code >= 400: + print(f"ERROR {resp.status_code} {path}: {resp.text[:200]}") + return None + return resp.json()["data"] + + +# One entity, in the order of section 4. Legacy history is preserved because +# X-Import-Mode is set. +client.upsert( + "/api/vendors", + idfield="vendorid", + lookupparams={"vendor": row["vendor"]}, + payload={"vendor": row["vendor"], + "createddate": row.get("dateadded"), + "modifieddate": row.get("lastupdated")}, +) +``` + +Keep a `legacy_id -> new_id` dictionary for every entity as you import it; you +need it to remap foreign keys (a machine's `businessunitid`, a checkout's +`machineid`, a relationship's `machineid`/`related_machineid`, and so on). + +--- + +## 6. Verifying a run + +Compare counts after each phase. The new side exposes a total in the pagination +metadata, so one call per entity answers it: + +```bash +curl -s "http://localhost:5001/api/vendors?per_page=1" \ + -H "Authorization: Bearer " | jq '.meta.pagination.total' +``` + +Count the same thing on the source however that source is queried, and +investigate any gap. Where your loader routes one source table to several target +plugins, the source-side count has to be filtered the same way the routing was, +or the two numbers are not measuring the same set. + +Beyond counts: resolve-rate on foreign keys (nothing silently landed NULL), then +a UI spot-check - log in, open a list, the map, and one detail page. + +The wider API surface and its auth model is the generated spec at `GET /api/docs` +(`docs/openapi.json`); [llms.txt](llms.txt) is the short version of the envelope +and the auth model. diff --git a/docs/IMPORT-LEGACY-ASP.md b/docs/IMPORT-LEGACY-ASP.md new file mode 100644 index 00000000..678e1e2d --- /dev/null +++ b/docs/IMPORT-LEGACY-ASP.md @@ -0,0 +1,163 @@ + + +# Migrating the classic-ASP shopdb: the record + +**Read this only if your source is the classic-ASP shopdb.** It is one site's +completed migration, kept for the parts a second site would otherwise rederive +and for the parts that still matter after the import is over: how the `machines` +hub was fanned out, which judgement calls were made, and which legacy tables +were deliberately left behind. + +It is NOT the product contract. Authentication, `X-Import-Mode`, the seed, the +ordering rule, the idempotent upsert recipe and the verification method are in +[IMPORT-API.md](IMPORT-API.md), which is current and schema-agnostic. Read that +first; this page assumes it. The mechanical column-by-column mapping that the +run was driven from is not reproduced here - the source and target column names +match closely enough to re-derive, and a copy would only drift. + +What dates it, and what you must re-check against your own database rather than +trust here: the `machinetypeid` integers below are ids from ONE site's +`machinetypes` lookup, not a shared vocabulary; the row counts are that +database's; and the routing is a recommended default that a site may +legitimately depart from. + +--- + +## The `machines` hub fans out into the asset plugins + +`machines` (885 rows) is the central legacy asset table. Two columns drive the +fan-out: `machinetypeid` (what the asset physically is) and `pctypeid` (a +computer's sub-type). Route each row by `machinetypeid`: + +| legacy `machinetypeid` | `machinetypes.machinetype` | target plugin | subtype source | +|---|---|---|---| +| 1 | LocationOnly (also `islocationonly=1`) | core **Locations** (NOT an asset) | `locationtype` | +| 33 | PC | **computers** | `computertype` from `pctype.typename` via `machines.pctypeid` | +| 20 | Server | **computers** | computer type "Server" | +| 15 | Printer | **printers** | `printertype` (authoritative source is the legacy `printers` table) | +| 16 Access Point / 17 IDF / 18 Camera / 19 Switch / 46 Firewall | | **network** | `networkdevicetype` | +| 44 | USB Device | **usb** | (usb device) | +| 23 Measuring Machine / 3 CMM / 48 Spline Checker / 8 Eddy Current / 47 Inspection | | **measuringtools** (gage-lab judgment call; ADR-005) | `measuringtooltype` | +| 2,4,5,6,7,9,10,11,12,13,14,21,22,24,25,45 (lathes, mills, welders, grinders, ...) | | **machines** | `machinetype` | + +Decide the routing table once, up front: it is what every later count, report +and parity check is measured against. A site may re-route a `machinetypeid` - +sending CMM to `machines` rather than `measuringtools`, for example. + +Non-obvious column mappings on the hub, the ones that are not a same-name copy: + +- `machinenumber` is the `assetnumber` (the business identifier and the natural + key); `alias` or `hostname` becomes the layperson `name`. +- `mapleft`/`maptop` become `mapx`/`mapy`, which are pixels of ONE level. An + import that omits `levelid` produces markers that are never drawn - see + [IMPORT-API.md](IMPORT-API.md#a-map-position-needs-its-level). +- `machinestatusid`, `businessunitid`, `osid` are remapped through the lookups + imported before the assets. +- `dateadded`/`lastupdated` become `createddate`/`modifieddate`, honored in + import mode only. +- `ipaddress1` is the primary IP, folded onto the asset as `ipaddress`. + +## Lookups that are not a straight copy + +Most legacy lookup tables map name-to-name onto the obvious endpoint. These do +not: + +| legacy table | target | the catch | +|---|---|---| +| `machinetypes` | `POST /api/modeltypes` | becomes the MODEL type vocabulary (ADR-011), and needs a `category` (Equipment/Computer/...) the legacy row does not carry | +| `controllertypes` | `vendors` + `models` | one legacy row splits in two: "Fanuc" becomes a Vendor, the controller model becomes a Model, then set `controllervendorid`/`controllermodelid` on the machine | +| `pctype` | `POST /api/computers/types` | `typename` becomes `computertype`; also drives the computer subtype in the fan-out above | +| `subnets` | `POST /api/network/subnets` | create the VLAN first (`POST /api/network/vlans`), then reference it; `subnettypeid` resolves to a `subnettype` NAME, not an id | +| `dashboarddefaults` | `POST /api/dashboarddefaults` | resolution is FQDN-first with IP fallback, so import both keys when the source has them | +| `comstypes` | `communicationtypes` | seeded, not imported. `flask seed reference-data` creates IP/Serial/USB/... and there is no CRUD endpoint | +| `topics` | `POST /api/applications` | `topics` is a near-clone of `applications` and `knowledgebase.appid` points at it, so each topic imports as an Application or the KB links resolve against nothing | +| `appowners` | `POST /api/supportteams/{id}/contacts` | folded into contacts: each `supportteams` row carries one `appownerid`, which becomes ONE contact on that team | + +**Model images** import as a plain URL string only. Binary photos are not part +of the import payload; upload them afterwards with +`POST /api/models//image`. Employee photos work the same way +(`POST /api/employees//photo`), and only self-hosted mode owns people at +all - in external mode the photo is a read-only pass-through from the HR +database. + +## Relationships + +`machinerelationships` becomes `POST /api/assets/relationships`, with +`machineid`/`related_machineid` resolved through the legacy-id crosswalk to +`sourceassetid`/`targetassetid`. The legacy type names do not survive as they +are; this is the mapping that was used: + +Controls and Master-Slave become **Controls**; Controlled By becomes **Controls** +with source and target reversed; Cluster Member and Contains become **partof**; +Connected To becomes **connectedto**; Dualpath, Backup For and Stored At keep +their own types. Create the types before the links. + +## Data quirks worth knowing about + +- **`notifications.endtime` carries the sentinel `2099-00-03 09:52:32`**, which + is not a valid date. Drop or clamp it; a straight copy fails the parse. +- **`installedapps` only makes sense for computer-class assets.** Skip rows + whose `machineid` did not route to a computer. +- **`warranties` needs a `vendor`** that the legacy row may not carry. It is + required on create. +- **USB history replays as events, not rows**: create the device + (`POST /api/usb`), then replay each `usbcheckouts` row as a checkout and, if + it was returned, a checkin, carrying `checkouttime`/`checkintime`. Those + overrides are honored in import mode only. +- Any legacy column with no target field (`machines.logicmonitorurl`, + `machines.fqdn`, `printers.printerpin`) becomes a custom field - the recipe is + step 7 of [IMPORT-API.md](IMPORT-API.md#4-order-of-operations). + +--- + +## Tables with no target + +These legacy tables have no import target. The dispositions are DECIDED, not +open questions. + +- **`dncconfig` and `commconfig`** - intentionally NOT migrated. DNC + communication settings drift constantly, so a one-shot import of stale data + has little value. The plan is a future DNC feature fed live by the GE-Enforce + collector rather than a historical import. When that is built, the expected + ingestion pattern is one attribute at a time across the whole facility (sweep + every machine's baud rate in one pass, then ports, and so on), so the design + should favor per-field fleet-wide updates over per-machine full-record + imports. +- **`compliance`, `compliancescans`** - 0 rows. A future compliance plugin would + own them. Skip. +- **`ednc_installations`, `ednc_logs`** - 0 rows, and they belong to the eDNC + tooling rather than the asset catalog. Skip. +- **`distributiongroups`** (2 rows) - email distribution lists referenced by + `businessunits.distributiongroupid`. No target; skip, or attach as a business + unit custom field. +- **`functionalaccounts`** (7 rows) - service-account concept referenced by + `pctype`/`machinetypes`; no equivalent in the new schema. Skip, or capture as + a computer-type custom field. +- **`skilllevels`** (2 rows) - orphaned lookup (no FK from `machines`). Skip. + +## Row-count parity + +The method is section 6 of [IMPORT-API.md](IMPORT-API.md). What is specific to +this source is the legacy-side `WHERE` clause, because the fan-out means one +legacy table feeds several targets. Adjust each to match the routing you chose: + +| entity | legacy count | new count | +|---|---|---| +| computers | `machines WHERE machinetypeid IN (33,20)` | `GET /api/computers` total | +| machines | `machines WHERE machinetypeid IN (2,4,5,6,7,9,10,11,12,13,14,21,22,24,25,45)` | `GET /api/machines` total | +| network devices | `machines WHERE machinetypeid IN (16,17,18,19,46)` | `GET /api/network` total | +| USB devices | `machines WHERE machinetypeid=44` | `GET /api/usb` total | +| printers | `printers WHERE isactive` | `GET /api/printers` total | +| applications | `applications` | `GET /api/applications?showhidden=true` total | +| knowledge base | `knowledgebase WHERE isactive` | `GET /api/knowledgebase` total | +| relationships | `machinerelationships WHERE isactive` | per-asset `GET /api/assets/{id}/relationships` | +| USB checkouts | `usbcheckouts` | `GET /api/usb/checkouts` | + +Vendors, models, business units and the other straight lookups compare +one-to-one and need no clause. diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 00000000..9fdf05ab --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,178 @@ + + +# Documentation index + +Find the row that matches what you are about to do, read the pages in the +"Read" column, and skip everything else. That is the whole intent: these docs +are larger than any one person or agent can hold, so the only useful thing an +index can do is tell you which few pages you actually need. + +**Lines** is the total size of that row's page set. It is there so you can see +the cost before you pay it. A one-page row is one page on purpose. + +**Derived, not authored.** Every row comes from the `docmeta` block at the top +of the page it names - its `purpose`, `status` and `summary` - and the page +itself is the source of truth. So do not improve a row here: fix the `docmeta` +on the page and bring the row into line with it. `tests/test_docs_index.py` +fails when this file and the pages disagree, when a current page is reachable +from no task row, when a row misstates what it costs to read, and when a link +here or in START-HERE points at a file that is not there. + +New to the project and not sure which row is yours: [START-HERE](START-HERE.md) +is shorter and picks for you. An agent should read [llms.txt](llms.txt) first. + +## Tasks + +### Run a site + +Docker is the topology this product line ships, and the only one. There is no +Windows installer here and no IIS procedure to follow. + +| Task | Read | Lines | +|---|---|---| +| Stand up a new site on Docker at a connected facility | [DEPLOY](DEPLOY.md) | 500 | +| Stand up a site with no internet at all | [DEPLOY-AIRGAP](DEPLOY-AIRGAP.md) | 180 | +| Build the image where npm cannot reach the registry | [DOCKER-PREBUILT-APPROACH](deployment/DOCKER-PREBUILT-APPROACH.md) | 87 | +| Try the product on a Windows box without building it | [DEV-BUILD-TEST](DEV-BUILD-TEST.md) | 56 | +| Upgrade a Docker or venv site to a newer version | [UPGRADE](UPGRADE.md) | 150 | +| Back up a site, or restore one | [BACKUP-RESTORE](BACKUP-RESTORE.md) | 202 | +| Find a setting, an environment variable or its default | [CONFIG](CONFIG.md) | 356 | +| Decide which plugins this site should carry | [PLUGINS](PLUGINS.md) | 134 | +| Answer whether this site is exposed to a published advisory | [DEPENDENCY-SECURITY](DEPENDENCY-SECURITY.md) | 108 | +| Hand somebody a password without leaving it in a chat | [SECURE-SEND](SECURE-SEND.md) | 157 | + +### Fill a site with data + +| Task | Read | Lines | +|---|---|---| +| Load starting data from spreadsheets, with no developer | [CSV-IMPORT](CSV-IMPORT.md) | 139 | +| Load starting data from a source database | [IMPORT-API](IMPORT-API.md) + [IMPORT-ADOPTION](IMPORT-ADOPTION.md) | 379 | +| Look up the import wire contract only | [IMPORT-API](IMPORT-API.md) | 283 | +| Load the floor plans, or move every marker onto a redrawn plan | [FLOOR-MAP](FLOOR-MAP.md) | 137 | +| Write a procedure technicians will follow on the floor | [GUIDES](GUIDES.md) | 237 | +| File several devices that share one asset number | [ASSET-COMPOSITION](ASSET-COMPOSITION.md) | 158 | + +### Shop-floor PCs + +| Task | Read | Lines | +|---|---|---| +| Understand what runs on a bay and which page to open next | [FLEET-ARCHITECTURE](FLEET-ARCHITECTURE.md) | 144 | +| Understand what the collector is before choosing a page | [COLLECTOR-INTEGRATION](COLLECTOR-INTEGRATION.md) | 85 | +| Get the shipped reporters running on a fleet, and prove it | [COLLECTOR-CLIENT-SETUP](COLLECTOR-CLIENT-SETUP.md) + [COLLECTOR-AUTH](COLLECTOR-AUTH.md) | 439 | +| Write a client that posts to the collector API | [COLLECTOR-PAYLOADS](COLLECTOR-PAYLOADS.md) + [COLLECTOR-AUTH](COLLECTOR-AUTH.md) | 691 | +| Decide how a PC authenticates - per-PC credential or shared key | [COLLECTOR-AUTH](COLLECTOR-AUTH.md) | 292 | +| Fix a bay showing the wrong slides | [EVENTSAVER](EVENTSAVER.md) | 144 | +| Install printers from ShopDB, or read the installer endpoints | [PRINTER-INSTALLER](PRINTER-INSTALLER.md) | 140 | +| Point the shop-floor tools at a different site's ShopDB | [ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md) | 290 | +| Explain why a host's backup is listed as stopped | [BACKUP-STALENESS](BACKUP-STALENESS.md) | 104 | + +### Build on the platform + +| Task | Read | Lines | +|---|---|---| +| Write and install a first plugin | [PLUGIN-QUICKSTART](PLUGIN-QUICKSTART.md) + [PLUGIN-HOOKS](PLUGIN-HOOKS.md) | 1059 | +| Look up one hook, manifest field or shopdb.api import | [PLUGIN-HOOKS](PLUGIN-HOOKS.md) | 866 | +| Check a plugin against the conventions, and the traps that have bitten people | [PLUGIN-GUIDE](PLUGIN-GUIDE.md) + [PLUGIN-HOOKS](PLUGIN-HOOKS.md) | 1060 | +| Ship a plugin from its own git repository | [PLUGIN-EXTERNAL-REPO](PLUGIN-EXTERNAL-REPO.md) + [PLUGIN-HOOKS](PLUGIN-HOOKS.md) | 1152 | +| Choose how tightly to pin a sister site to the contract | [CONTRACT-STABILITY](CONTRACT-STABILITY.md) | 149 | +| Sign a plugin artifact, or make a site verify signatures | [PLUGIN-SIGNING](PLUGIN-SIGNING.md) | 149 | +| Add a backup kind to the backups plugin | [BACKUP-KINDS](BACKUP-KINDS.md) | 52 | +| Set up a dev environment and contribute a change back | [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md) | 390 | +| Call the API: where the generated spec is, which surface owns what, how auth works | [API-REFERENCE](API-REFERENCE.md) | 89 | +| Look up current versions, plugin list or Alembic heads | [PROJECT-MAP](PROJECT-MAP.md) | 94 | +| Find out what has shipped, and what is left before 1.0 | [ROADMAP](ROADMAP.md) | 210 | +| Find which architectural decision governs a question | [adr/README](adr/README.md) | 69 | +| Find the design record behind a shipped feature | [proposals/README](proposals/README.md) | 27 | + +**There is no build-along lab on this line.** The five-part tutorial was written +around a plugin this product does not carry, and a tutorial that walks a reader +through a directory they do not have is worse than none. Learn the contract from +PLUGIN-QUICKSTART and PLUGIN-HOOKS, scaffold with `flask plugin scaffold`, and +read `plugins/warranty/` - the smallest bundled plugin that owns tables, +permissions, an asset panel and a dashboard card. + +Looking for one endpoint? Do not look in a page. The generated spec is served +at `GET /api/docs` on any running instance and tracked as `docs/openapi.json`; +[llms.txt](llms.txt) is the short version - auth, the response envelope, and +the recipes people get wrong. The API-REFERENCE row above is the pointer to +both, plus the surface and auth orientation a spec cannot give. + +### Decisions + +One ADR answers one question. Read the one row that matches and stop - +nothing here is a prerequisite for anything else. [adr/README](adr/README.md) +is the authoritative ADR index and the only place that records whether a +decision still holds; the lines below are the pages' own summaries, for +routing. + +| Question it settles | Read | Lines | +|---|---|---| +| Why Asset, not the legacy Machine model, is the surface plugin authors may depend on | [ADR-001](adr/ADR-001-asset-as-platform-contract.md) | 200 | +| How the plugin contract is versioned and what core_version a plugin must declare | [ADR-002](adr/ADR-002-plugin-versioning.md) | 68 | +| How a plugin reaches a site: bundled in-tree, or dropped into plugins/ from its own repo | [ADR-003](adr/ADR-003-plugin-distribution.md) | 76 | +| Why every site runs its own instance instead of one multi-tenant deployment | [ADR-004](adr/ADR-004-deployment-topology.md) | 100 | +| How a plugin declares a collector schema so the framework wires the ingest endpoint and auth | [ADR-006](adr/ADR-006-collector-contract.md) | 156 | +| How the product is versioned and released, separately from the plugin contract | [ADR-007](adr/ADR-007-product-versioning-and-releases.md) | 123 | +| Why each plugin owns its own Alembic chain instead of adding tables to the core chain | [ADR-008](adr/ADR-008-plugin-migration-ownership.md) | 131 | +| Why the SPA gates plugin routes, so a disabled plugin URL redirects instead of breaking | [ADR-009](adr/ADR-009-frontend-plugin-gating.md) | 156 | +| The hook contract a plugin uses to contribute UI without hand-editing core Vue files | [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) | 326 | +| Why the equipment domain was renamed to machines and machinetypes became modeltypes | [ADR-011](adr/ADR-011-machines-rename.md) | 75 | +| How a site gets only the plugins it chose, and why curation plus signing is the security model | [ADR-013](adr/ADR-013-plugin-catalog-and-lean-builds.md) | 435 | +| Why a lean site stops creating database tables for plugins it never installed | [ADR-014](adr/ADR-014-schema-lean-per-site.md) | 150 | +| Where a site's own hostnames, paths and defaults are allowed to live, and where they are not | [ADR-015](adr/ADR-015-site-specific-configuration.md) | 156 | +| Why the floor map became buildings and levels instead of one image with stacked floors | [ADR-017](adr/ADR-017-buildings-and-levels.md) | 151 | +| Why metrology instruments are machines, so this product has no measuring-tool asset class | [ADR-018](adr/ADR-018-metrology-instruments-are-machines.md) | 132 | +| Why every endpoint gets its own enrolled secret instead of one fleet-wide shared key | [ADR-019](adr/ADR-019-per-pc-endpoint-credentials.md) | 217 | + +## Archive - do not follow these + +Nothing below is current guidance and nothing above depends on it. A **legacy** +page is still true but superseded: do not start new work from it. A +**historical** page is a finished migration, a design proposal, or a decision +about something this product line no longer carries, kept only as the record of +why something looks the way it does. + +They are listed so you can recognise one if you land on it, and so nobody has +to read one to find out it does not apply. Together they are 1,799 lines that +almost nobody needs. + +| Page | Status | What it is | Lines | +|---|---|---|---| +| [IMPORT-LEGACY-ASP](IMPORT-LEGACY-ASP.md) | historical | Record of one classic-ASP shopdb migration: the fan-out routing, the judgement calls and what was left behind. | 163 | +| [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md) | historical | The split of machinery from metrology tools into two plugins. Superseded by ADR-018. | 163 | +| [ADR-012](adr/ADR-012-geenforce-manifest-ownership.md) | historical | Why ShopDB owned GE-Enforce manifests as data. That plugin is not bundled here; ADR-013 and the API-token resource scopes trace back to this decision. | 137 | +| [ADR-016](adr/ADR-016-credential-delivery.md) | historical | The plan for ShopDB-resolved credential targeting. Never built; superseded by ADR-019. | 204 | +| [deployment/PRODUCTION-READINESS](deployment/PRODUCTION-READINESS.md) | historical | The 2026-09-07 assessment of why deployment was hard. Its gap list became the distributed-release item on the roadmap; its recommendation of Windows/IIS was reversed. | 372 | +| [proposals/dashboard-live-fleet](proposals/dashboard-live-fleet.md) | historical | The case for a worst-first dashboard queue. Built; kept as the record of why. | 179 | +| [proposals/device-identity](proposals/device-identity.md) | historical | One durable identity for the device a PC drives. Phase 1 is built; phases 2 and 3 are not started. | 235 | +| [proposals/printer-assignment](proposals/printer-assignment.md) | historical | The design making printers a property of the machine. Shipped, and the shipped behaviour is in PRINTER-INSTALLER. | 346 | + +## What the docmeta fields mean + +Every page in `docs/` opens with an HTML comment like this one, and this index +is built from those blocks: + +``` + +``` + +- **purpose** - `tutorial` teaches by doing, `howto` is followed under + pressure, `reference` is looked something up in, `explanation` says why. +- **audience** - `operator`, `adopter`, `developer`, `integrator`. +- **status** - `current`, `legacy` (true but superseded), `historical` (a + record, not guidance). +- **summary** - what this index shows instead of the page. +- **loadwith** - the pages that page is not complete without. Usually `none`. + Where a row above lists two pages, that is where the second one came from. diff --git a/docs/PLUGIN-EXTERNAL-REPO.md b/docs/PLUGIN-EXTERNAL-REPO.md new file mode 100644 index 00000000..0ba95148 --- /dev/null +++ b/docs/PLUGIN-EXTERNAL-REPO.md @@ -0,0 +1,286 @@ + + +# Developing a plugin in its own repo + +This guide is for a sister GE Aerospace site (or any third party) building a +shopdb-flask plugin in a git repo it owns, outside the framework tree. It is +the "external plugin" path in [ADR-003](adr/ADR-003-plugin-distribution.md): +the framework ships a bundled set, and you drop your own plugin into +`/plugins//` by clone, submodule, or symlink. No pip packaging +is required for v1 (pip distribution is deferred to v2 per ADR-003). + +> **Windows:** use `venv\Scripts\python` and `$env:FLASK_APP="shopdb"` (not +> `export`). Onboarding: [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md). + +If you have not written a plugin before, start with +[PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) and the hook reference in +[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md). This document only covers the parts that are +different when the plugin lives in its own repo. + +## Recommended repo layout + +Your repo root holds the plugin directory contents directly, so the whole repo +can be dropped in at `/plugins//`. A plugin named `shipping` +in a repo named `sitecode-shipping` looks like this: + +``` +sitecode-shipping/ # your git repo root == the plugin directory + manifest.json # required: name, version, description, core_version + plugin.py # required: your BasePlugin subclass + __init__.py + models/ + __init__.py # exports every model + shipping.py + api/ + __init__.py + routes.py # Flask Blueprint returned by get_blueprint() + migrations/ # per-plugin Alembic chain (ADR-008), if you own tables + env.py + script.py.mako + versions/ + 0001_shipping_baseline.py + tests/ # your own tests (the CI harness runs these) + test_shipping.py + README.md # what it tracks, who maintains it, where to file issues +``` + +Notes: + +- `manifest.json` is the single source of truth for metadata (ADR-002). The + `name` field must match the directory name the site installs it under and + follows the framework naming convention (lowercase concatenated, no + underscores or dashes). Prefix site-specific names with the site code when a + collision across sites is possible, e.g. `sitecode-shipping` + (see [PLUGINS.md](PLUGINS.md) naming policy and ADR-003). +- `migrations/` is only needed if your plugin owns tables. A plugin built + outside the tree never had its tables created by the framework's core chain, + so its `0001` is a REAL baseline that creates them, not a stamp-only anchor. + That distinction is explained once, in [PLUGINS.md](PLUGINS.md#migrations). +- Import core code only through `shopdb.api` and `shopdb.plugins.base`. The + surface, and what happens when you reach past it, are in + [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md#import-surface). + +## Local dev workflow + +Symlinking lets you edit in your own repo while the framework loads the plugin +live. The loader discovers a symlinked directory the same as a real one. + +```bash +# 1. Clone the framework and your plugin repo side by side. +git clone https://github.com/ge-aero/shopdb-flask.git +git clone https://github.com/ge-aero/sitecode-shipping.git + +# 2. Symlink your repo into the framework's plugins/ directory. +# The link name is the plugin name from your manifest.json. +cd shopdb-flask +ln -s ../../sitecode-shipping plugins/shipping +# (use an absolute path if you prefer: ln -s "$(pwd)/../sitecode-shipping" plugins/shipping) +# +# Windows: use a directory junction instead of ln -s. In an ADMIN prompt +# (or with Developer Mode on) from the shopdb-flask dir: +# mklink /D plugins\shipping ..\..\sitecode-shipping +# The plugin loader treats a junction the same as a real directory. + +# 3. Set up the framework as usual. +python3 -m venv venv +venv/bin/pip install -r requirements-dev.txt + +# 4. Install (enable) your plugin. +venv/bin/flask plugin install shipping + +# 5. If your plugin owns tables, apply its migrations. +venv/bin/flask db upgrade # core chain +venv/bin/flask plugin upgrade-all # plugin chains, including yours + +# 6. Run tests. +venv/bin/python -m pytest tests/test_plugin_contract.py +venv/bin/python -m pytest ../sitecode-shipping/tests +``` + +Edits in `../sitecode-shipping` are picked up on the next framework restart, because +the symlink points back at your working tree. + +### If your plugin owns tables, step 5 needs a change to the framework + +This is the one place an external plugin is not self-contained, and it is better +said plainly than discovered at the first migration. + +`flask plugin upgrade-all` builds each plugin's metadata from +`PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`, and raises rather +than guessing when a plugin has no entry: + +``` +RuntimeError: PLUGIN_TABLE_OWNERS has no entry for plugin 'shipping'. +Update shopdb/plugins/alembic_template.py. +``` + +So a plugin that owns tables needs its table names registered in that dictionary +in the framework repository. It is deliberate - the registry is what stops one +plugin's migration touching another's tables, and `tests/test_plugin_migrations.py` +tests it - but it does mean a table-owning external plugin is a two-repository +change: yours, plus a one-line addition upstream. + +Two ways to live with it: + +- **Send the entry upstream.** One line in `PLUGIN_TABLE_OWNERS` plus one in + `EXPECTED_HEAD_REVISION`, and your plugin is a normal citizen from then on. +- **Own no tables.** A plugin that stores nothing of its own - a report, a + dashboard card, a settings page over existing models - has nothing to + register and stays entirely in your repository. More plugins fit this than + expect to. + +## core_version pinning for sister sites + +The framework is pre-1.0. Under semver, any 0.x minor bump is allowed to break +the contract, and this project uses that latitude (see the history in +[CONTRACT-STABILITY.md](CONTRACT-STABILITY.md)). So pin a TIGHT range in your +`manifest.json`, per ADR-002 (pip-style `>=,<`), admitting only the contract +minor you tested against and not the whole 0.x line. + +Read the current contract version from [PROJECT-MAP.md](PROJECT-MAP.md), which +is generated from `shopdb/__init__.py` - a version typed into this page is +wrong within a fortnight, and a plugin pinned to a stale one is refused at +startup. Take that minor and the next one up: if the map says `0.31.0`, your +range is `>=0.31.0,<0.32.0`: + +```json +{ + "name": "shipping", + "version": "1.0.0", + "description": "Tracks shipping-station scanners and label printers", + "core_version": ">=,<", + "dependencies": [] +} +``` + +Do NOT pin the loose `>=0.2.0,<1.0.0` default that `PluginMeta` falls back to. +That default exists so bundled plugins keep loading across minor bumps; an +external plugin should be deliberate and re-test before widening its range. + +What happens at load time on a mismatch (ADR-002): + +| Environment | Behavior on core_version mismatch | +|-------------|-----------------------------------| +| dev / test | The loader re-raises. Startup fails loud so you notice immediately. | +| production | The loader logs an error, marks the plugin incompatible, and excludes it from registration. The rest of the app still starts. | + +When you move a site to a newer framework, bump your `core_version` upper bound +only after the harness below passes against the new ref. + +## CI recipe + +`scripts/test-external-plugin.sh` (in the framework repo) stands up a throwaway +framework at a pinned ref, drops your plugin in as a symlink, and runs the +framework contract tests plus your own `tests/`. It has two modes: + +- **CI / remote** (default): clones the framework at `FRAMEWORK_REF` and builds + a fresh venv. Needs network access to git and PyPI. +- **Local / offline**: set `LOCAL_FRAMEWORK` to a framework checkout on disk. + The script exports that checkout at HEAD with `git archive` and reuses its + existing venv, so it runs with no internet. Useful for air-gapped verification. + +```bash +# CI: test the plugin in the current repo against a pinned tag +PLUGIN_DIR=. FRAMEWORK_REF=v0.5.0 scripts/test-external-plugin.sh + +# Offline: test against a framework checkout already on disk +LOCAL_FRAMEWORK=/opt/shopdb-flask PLUGIN_DIR=. scripts/test-external-plugin.sh +``` + +Its header comment documents every input (`PLUGIN_DIR`, `FRAMEWORK_REF`, +`FRAMEWORK_URL`, `LOCAL_FRAMEWORK`); read it in the file rather than from a copy +on a wiki page, because a copied script is one that stops matching the script +that runs. In order it resolves the plugin name from `manifest.json`, stands up +the throwaway framework, symlinks your plugin into its `plugins/`, clears +`instance/plugins.json`, runs the framework's contract tests, then runs your +plugin's own `tests/` if it has any. Nonzero exit means the plugin is not +compatible with that framework ref. + +### What the harness does and does not check + +The symlinked plugin is discovered and loaded by the plugin loader when the app +starts under test. That validates the things that break a real install: the +manifest parses, the `core_version` range admits the framework's +`__contract_version__` (an out-of-range plugin makes startup fail loud, so the +contract test run errors and the script exits nonzero), models expose +`__tablename__`, and hooks return the right shapes. + +The import-surface scan (`test_plugins_only_import_contract_surface`) covers +symlinked plugins too: the scanner resolves each plugin directory before +walking it, because `Path.rglob` alone does not descend symlinks (pinned by +`test_import_scan_covers_symlinked_plugins`). You can additionally keep an +equivalent import-surface assertion in your own `tests/`, so violations fail +in your repo's CI even when run without the framework harness. A minimal +version: + +```python +# tests/test_import_surface.py +import re +from pathlib import Path + +ALLOWED = ('shopdb.api', 'shopdb.plugins.base') +IMPORT_RE = re.compile(r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE) + +def test_only_contract_surface_imports(): + root = Path(__file__).resolve().parent.parent + bad = [] + for path in root.rglob('*.py'): + if '__pycache__' in path.parts or 'migrations' in path.parts: + continue + for m in IMPORT_RE.finditer(path.read_text()): + mod = m.group(1) or m.group(2) + if mod and not any(mod == a or mod.startswith(a + '.') for a in ALLOWED): + bad.append(f'{path.name}: {mod}') + assert not bad, 'core imports outside shopdb.api / shopdb.plugins.base: ' + '; '.join(bad) +``` + +## GitHub Actions example + +For a plugin repo hosted on GitHub, a workflow calling the harness against a +pinned framework tag (config only, adjust URLs and ref to your setup): + +```yaml +name: plugin-contract + +on: + push: + pull_request: + +jobs: + contract: + runs-on: ubuntu-latest + env: + FRAMEWORK_REF: v0.5.0 + FRAMEWORK_URL: https://github.com/ge-aero/shopdb-flask.git + steps: + - name: Check out the plugin + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + - name: Fetch the harness from the framework + run: | + git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" /tmp/framework + - name: Run the contract harness against this plugin + run: | + PLUGIN_DIR="$GITHUB_WORKSPACE" \ + FRAMEWORK_REF="$FRAMEWORK_REF" \ + FRAMEWORK_URL="$FRAMEWORK_URL" \ + bash /tmp/framework/scripts/test-external-plugin.sh +``` + +## See also + +- [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - generate and install a plugin fast +- [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) - the full hook and import-surface reference +- [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md) - what is settled vs still churning before 1.0 +- [ADR-002](adr/ADR-002-plugin-versioning.md) - contract versioning and core_version ranges +- [ADR-003](adr/ADR-003-plugin-distribution.md) - the bundled vs external distribution model +- [ADR-008](adr/ADR-008-plugin-migration-ownership.md) - per-plugin migration ownership diff --git a/docs/PLUGIN-GUIDE.md b/docs/PLUGIN-GUIDE.md new file mode 100644 index 00000000..b4db9402 --- /dev/null +++ b/docs/PLUGIN-GUIDE.md @@ -0,0 +1,194 @@ + + +# Building a ShopDB plugin: conventions and traps + +This page is the judgement layer. [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) says what +each hook TAKES AND RETURNS; this says what a plugin that works actually DOES +with them - the conventions every bundled plugin follows, the traps that have +cost someone an afternoon, and the checklist to walk before calling a plugin +done. It defines nothing: where it shows a shape, the contract page is right. + +Elsewhere: [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) to get a plugin running +today; [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md) to ship one from its +own repo. + +> **Windows:** use `venv\Scripts\python` and `$env:FLASK_APP="shopdb"` (not +> `export`). Onboarding: [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md). + +**Read `plugins/warranty/` alongside this page.** It is the reference +implementation: own tables, own permissions, a derived status, an asset panel, a +dashboard widget and reports - most of the contract exercised correctly, and +small enough to read in one sitting. `plugins/network/` is the cleanest +asset-only plugin, `plugins/backups/` the one to copy for a revision ledger fed +by the collector, and `plugins/knowledgebase/` for a plugin that owns no assets +at all. + +--- + +## Conventions the contract does not state + +**Identity lives on the `Asset`, not in your table** (ADR-001). The asset +carries the number, the serial, the location and the status; your extension +table carries only what is specific to your domain, keyed one-to-one by +`assetid` with `unique=True` and `ondelete='CASCADE'`. A plugin that adds its +own "asset number" column has forked the platform contract. + +**Derived state is computed at read time, never stored.** Calibration status, +warranty coverage and low-stock are all derived from a date or a count when +they are read. A stored status column goes stale the moment a due date passes +with no write, and then the list badge, the detail badge and the report +disagree. Deriving costs one thing, and it is worth knowing before you choose: +you cannot filter it in SQL. `warranty` applies its coverage filter to the built +rows instead of as a `WHERE`, which is fine for the number of warranties a site +holds and would need rethinking at the size of the PC estate. + +**One payload, one shape.** Create and update take the asset core fields and +the extension fields in ONE body, and every response returns the asset's +`to_dict()` with the extension nested under the plugin's key, so GET, POST and +PUT are the same shape to the frontend. `BaseModel.to_dict` iso-formats +`datetime` but not plain `date`, so a model with `Date` columns formats them +itself (`YYYY-MM-DD`) rather than shipping whatever the driver returned. + +**Reads are `jwt_required(optional=True)`; writes require a permission.** +Anonymous kiosks and signed-out staff read the catalog; any state change needs +a logged-in user holding the right permission, declared by your own +[`get_permissions`](PLUGIN-HOOKS.md#hook-get_permissions) so core never edits +its catalog to accommodate a plugin. The `admin` role bypasses every check, so +an admin can drive the plugin before anyone grants the granular permissions. +Every write also writes an `AuditLog` row. + +**Use the framework response helpers** (`success_response`, `error_response` +with `ErrorCodes`, `paginated_response`, `get_pagination_params`, +`paginate_query`) so every plugin's API looks the same to the frontend. + +**Guard a delete that something still references.** Deleting a subtype that +rows still point at is refused with a 409 and a count, not allowed to blank out +existing rows. + +**Seed reference data in `on_install`, idempotently.** Each `_ensure_*` helper +checks for existence first, so a reinstall does not duplicate rows. Do NOT seed +`Setting` rows this way - declare them from +[`get_settings_defaults`](PLUGIN-HOOKS.md#hook-get_settings_defaults), which +also reaches sites that installed an earlier version. + +**Names are lowercase and concatenated** - `nextcalibrationdate`, not +`next_calibration_date`. `scripts/check-naming-and-style.sh` fails the build +otherwise. The rules are in `CONTRIBUTING.md`. + +**Skipping a hook is a decision worth recording.** `warranty` declares no +`get_config_schema` because warranties are entered by hand: no external system, +no endpoint, no API key. The collector hooks are skipped for the same reason. A +reviewer asks about both, so say why in the plugin class rather than leaving a +silent gap. + +--- + +## Traps + +**`create_plugin_tables` cannot render a foreign key to a core table.** It +builds a per-plugin `MetaData` filtered to the plugin's own tables, so an FK to +`assets` has nothing to resolve against and Alembic raises +`NoReferencedTableError` at compile time. If your tables reference core tables, +hand the DDL to `op.create_table` (the same shape autogenerate produces); the +helper is only for plugins whose tables stand alone. This is a candidate +framework fix - teaching `_get_plugin_metadata` to pull in FK-referenced core +tables - not a rule you should design around. + +**Two registries must know about your plugin**, and the failures are far apart: +`PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py` (missing entry = +`flask plugin upgrade-all` raises rather than guessing) and +`EXPECTED_HEAD_REVISION` in `tests/test_plugin_migrations.py` (missing entry = +a `KeyError` from the test suite). A plugin that later adds `0002` moves its +expected-head line to the new revision. Whether your `0001` creates tables or +is a stamp-only anchor is decided in [PLUGINS.md](PLUGINS.md#migrations). + +**Charset comes from the connection.** A plain `CREATE TABLE` inherits the +database default, which on the documented utf8mb4 database is what you want and +matches how the core chain creates its tables. Check any unique column against +the InnoDB index prefix limit before you widen it (utf8mb4 `VARCHAR(255)` is +1020 bytes, over the 767-byte cap on old MySQL - see the setup-problems table +in [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md)). + +**Enabling a plugin does not serve it - restart the API.** Blueprints are +registered at app startup and Flask refuses to register one after the app has +served a request. `flask plugin enable ` flips a registry flag; until the +service restarts, `/api/` is still a 404. + +**Never hardcode an AssetType id.** Seeded ids are not stable across sites. +Resolve the type at runtime from `GET /api/assets/types` by its `assettype` +key. The machines form hardcoded one and has since moved to the lookup. + +**`default_enabled: false` means the shared test app never registers your +blueprint.** The plugin's test module builds its own app, registers the +blueprint, runs `on_install` seeding, and restores the process-wide +`plugin_manager` singleton afterwards - the same snapshot/restore pattern +`tests/test_plugin_migrations.py` uses. The parametrized contract and migration +tests still pick the plugin up from the filesystem on their own. + +--- + +## The three core files a bundled plugin still edits + +Everything the sidebar, the Reports hub, the settings rail, asset panels and +the map show is data-driven off hooks, so a plugin contributes UI without core +knowing it exists. Three edits remain, and a review expects exactly these: + +1. `PLUGIN_TABLE_OWNERS` - the migration registry above. +2. `frontend/src/api/index.js` - ADD your `Api` block, mirroring + `machinesApi`. Do not reorganize the file. (The scaffold generates the block + for you and ships the views with an inline client so they run before you + paste it.) +3. `frontend/src/views/AppLayout.vue` - one `iconMap` entry mapping the icon + string your hooks return to a Lucide component. ADR-009 wants this replaced + by a registration API eventually. + +Your route module carries `meta.plugin` on every route so the router gates it +when the plugin is absent; the staging rules are in +[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md#frontend). Follow the app's auth pattern: +list and detail public, the form `requiresAuth`, the settings subtype page +`requiresAuth + requiresAdmin`. + +Three core features compose onto your pages for free, and there is nothing to +wire: `CustomFieldsSection` / `CustomFieldsInputs` (call +`customFieldsRef.value.save(assetId)` after your own save), `WarrantyPanel` +(asset-general, works on any type), and the floor map, which draws any typed +asset that has a position (ADR-001, ADR-017). + +--- + +## End checklist + +Before you call a plugin done: + +- [ ] `manifest.json`: name, version, `core_version` pinned to the lowest + contract you need and capped below the next major, `api_prefix`, + `default_enabled`. +- [ ] Models follow the naming convention; identity stays on `Asset`. +- [ ] Derived state (if any) is computed at read time, never stored. +- [ ] Tables in `PLUGIN_TABLE_OWNERS`; expected head in + `tests/test_plugin_migrations.py`. +- [ ] Migration chain correct for your side of the cutover; hand-written + `op.create_table` if you have FKs to core tables. +- [ ] Imports only from `shopdb.api` and `shopdb.plugins.base` (the contract + test scans for this). +- [ ] Blueprint: jwt-optional reads, permission-gated writes, framework + response and pagination helpers, audit logs on writes. +- [ ] Permissions declared from `get_permissions`; install/enable seeds them. +- [ ] `on_install` seeds the asset type and reference data, idempotently; + settings declared from `get_settings_defaults`. +- [ ] Hooks: navigation, reports and models implemented; config schema and + collector implemented or consciously skipped with a reason. +- [ ] Frontend: route module with `meta.plugin`, API client addition, views + mirroring the master templates, settings subtype page, one `iconMap` + entry. +- [ ] Custom fields and warranty panel composed onto the detail and form pages. +- [ ] Tests: unit + API, self-contained app if `default_enabled` is false; the + three gates green ([DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md)). +- [ ] Install / enable / RESTART verified live, and a demo record renders on + every page. diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md new file mode 100644 index 00000000..18f1f1c5 --- /dev/null +++ b/docs/PLUGIN-HOOKS.md @@ -0,0 +1,866 @@ + + +# Plugin Hooks Reference + +**This page is the contract, and it is the only page that defines it.** Every +hook a plugin may implement, every manifest field, and every name a plugin may +import is specified here. PLUGIN-QUICKSTART and PLUGIN-GUIDE TEACH - they show +one plugin making one set of choices, and they link back here for the shape of +anything they use. If one of them disagrees with +this page, this page is right and the other page is a defect: fix it here and +delete the copy there. + +Plugin authors implement `BasePlugin` and override the hooks they care about. +Required hooks must be implemented; optional hooks have defaults and can be +left alone. Every section carries a stable anchor (`#hook-get_reports` and so +on) so another page links to exactly the hook it means. + +The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](../docs/adr/ADR-002-plugin-versioning.md). + +## Hook index + +Jump straight to the one you need; you should never have to read this page end +to end. + +| Hook | | Contributes | +|---|---|---| +| [`meta`](#hook-meta) | required | The plugin's identity, built from `manifest.json` | +| [`get_blueprint`](#hook-get_blueprint) | required | The Flask blueprint mounted at `api_prefix` | +| [`get_models`](#hook-get_models) | required | The SQLAlchemy models the plugin owns | +| [`init_app`](#hook-init_app) | optional | Anything else at startup: schemas, a second blueprint | +| [`get_cli_commands`](#hook-get_cli_commands) | optional | Click commands on the Flask CLI | +| [`get_services`](#hook-get_services) | optional | Named services another plugin can obtain | +| [`get_dashboard_widgets`](#hook-get_dashboard_widgets) | optional | Cards on the dashboard queue | +| [`get_navigation_items`](#hook-get_navigation_items) | optional | The sidebar entry | +| [`get_reports`](#hook-get_reports) | optional | Cards on the Reports hub | +| [`get_permissions`](#hook-get_permissions) | optional | The RBAC permissions the plugin's routes enforce | +| [`get_guest_grantable_permissions`](#hook-get_guest_grantable_permissions) | optional | Which of its WRITES a site may grant to `guest` | +| [`get_settings_cards`](#hook-get_settings_cards) | optional | Cards in the settings rail | +| [`get_asset_panels`](#hook-get_asset_panels) | optional | Panels on asset detail pages | +| [`get_map_overlays`](#hook-get_map_overlays) | optional | Badges/rings and a legend entry on the floor map | +| [`get_asset_presentation`](#hook-get_asset_presentation) | optional | Icon and detail route for an asset type it owns | +| [`get_provisioning_note`](#hook-get_provisioning_note) | optional | What the setup wizard warns a site it will create | +| [`get_config_schema`](#hook-get_config_schema) | optional | Config fields the setup wizard prompts for | +| [`get_settings_defaults`](#hook-get_settings_defaults) | optional | The `Setting` rows the plugin owns | +| [`get_collector_schema`](#hook-get_collector_schema) | optional | JSON Schema for an external collector push | +| [`apply_collector_payload`](#hook-apply_collector_payload) | conditional | The upsert behind that collector | +| [`on_install` / `on_uninstall` / `on_enable` / `on_disable`](#lifecycle-hooks) | optional | State-transition work | +| [`get_setting` / `set_setting`](#helpers) | provided | Plugin-scoped settings (you call these, not override) | + +The rest of the contract: [manifest fields](#manifest) - +[frontend contribution](#frontend) - [the `shopdb.api` import +surface](#import-surface) - [helpers](#helpers) - [removed +hooks](#removed-hooks) - [changing the contract](#versioning) - [the bulk-import +journal](#import-journal). + +**One rule holds for every list-returning hook below.** The endpoint that +consumes it skips disabled plugins, and isolates a plugin that raises: logged +and dropped in production, re-raised in dev and test so you see it. + + + +## Plugin metadata + +Each plugin ships a `manifest.json`, the single source of truth for its +identity; the dataclass `PluginMeta` is constructed from it. + +```json +{ + "name": "computers", + "version": "1.0.0", + "description": "Tracks shop-floor PCs and engineering workstations", + "author": "shopdb-flask", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/computers" +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `name` | Yes | Lowercase concatenated, no underscores or dashes | +| `version` | Yes | Plugin's own semver | +| `description` | Yes | One sentence | +| `dependencies` | No | List of plugin names that must load first | +| `core_version` | Yes | Range of framework `__contract_version__` this plugin supports | +| `api_prefix` | No | Defaults to `/api/` | +| `display_name` | No | Human label for UIs; defaults to the title-cased `name` | +| `default_enabled` | No | `false` means installing does not turn the plugin on | + +`core_version` is checked at load time and a plugin outside the range is +refused. The framework declares its own version in `shopdb/__init__.py` as +`__contract_version__`; the current value is in +[PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code. This page +does not restate it, because a version copied into prose is stale within a +fortnight and a plugin pinned against a stale one is refused at startup. How +tight to pin, and what happens on a mismatch, are in +[PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md). + + +## Required hooks + + +### `meta` -> `PluginMeta` + +Returns the plugin's metadata. Read it from `manifest.json` rather than typing +the values twice - the scaffold (`flask plugin new`) writes this for you: + +```python +from shopdb.plugins.base import BasePlugin, PluginMeta + +class ComputersPlugin(BasePlugin): + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name=self._manifest['name'], + version=self._manifest['version'], + description=self._manifest['description'], + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0'), + api_prefix=self._manifest.get('api_prefix'), + ) +``` + + +### `get_blueprint() -> Optional[Blueprint]` + +Returns a Flask Blueprint with the plugin's API routes, or `None` if the plugin has no HTTP routes. The loader registers the blueprint at the `api_prefix` from the manifest. + +```python +from flask import Blueprint +from .api import computers_bp + +class ComputersPlugin(BasePlugin): + def get_blueprint(self): + return computers_bp +``` + + +### `get_models() -> List[Type]` + +Returns the SQLAlchemy model classes the plugin defines. Used by the migration runner and admin tooling. + +```python +from .models import Computer, ComputerSoftware + +class ComputersPlugin(BasePlugin): + def get_models(self): + return [Computer, ComputerSoftware] +``` + + +## Optional hooks + + +### `init_app(app, db) -> None` + +Custom initialization. Called by the loader after the blueprint is registered and models are known. Use for Marshmallow schema registration, Caching configuration, secondary blueprint registration, or anything else the plugin needs. + +```python +class PrintersPlugin(BasePlugin): + def init_app(self, app, db): + from .api import printers_legacy_bp + app.register_blueprint(printers_legacy_bp, url_prefix='/api/printers/legacy') +``` + + +### `get_cli_commands() -> List` + +Returns a list of Click commands or command groups to register on the Flask CLI. + +```python +import click + +@click.group() +def computers_cli(): + pass + +@computers_cli.command() +def reset_computers(): + """Reset all computer status flags.""" + ... + +class ComputersPlugin(BasePlugin): + def get_cli_commands(self): + return [computers_cli] +``` + + +### `get_services() -> Dict[str, Type]` + +Returns a dict of service-name to service-class. Another plugin obtains one via +`plugin_manager.get_service('')`, which searches enabled plugins and +returns the registered class/factory (or None). + +```python +from .services import ZabbixService + +class PrintersPlugin(BasePlugin): + def get_services(self): + return {'zabbix': ZabbixService} +``` + + +### `get_dashboard_widgets() -> List[Dict]` + +Returns dashboard card definitions for the home page. + +A card declares DATA AND SHAPE, never a component name. Core owns a small set of +generic renderers and draws the card; the plugin says what to show, where it +comes from, and how to link it. + +**A card using the pre-0.19.0 shape (`'component': 'SomeWidget.vue'`) is +ignored and renders nothing** - if a card of yours is silently absent, that is +why. The reasoning is in the 0.19.0 row of +[CONTRACT-STABILITY.md](CONTRACT-STABILITY.md). + +```python +class WarrantyPlugin(BasePlugin): + def get_dashboard_widgets(self): + return [{ + 'id': 'warranty-expiring', # stable, unique across plugins + 'title': 'Warranties expiring', + 'endpoint': '/api/warranty/dashboard/expiring', + 'render': 'exceptions', # a core renderer, not a component + 'severity': 'info', # orders cards on the page + 'permission': 'warranty.view', # hidden without it + 'empty': 'hide', # say nothing when there is nothing + 'position': 50, + 'viewall': '/reports/warranty', # optional link behind the heading + 'map': {'title': 'hostname', 'detail': 'state'}, + }] +``` + +`empty: 'hide'` is not cosmetic. A card that reports "nothing wrong" every day +teaches people to stop reading the page. + +Consumed by `GET /api/dashboard/widgets`, which merges every enabled plugin's +widgets sorted by `position`. + + +### `get_navigation_items() -> List[Dict]` + +Returns navigation menu items. A plugin owns its own sidebar entry here, so it +appears when the plugin is installed and disappears when it is not (including in +a lean per-site build that omits the plugin). + +```python +class ComputersPlugin(BasePlugin): + def get_navigation_items(self): + return [{ + 'name': 'Computers', + 'icon': 'desktop', + 'route': '/computers', + 'position': 10, + }] +``` + +**Placement.** `position` (int) sets both the sort order and which section the +item lands in - the core sidebar (`AppLayout.vue:buildNavItems`) assigns section +headers by position range: + +| `position` | section | +|-----------|---------| +| `< 10` | top, above any header (Dashboard is 0, Map is 4) | +| `10-29` | **Assets** | +| `30-49` | **Information** | +| `>= 50` | trailing, below Information | + +Lower number sorts higher within a section. An explicit `'section': +'information'` forces the Information group regardless of position. Only these +two named sections exist; a new section needs a core edit to `buildNavItems`. +The **Displays** group (kiosk/TV links) is hardcoded in `AppLayout.vue`, not +plugin-driven. + +`icon` is a string key mapped to a Lucide component core-side (same idea as +`get_settings_cards`); an unknown key renders with no icon. + + +### `get_reports() -> List[Dict]` + +Returns report card definitions for the Reports hub. Added in contract 0.6.0. + +Each entry has `id`, `name`, `description`, `category`, plus EXACTLY ONE of +`route` (a frontend path for a dedicated report page) or `endpoint` (an API +endpoint the hub renders inline). + +```python +class WarrantyPlugin(BasePlugin): + def get_reports(self): + return [{ + 'id': 'warranty', + 'name': 'Warranty Report', + 'description': 'Assets bucketed by coverage: expired, expiring soon, active', + 'category': 'warranty', + 'route': '/reports/warranty', + }] +``` + +Consumed by `GET /api/reports`, which merges plugin cards after the static core +reports; the frontend groups them by category. + + +### `get_permissions() -> List` + +Returns the RBAC permissions this plugin owns. Added in contract 0.10.0. A +plugin declares the permission names its own routes enforce via +`require_permission`, instead of core accumulating every plugin's permissions in +one catalog (plugin-is-the-product). + +Each entry is a `(name, description, category)` tuple, matching the core +permission catalog shape (dicts with those keys are also accepted). Names follow +the naming convention (lowercase dotted, e.g. `machines.edit`). + +```python +class MachinesPlugin(BasePlugin): + def get_permissions(self): + return [ + ('machines.view', 'View machines', 'machines'), + ('machines.create', 'Create machines', 'machines'), + ('machines.edit', 'Edit machines', 'machines'), + ('machines.delete', 'Delete machines', 'machines'), + ] +``` + +Consumed by the core helper `full_permission_catalog()` (core permissions plus +every ENABLED plugin's `get_permissions()`), which backs three consumers: + +- `flask seed permissions` seeds the full catalog. +- The role-management grid (`GET /api/users/permissions`) lists it, grouped by + category. +- API-token scope validation (`ApiToken.unknown_scope_names`) accepts a plugin + permission as a scope only while that plugin is enabled. + +Plugin install and enable also seed the plugin's own permissions idempotently, +so enabling a fresh plugin creates its `Permission` rows without a separate seed +pass. + +Disabled-plugin edge case: a disabled plugin is skipped by the catalog, so its +permissions are no longer offered for new scope grants or new role assignments. +The `Permission` ROWS already in the database are NOT deleted, so roles that +already reference them keep working until an admin edits the role. A broken +plugin is isolated in prod and re-raised in dev/test. + + +### `get_guest_grantable_permissions() -> List[str]` + +Names the WRITE permissions this plugin allows a site to grant to the `guest` +role. Added in contract 0.21.0. + +Anyone with no account holds the `guest` role. It is seeded with nothing, and a +site opens a door by granting it a permission in the role grid - which replaced +the per-feature booleans (`securesend_allow_guests`, one per kiosk) that used to +answer the same question one feature at a time. + +Any `.view` or `.read` permission is grantable without a plugin saying anything. +This hook is only for the exception: a write that is genuinely meant to work for +somebody who never signs in. Naming one here does not open it - it makes the +checkbox available, and a site still has to tick it. + +```python +class ToolsPlugin(BasePlugin): + def get_guest_grantable_permissions(self): + # Sending a password to a vendor: the recipient has no account here, + # and often neither does the sender. The unguessable token is the + # authorisation and the link is one-view. + return ['tools.secrets'] +``` + +The bar for naming one: could this action be attributed to nobody, forever, and +still be defensible in the audit log? A guest write records no creator - the +trail says when and from where, never who. Return `[]` (the default) and none of +this plugin's writes can ever be made anonymous; anything not named is refused +by the API when an admin tries to grant it, not merely warned about. + +Consumed by `guest_grantable_permissions()`, which backs the role API's guard on +the guest role and the greyed-out checkboxes in the role grid. A plugin may only +bless permissions that already exist in the catalog, so a typo widens nothing. + + +### `get_settings_cards() -> List[Dict]` + +Returns settings-catalog card definitions. Added in contract 0.7.0 (ADR-010). +Each card is merged into the settings rail and landing overview without the +plugin hand-editing the core `settingsNav.js` catalog. `icon` is a string key +mapped to a Lucide component core-side, exactly like `get_navigation_items`. + +```python +class PrintersPlugin(BasePlugin): + def get_settings_cards(self): + return [{ + 'group': 'Printers', # rail group title (created if new) + 'to': '/settings/printer-alerts', + 'icon': 'bell', # string key, mapped core-side + 'title': 'Low-Toner Alerts', + 'description': 'Who gets warning and critical toner emails', + 'position': 48, # order within the group + }] +``` + +Consumed by `GET /api/pluginui/settings-cards`, which merges enabled plugins' +cards into the core catalog. + + +### `get_asset_panels() -> List[Dict]` + +Returns asset-detail extension-panel definitions. Added in contract 0.7.0 +(ADR-010). A generic core `AssetPanel` component renders each panel on the +matching detail pages, fetching the panel's `endpoint`. This replaces +hand-composing a plugin panel component into each detail view. + +```python +class WarrantyPlugin(BasePlugin): + def get_asset_panels(self): + return [{ + 'id': 'warranty', + 'title': 'Warranty', + 'assettypes': ['*'], # detail pages it appears on; ['*'] = all + 'endpoint': '/api/warranty/asset/{assetid}', + 'render': 'table', # 'keyvalue' | 'table' | 'badge' + 'position': 30, + }] +``` + +Consumed by `GET /api/pluginui/asset-panels?assetid=`, which returns the +panels whose `assettypes` match that asset's type. A panel that needs bespoke UI +(a chart) is out of scope for this data-only hook. + + +### `get_map_overlays() -> List[Dict]` + +Returns shop-floor map overlay/decoration definitions. Added in contract 0.7.0 +(ADR-010). The map stays data-driven off asset types + positions; an overlay +adds decoration data (a badge or ring) plus an optional legend entry, with no +plugin-side map code. + +**No bundled plugin implements this hook on this line,** so the example below is +written out rather than pointed at. `GET /api/pluginui/map-overlays` returns an +empty list until a plugin declares one, which is the correct behaviour and not a +fault. Take the shape from here and from +[ADR-010](adr/ADR-010-frontend-plugin-hooks.md). + +```python +class CamerasPlugin(BasePlugin): + def get_map_overlays(self): + return [{ + 'id': 'camera-offline', + 'label': 'Camera offline', # legend label + 'endpoint': '/api/cameras/map-overlay', # -> [{assetid, color, label}] + 'style': 'badge', # 'badge' | 'ring' + 'legend': True, + }] +``` + +Consumed by `GET /api/pluginui/map-overlays`. + +An overlay keys on `assetid` and never on coordinates, so it needs to know +nothing about buildings or levels (ADR-017): it decorates whichever marker that +asset already has, on whatever level that marker is on. Do not return `mapx` / +`mapy` / `levelid` from an overlay endpoint - a position belongs to the asset, +and a second copy of it in an overlay is one that can disagree. + + +### `get_asset_presentation() -> List[Dict]` + +Returns asset-type presentation/routing definitions. Added in contract 0.7.0 +(ADR-010). Declares how a plugin-owned asset type renders in global-search rows +and cross-links (which icon, which detail route), so core never hardcodes a +plugin's route or icon. + +**No bundled plugin implements this hook on this line either,** so this example +is also written out. `GET /api/pluginui/asset-presentation` returns an empty +list until a plugin declares one; core then falls back to its own icon and +route for the asset type. + +```python +class CamerasPlugin(BasePlugin): + def get_asset_presentation(self): + return [{ + 'assettype': 'camera', # AssetType.assettype key the plugin owns + 'icon': 'video', + 'label': 'Camera', + 'route': '/cameras/{assetid}', + }] +``` + +Consumed by `GET /api/pluginui/asset-presentation`. + + +### `get_provisioning_note() -> Optional[Dict]` + +Transparency note the setup wizard shows the moment a site checks this plugin +during setup. Return `None` (the default) for plugins that need no special +setup. Plugins that create extra tables beyond their asset-extension table +return: + +No bundled plugin declares one on this line; the shape is: + +```python +class CamerasPlugin(BasePlugin): + def get_provisioning_note(self): + return { + 'tables': ['camerastreams'], + 'note': 'Creates a stream-credential table in the shopdb database.', + 'docs': 'plugins/cameras/README.md', + } +``` + + +### `get_config_schema() -> List[Dict]` + +Declares the config fields this plugin needs, so the setup wizard can prompt +for them. Return `[]` (the default) if the plugin needs no configuration. +Each field is a dict: + +| Key | Meaning | +|-----|---------| +| `key` | the Setting key (non-secret) it maps to | +| `label` | human label shown in the wizard | +| `type` | `'text'` / `'number'` / `'password'` | +| `secret` | `True` for credentials; NOT stored in the DB - the wizard emits an `.env` line for the operator instead | +| `envvar` | (secret only) the `.env` variable name to emit | +| `default` | optional placeholder | +| `help` | optional hint | + +```python +class PrintersPlugin(BasePlugin): + def get_config_schema(self): + return [ + {'key': 'zabbix_url', 'label': 'Zabbix URL', 'type': 'text', + 'help': 'Base URL of the Zabbix server for supply lookups'}, + {'key': 'zabbix_token', 'label': 'Zabbix API token', 'type': 'password', + 'secret': True, 'envvar': 'ZABBIX_TOKEN'}, + ] +``` + + +### `get_settings_defaults() -> List[Dict]` (0.16.0) + +Declares the `Setting` rows this plugin owns. Return `[]` (the default) if it +owns none. Each entry is a dict: + +| Key | Meaning | +|-----|---------| +| `key` | the Setting key | +| `value` | default value in string form | +| `valuetype` | `'string'` / `'boolean'` / `'integer'` / `'json'` | +| `category` | grouping the plugin's settings page filters on | +| `description` | what the setting does | +| `public` | `True` if an unauthenticated caller may read it; default `False` | + +```python +class BackupsPlugin(BasePlugin): + def get_settings_defaults(self): + return [ + {'key': 'backups_intervalhours', 'value': '24', 'valuetype': 'integer', + 'category': 'backups', 'public': True, + 'description': 'Minimum hours between collection attempts on a PC'}, + ] +``` + +`public: True` there is load-bearing, not tidiness: the collecting script reads +that value from `/api/settings/public` BEFORE it has any credential. Left +private, the setting looked configurable and was not. + +The framework seeds declared keys at install, at enable, and on every +`flask plugin upgrade-all`, so a key added in a later plugin version reaches a +site that installed an earlier one. Existing values are never overwritten. + +Declaring a key is also what tells the settings API which category and type to +use when an admin's save creates the row for the first time. Do not seed +settings by hand in `on_install` / `on_enable`: those hooks fire only on a state +transition, so a hand-seeded key added later never reaches an existing site, and +the row the first save creates lands in the placeholder `plugin` category where +the plugin's own settings page (which filters by category) cannot see it. + +`public: True` puts the key on the unauthenticated read allowlist of +`GET /api/settings/` and `GET /api/settings`. Use it only for cosmetic +values that a page rendering before login needs (a kiosk, a print page). Never +mark a credential, a hostname, or an integration URL public. + + +### `get_collector_schema() -> Optional[Dict]` + +Declares the JSON Schema for an external collector pushing to `/api/collector/`. See [ADR-006](../docs/adr/ADR-006-collector-contract.md) for the contract. + +```python +class ComputersPlugin(BasePlugin): + def get_collector_schema(self): + return { + 'identityfield': 'hostname', + 'fields': { + 'hostname': {'type': 'string', 'required': True}, + 'macaddress': {'type': 'string'}, + 'osname': {'type': 'string'}, + 'osversion': {'type': 'string'}, + 'currentuser': {'type': 'string'}, + 'ipaddress': {'type': 'string'}, + } + } +``` + +If the hook returns `None` (the default), no collector endpoint is registered. + + +### `apply_collector_payload(payload: Dict) -> Dict` + +Companion to `get_collector_schema` (ADR-006). The generic +`/api/collector/` endpoint calls this after the payload passes +identity validation, to idempotently upsert an asset. Return a dict with at +least `action` (`created` | `updated` | `noop`), `assetid`, and `warnings` +(list). + +This is a CONDITIONAL hook: it is only required when `get_collector_schema` +returns non-None. The BasePlugin default raises `NotImplementedError` (the +dispatcher turns that into a 500), so a plugin that declares a schema but +forgets the upsert fails loud. Plugins with no collector schema never need it. +The `test_schema_declaring_plugins_implement_apply` contract test enforces the +pairing. + +```python +def apply_collector_payload(self, payload): + host = payload['hostname'] + comp = Computer.query.filter(Computer.hostname.ilike(host)).first() + action = 'updated' if comp else 'created' + # ... create-or-update Asset + extension ... + db.session.commit() + return {'action': action, 'assetid': comp.assetid, 'warnings': []} +``` + + +## Frontend contribution + +A plugin's UI is part of the plugin, not part of core. This section is the +contract for it; ADR-013 has the reasoning. + +**Where the files live.** `plugins//frontend/`, holding `routes.js` and +`views/`. Nothing is written into `frontend/src/`. `scripts/stage-frontend.mjs` +(run by npm predev/prebuild) copies each chosen plugin's `frontend/` into +`frontend/src/.plugins-staged//` and codegens +`frontend/src/router/routes.gen.js`, which the router imports. Both outputs are +generated and gitignored. + +**What a lean build stages.** `SITE_PLUGINS=machines,printers npm run build` +stages only those plugins; unset, it stages every plugin that has a +`frontend/routes.js`. The exception, and the rest of the lean-build model, is +in [PLUGINS.md](PLUGINS.md#lean-builds). + +**The route module.** `routes.js` default-exports an array of routes and may +also export `toplevel` for full-screen routes that render outside `AppLayout` +(a kiosk, a print page). `toplevel` is optional: the codegen imports each +staged module as a namespace, so a module without it is not a build error. +Every route carries `meta: { plugin: '' }`; the router guard redirects to +the dashboard when the backend plugin is disabled, so a route never loads a +shell over a plugin that is not there. + +**One import rule, and staging is the reason.** Anything from the CORE tree +uses the `@` alias (`@/api`, `@/components/PaginationBar.vue`, `@/stores/auth`); +anything inside your own plugin is relative (`./views/...`). A relative path +into the core tree such as `../../api` is correct where the file sits in your +plugin and wrong once staged into `.plugins-staged//`. The failure is a +build error, not anything subtle. + +**Menus follow the build, not a flag.** The sidebar nav, the settings rail and +the Displays links gate on whether your route was actually staged, so an +omitted plugin's nav item, settings cards and kiosk links vanish together - +your hooks cannot leave a menu entry pointing at a page that is not there. + + +## Lifecycle hooks + +These run when the plugin's installation state changes. All optional. + +| Hook | When | Use case | +|------|------|----------| +| `on_install(app)` | First time the plugin is installed via `flask plugin install` | Seed reference data, run plugin-specific migrations, register webhooks | +| `on_uninstall(app)` | When the plugin is removed via `flask plugin uninstall` | Clean up reference data, deregister webhooks | +| `on_enable(app)` | When the plugin is enabled at runtime | Subscribe to events, warm caches | +| `on_disable(app)` | When the plugin is disabled at runtime | Unsubscribe, drain queues | + + +## The import surface (`shopdb.api`) + +`shopdb.api` is the ONLY core module a plugin may import from (besides +`shopdb.plugins.base` for `BasePlugin` / `PluginMeta`). Importing internal +paths like `shopdb.core.models.*`, `shopdb.extensions`, or `shopdb.utils.*` +is a contract violation and fails the test +`tests/test_plugin_contract.py::test_plugins_only_import_contract_surface`. + +What `shopdb.api` exposes. The machine-readable truth is `__all__` in +`shopdb/api/__init__.py`; this list groups the same names by what they are for. + +**Infrastructure and model bases** - `db`, `cache`, `BaseModel`, `AuditMixin`. + +**Core models (the asset contract, ADR-001)** - `Asset`, `AssetType`, +`AssetStatus`, `AssetRelationship`, `RelationshipType`, `Vendor`, `Model`, +`Location`, `Communication`, `CommunicationType`, `Setting`, `AuditLog`, +`Application`, `AppVersion`, `OperatingSystem`, `Script` (the hosted script +library), `User`, `Role`, `SupportTeam` (carries a `webhookurl`, so an alerting +plugin can route to a team's webhook). + +**Displays** - `DashboardDefault`, `DISPLAY_ROLES`, `DISPLAY_ROLE_PATHS`, +`normalize_display_role`. The role vocabulary is core's, so a plugin that +reports on displays reads it instead of keeping a copy that drifts. + +**Responses and pagination** - `success_response`, `error_response`, +`paginated_response`, `ErrorCodes`, `get_pagination_params`, `paginate_query`. + +**Authorization** - `require_permission`, `require_any_permission` (a read two +different rights should reach), `require_role`, and `caller_holds(permission)`, +which ANSWERS whether the caller holds a permission without refusing the +request - for a route that serves everyone but should only offer what the +caller can act on. + +**Service and collector tokens** - `service_token_authorized(scope)` returns +True when the request carries a managed service token scoped for `scope` whose +owner holds that permission; `authorized_service_token(scope)` returns the +`ApiToken` itself so a plugin can honor the token's optional resource binding +(`token.resourcescopelist`, NULL = unrestricted); `mint_service_token`, +`list_service_tokens` and `revoke_service_tokens` provision, list and revoke a +single-scope token from a plugin's own UI; `collector_authorized(pluginname)` +gates an agent-facing route that lives outside the generic collector blueprint +on the same per-plugin key and kill switch the ingest route uses. + +**Serving uploads** - `send_upload`, `harden_upload_response`. Use them for any +user-supplied bytes: they set the headers that keep an SVG or an HTML file from +running as script in this origin. + +**Caller IP** - `client_ip`, `strip_port`. Do not hand-roll this. The obvious +version reads the first `X-Forwarded-For` hop, which the caller supplies, so a +rate limit keyed on it is defeated by rotating the header. + +**Request-argument and value parsing** - `intarg`, `intargs`, `QueryArgError`; +`parse_date`, `parse_date_fields`, `DateValueError`; `badge_kind`, +`normalise_badge`, `BadgeShapeError` (one badge vocabulary for the site, since +both kiosks read the same physical badges). + +**Helpers** - `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`, +`dualpath_single_machine_enabled`. + +**Mail and webhooks** - `send_email(to, subject, html, text=None)`, +`send_alert(subject, html, text=None)` (settings-first, no-op safe when email +is unconfigured; targets the site's alert_recipients) and +`send_webhook(title, text)` (Teams Incoming Webhook / Workflow, or generic JSON +via the `alert_webhook_format` setting; best-effort, no-op when unset). +`send_alert` fans out to the webhook automatically alongside email. + +**Bulk import** - `apply_import_timestamps`, `import_mode_active`, +`parse_import_datetime`; `register_import_hook` and `RowWarning` (a plugin owns +a VIRTUAL COLUMN on an importable CSV table plus a post-write row hook; core +never learns what the column means); and the journal models under +[Reversible bulk-import journal](#import-journal). + +**External directories** - `employee_connection`, a read-only handle on a +site's existing HR database. Core global search and audit-log name resolution +use it; a plugin may too. It returns nothing when `EMPLOYEE_DB_*` is unset, so +guard on that rather than assuming a site has one. + +```python +from shopdb.api import db, Asset, AssetType, success_response, paginate_query +``` + +Adding a name to `shopdb.api` is an additive (minor) contract bump; removing +one is breaking (major). See ADR-002. + + +## Helpers exposed to plugins + +Everything importable is listed above; this section is only the semantics you +cannot guess from the name. + +**`audit_log(action, entitytype, entityid=None, entityname=None, changes=None, +details=None)`** - write an audit row on every mutation. `changes` takes +`{'before': ..., 'after': ...}`. The current user, IP and timestamp are +captured for you. + +**`get_setting(key, default=None)` / `set_setting(key, value, valuetype='string', +description=None)`** - methods ON your plugin class, reading and writing the +core `Setting` store under the namespaced key `plugin..`, so +two plugins cannot collide. Use them to READ; declare the rows themselves from +[`get_settings_defaults`](#hook-get_settings_defaults) rather than writing them +in `on_install`. + +**`resolve_asset_position(asset)`** - returns +`{'mapx', 'mapy', 'levelid', 'positionsource'}`, where `positionsource` is +`'self' | 'related' | 'location' | None`. The resolution order is in +[ADR-001](../docs/adr/ADR-001-asset-as-platform-contract.md); the `levelid` +belongs to whichever source supplied the coordinates, and a plugin that writes +a position MUST write its level with it (ADR-017) - a marker drawn on the wrong +floor plan looks correct. + +**`resolve_dualpath_pairs()` / `dualpath_single_machine_enabled()`** - a +Dualpath pair is one physical dual-bay machine recorded as two asset rows. When +the site setting `dualpath_single_machine` is on (the default), lists, +dashboard/report counts and the map show the pair as one machine by hiding the +SECONDARY bay; both rows always exist and detail pages stay per-bay. PRIMARY is +the pair member with the lower natural-sort assetnumber. +`resolve_dualpath_pairs()` returns `secondaryassetids` (the ids to hide) and +`partnerbyasset` (`assetid -> partner`, for both members, for banners) and +IGNORES the toggle, so a detail-page sibling banner can always show; gate the +collapse itself on `dualpath_single_machine_enabled()`. + +**`apply_import_timestamps(row, data)` / `import_mode_active()` / +`parse_import_datetime(value)`** - bulk imports from the classic ASP shopdb +preserve each row's original `createddate` / `modifieddate` instead of stamping +"now". `apply_import_timestamps` is a no-op unless the caller is an admin AND +sent `X-Import-Mode: true`, so it never affects normal traffic; call it after +`db.session.add(row)` and before the commit. `import_mode_active()` is the same +predicate, for guarding other backdated behaviour (accepting a historical +`checkouttime`, say). `parse_import_datetime` reads both ISO +`2020-01-05T12:00:00` and legacy `YYYY-MM-DD HH:MM:SS` into naive UTC. The +operator manual is [IMPORT-API.md](IMPORT-API.md). + + +## Removed hooks + +The following hooks existed in early drafts and have been removed for v1: + +| Hook | Reason | +|------|--------| +| `get_event_handlers` | Event bus deferred indefinitely. No real use case yet. Add via new ADR if needed. | +| `get_searchable_fields` | Removed in 0.4.0. Global search (`/api/search`) is a core concern over the asset model, already covering every bundled asset type, and honours runtime enable/disable. No plugin ever implemented it. | + + +## Versioning your changes + +When you change anything documented here, you must: + +1. Bump `__contract_version__` per [ADR-002](../docs/adr/ADR-002-plugin-versioning.md): major for removals or signature changes, minor for additive optional hooks, patch for docs. +2. Update [ADR-001](../docs/adr/ADR-001-asset-as-platform-contract.md) if the contract surface itself changed (or supersede with a new ADR). +3. Add or update the test in `tests/test_plugin_contract.py` that asserts the new behavior. +4. Update this page in the same change. `tests/test_docs_contract.py` fails the + build when a public `BasePlugin` hook is not mentioned here. + + +## Reversible bulk-import journal + +Contract version 0.30.0. + +A plugin that drives a bulk import - a site asset adoption, an integration sync - +records the primitive changes it makes so the whole batch can be reversed later. +The journal is import-agnostic: it stores primitive mutations (set a cell, insert +a row) tied to a batch id, and a generic undo replays them in reverse. Nothing in +core learns what the import means. + +Exposed on `shopdb.api`: `ImportBatch` (one row per applied import; `batchid` is +the identity the approval step, the write and the undo all reference; `status` +is `BATCH_APPLIED` or `BATCH_UNDONE`) and `ImportJournalEntry` (one reversible +mutation; `operation` is `OP_UPDATE`, carrying `oldvalue`/`newvalue`, or +`OP_INSERT`, a row this batch created). + +**A plugin READS these and does not write them.** Its import UI lists applied +batches and offers "Undo import" per batch. The write and undo logic lives with +the import writer (`scripts/site_imports/journal.py`), which journals in the +SAME transaction as the mutation it describes and reverses conservatively: an +update is put back only if the cell still holds the value the import wrote (a +value a human has since changed is left alone and reported), and an insert is +hard-deleted only when nothing outside the batch references the created row. + +The tables are core-owned (migration `7d37_import_batches_and_journal`), so they +exist regardless of which plugins are installed. diff --git a/docs/PLUGIN-QUICKSTART.md b/docs/PLUGIN-QUICKSTART.md new file mode 100644 index 00000000..4dfc8c2a --- /dev/null +++ b/docs/PLUGIN-QUICKSTART.md @@ -0,0 +1,193 @@ + + +# Plugin Quickstart + +Build a working shopdb-flask plugin in about 30 minutes: generate it, put your +fields in it, install it, see it in the UI. Every shape used below is defined +in [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), the contract and the only page that +defines it; read shapes there, not here. + +> **Windows:** use `venv\Scripts\python` and `$env:FLASK_APP="shopdb"` (not +> `export`). Onboarding: [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md). + +## Step 1: Generate the skeleton + +```bash +flask plugin new cameras --description "Tracks shop-floor surveillance cameras" +``` + +Output: `plugins/cameras/` with manifest, plugin class, example model, example +routes, schemas stub, tests, a README, a paste-in `frontend-api-snippet.js`, +and the frontend starting points - `plugins/cameras/frontend/routes.js` and +`frontend/views/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue`. + +Nothing is written into `frontend/src/`; the plugin owns its frontend, which is +what makes it prunable. The staging rules are in +[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md#frontend). A plugin developed in its own repo +gets exactly this layout and starts working the moment the directory is dropped +into a host repo's `plugins/`. + +The generated plugin already passes the framework's contract tests. Verify +before editing: + +```bash +pytest plugins/cameras/tests/ +``` + +## Step 2: Edit the model + +Open `plugins/cameras/models/cameras.py`. Replace the `examplefield` +placeholder with your domain fields: + +```python +class Cameras(BaseModel): + __tablename__ = 'cameras' + + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + primary_key=True, + ) + streamurl = db.Column(db.String(255), nullable=False) + resolution = db.Column(db.String(20)) + fps = db.Column(db.Integer) + poeport = db.Column(db.String(50)) + asset = db.relationship('Asset', backref=db.backref('cameras', uselist=False)) + + def to_dict(self): + return { + 'assetid': self.assetid, + 'streamurl': self.streamurl, + 'resolution': self.resolution, + 'fps': self.fps, + 'poeport': self.poeport, + } +``` + +Note the naming convention: lowercase concatenated, no underscores +(`streamurl`, not `stream_url`). See [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Step 3: Add routes + +Open `plugins/cameras/api/routes.py`. The scaffold provides list and detail +endpoints. Add CRUD as needed: + +```python +@cameras_bp.route('', methods=['POST']) +@jwt_required() +def create_camera(): + data = request.get_json() + + asset = Asset(assetnumber=data['assetnumber'], name=data['name'], ...) + db.session.add(asset) + db.session.flush() + + camera = Cameras( + assetid=asset.assetid, + streamurl=data['streamurl'], + resolution=data.get('resolution'), + ) + db.session.add(camera) + db.session.commit() + + return success_response(camera.to_dict(), http_code=201) +``` + +Everything you may import comes from `shopdb.api` - the list is in +[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md#import-surface). For audit logging: + +```python +from shopdb.api import audit_log + +audit_log(action='created', entitytype='Camera', entityid=asset.assetid, entityname=asset.name) +``` + +## Step 4: Install the plugin + +First add `plugins/cameras/migrations/` with a per-plugin Alembic chain that +creates the plugin's tables, and register those tables in +`PLUGIN_TABLE_OWNERS`. Your plugin is post-cutover, so its `0001` really +creates the tables; see [PLUGINS.md](PLUGINS.md#migrations) for the +anchor-vs-baseline rule. Then: + +```bash +flask plugin install cameras +flask plugin upgrade-all +``` + +`install` runs the plugin's `on_install` hook (which seeds the AssetType row), +registers it in the plugin registry, and runs the plugin's own migration chain. +`flask db migrate` / `flask db upgrade` is reserved for core tables and must not +be used for plugin schema. + +## Step 5: Verify it works + +```bash +flask plugin list # cameras [Enabled] +pytest plugins/cameras/tests/ +curl http://localhost:5001/api/cameras +``` + +## Step 6: Add hooks (optional) + +Override hooks on the plugin class as needed. Each has a default that does +nothing, so override only what your plugin needs. The catalog, with the exact +shape each one returns, is +[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md#optional-hooks) - do not copy shapes from +this page or any other, because there is only one that is kept correct. + +Four that most new plugins start with: `get_navigation_items` (sidebar entry), +`get_permissions` (its own RBAC permissions), `get_reports` (a card on the +Reports hub) and `get_settings_cards` (a card in the settings rail). + +## Step 7: Finish the generated frontend + +The scaffolded views build and run out of the box against the example model, so +`npm run build` is green immediately after scaffolding. What you finish by hand, +in order. Copy patterns from the closest bundled plugin - `network` is the +cleanest. + +1. **Views** - replace the `examplefield` columns and inputs in + `plugins/cameras/frontend/views/*.vue` with your domain fields. Keep the + global `.filters` / `.form-control` / `.card` styles; do not invent per-page + input styling. Mind the import rule in + [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md#frontend): `@` for core, relative for your + own plugin. +2. **Routes** - `plugins/cameras/frontend/routes.js` is already generated with + `meta: { plugin: 'cameras' }` on every route and `requiresAuth` on the form + routes. No registration edit is needed. +3. **API client** - the scaffolded views ship with an inline `camerasApi` so + they run standalone. To graduate to the shared module, paste the generated + `plugins/cameras/frontend-api-snippet.js` block into + `frontend/src/api/index.js`, delete the inline const in each view, and + `import { camerasApi } from '@/api'` instead. +4. **Sidebar, reports, settings** - implement the hooks from step 6. No + frontend edit: those surfaces build themselves from what the hooks return. +5. **Settings subtype page** (if the plugin has subtypes) - add a route whose + path starts with `settings/` (e.g. `settings/cameratypes`); the router + nests it under the settings shell automatically. Copy a types-list view from + `frontend/src/views/settings/`. +6. **Verify** - `npm run build` must pass, then look at the pages against the + dev servers. + +## Common errors + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `PluginNotFoundError: manifest.json` | Manifest deleted or moved | Restore `plugins//manifest.json` | +| `PluginContractError: missing required field` | manifest.json incomplete | Re-add `name`, `version`, `description` | +| `PluginVersionError: requires core_version X but framework is Y` | Framework upgraded past your range | Update `core_version` in manifest | +| `Table 'cameras' is already defined` | Two models declared the same `__tablename__` | Pick a unique table name | +| Index name collision | Two indexes share the same name (SQLite enforces global uniqueness) | Prefix index names with table: `idx_cameras_streamurl` | + +## Next + +- [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) - the conventions and traps of a real plugin, and the end checklist. +- [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md) - shipping a plugin from its own repo, per [ADR-003](adr/ADR-003-plugin-distribution.md). +- [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md) - what you can safely build on before contract 1.0. diff --git a/docs/PLUGIN-SIGNING.md b/docs/PLUGIN-SIGNING.md new file mode 100644 index 00000000..f469784d --- /dev/null +++ b/docs/PLUGIN-SIGNING.md @@ -0,0 +1,149 @@ + + +# Plugin signing and packaging (curator guide) + +ADR-013 Phase 1. How a plugin becomes a signed, verifiable artifact and how a +site trusts it. The signature proves an artifact is EXACTLY what a curator +reviewed and signed - it does not prove the code is safe. Human review before +signing is the actual safety control; the signature makes that review's verdict +tamper-evident all the way to the point of execution. + +Requires the `cryptography` package (already a dependency). + +## One-time: create the publisher key pair + +``` +flask plugin keygen --out ./keys --name curator +``` + +Writes `keys/curator.key` (PRIVATE) and `keys/curator.pub` (public). + +- Keep the `.key` OFFLINE with the curator. It is the only thing that can sign a + trusted artifact. Never put it on the plugin shelf or in the repo. +- Distribute the `.pub` with each site's deployed config and pin it (below). +- Rotation: generate a new pair, pin BOTH public keys on sites for an overlap + window (`verify` accepts any trusted key), then retire the old one. + +## Per plugin: review, then pack + +1. Review the plugin's source. This is the security gate - read what it does. +2. Validate and package in one step: + + ``` + flask plugin pack printers --key ./keys/curator.key --publisher yoursite + ``` + + `pack` refuses to sign a directory that does not validate (manifest schema, + name/dir match, core_version, dependencies on disk). On success it writes + `printers-.shopdbplugin` - a zip of the plugin plus: + + - `PROVENANCE.json`: name, version, publisher, created, and a sorted + `{file: sha256}` map of every packaged file. + - `PROVENANCE.sig`: a detached ed25519 signature over the exact + `PROVENANCE.json` bytes. + +3. Publish the artifact to the shelf (a SharePoint-synced or copied folder). + Transport is untrusted; the signature is what makes it safe. + +## Verify an artifact + +``` +flask plugin validate dist/printers-1.0.0.shopdbplugin --pubkey ./keys/curator.pub +``` + +Checks, fail-closed: signature against the trusted key(s), every file's hash, +no unexpected files, manifest schema, and that the plugin's `core_version` +admits this framework's contract version. Any changed byte in any file fails +the hash check; a signature from an untrusted key fails the signature check. + +## Pin trusted keys on a site + +Set `PLUGIN_TRUSTED_KEYS` to one or more public-key PEM paths, separated by the +OS path separator (`:` on Linux, `;` on Windows), in the site's environment: + +``` +PLUGIN_TRUSTED_KEYS=/etc/shopdb/keys/curator.pub:/etc/shopdb/keys/curator-next.pub +``` + +Keys are read only from this deployed config, never from the shelf - a folder an +attacker could write must not also carry the keys that authenticate it. With no +keys set, `validate` on an artifact fails closed (unverifiable). + +## Enforce signatures (Phase 2) + +By default nothing is enforced - plugins load unsigned, as before. To require +signatures on a site: + +1. Stamp the plugins the image ships with, so verify-at-load applies to them + too (run at image build with the site/build key): + + ``` + flask plugin stamp-bundled --key ./keys/curator.key + ``` + + This writes `PROVENANCE.json` + `PROVENANCE.sig` into each in-tree plugin. + +2. Pin the public key(s) and turn enforcement on (site config): + + ``` + PLUGIN_TRUSTED_KEYS=/etc/shopdb/keys/curator.pub + PLUGIN_REQUIRE_SIGNED=true + ``` + +Now a plugin only loads or migrates when its tree matches a trusted signature. +Under enforcement a `sys.meta_path` guard verifies EVERY `plugins..*` +import (not just `plugin.py`) - including the `from plugins..models import +...` that core request handlers do - against the plugin's signed provenance, and +executes the exact bytes it hashed (never a `.pyc`). An unsigned, tampered, or +wrong-key plugin is refused, fail-closed. `PLUGIN_DEV_TRUST_DIRS` exempts named +directories, but ONLY under DEBUG/TESTING (the external-repo dev workflow); +production ignores it. + +Because every `plugins.*` import is verified, `stamp-bundled` must cover EVERY +plugin directory present (its no-argument form does), not only the enabled ones +- core code can import a disabled plugin's module, and an unstamped one would be +refused. + +Defense in depth - set filesystem permissions so the app's runtime user CANNOT +write the `plugins/` directory (owned by the deploy user). Import-time +verification closes the "attacker drops a file, a request imports it" path; a +strict read-only `plugins/` also closes the narrow verify-vs-migrate race where +an attacker with concurrent write to `plugins/` swaps a migration script between +the check and alembic re-reading it. + +## The shelf and adopt (Phase 2) + +A shelf is a read-only folder of artifacts plus a signed index. The app reads +`PLUGIN_SHELF_DIR`; it never talks to SharePoint - a sync (or robocopy/USB) +populates that folder, and the signature makes the transport untrusted and +interchangeable. + +Publish (curator, after packing artifacts into the shelf folder): + +``` +flask plugin shelf-build --dir /srv/shelf --key ./keys/curator.key --serial 3 +``` + +The index carries a monotonic `serial` (a site refuses an index older than the +last it saw) and a `revoked` list (carried forward across builds). Bump +`--serial` on every publish. + +On a site: + +``` +flask plugin shelf-list # browse (verifies index + serial) +flask plugin adopt printers # or printers==1.2.0 +flask plugin audit # warn if an installed version is revoked +``` + +`adopt` verifies the shelf index and the artifact (signature + every file +hash), unpacks into a staging area, re-verifies, then atomically moves it into +place and installs + enables it with its dependency closure. It refuses a +downgrade unless `--force-downgrade`. Run `flask plugin upgrade-all` and restart +afterward so migrations apply and routes register. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md new file mode 100644 index 00000000..2b629575 --- /dev/null +++ b/docs/PLUGINS.md @@ -0,0 +1,134 @@ + + +# Plugins + +shopdb-flask is a framework. The plugins listed here are the pieces other GE Aerospace facilities can install, build, or skip per ADR-003. Bundled plugins ship in the framework repo. External plugins live in their own repos and drop into `/plugins//` at install time. + +## Bundled (ship with the framework) + +These plugins are in `plugins/` in this repo. Enable per site with `flask plugin +install `. None of them ships `default_enabled: false`, so the shelf and +the catalog are the same list here: a site turns one on rather than fetching +it. [PROJECT-MAP](PROJECT-MAP.md) carries their versions and migration heads, +generated from the code. + +| Plugin | Tracks | Notes | +|--------|--------|-------| +| `machines` | Manufacturing machinery: 5-axis mills, lathes, broachers, heat treatment ovens, and metrology instruments | Manually entered. Metrology instruments are machines here, not a class of their own - see [ADR-018](adr/ADR-018-metrology-instruments-are-machines.md). Subtype tables for FOCAS / CLM / MTConnect controller protocols (planned). | +| `computers` | Shop-floor PCs and engineering workstations | Fed by the PXE pipeline collector per [ADR-006](adr/ADR-006-collector-contract.md). | +| `printers` | Network and shop-floor printers | Public installer map page + fleet install contract (`/api/printers/install-list`, `/pc-default`, `/install-batch`; see [PRINTER-INSTALLER.md](PRINTER-INSTALLER.md)). Optional Zabbix integration for supply tracking. Legacy `PrinterData` retiring per ADR-001. | +| `network` | Switches, routers, access points, IDFs as locations | Asset-only; cleanest of the bundled set. | +| `notifications` | Shop-floor notifications, recognitions, kiosk feed | Used by `ShopfloorDashboard.vue`. | +| `knowledgebase` | Knowledge Base articles linking to external resources | Lightweight article store. | +| `slides` | Slides for the lobby display and shop-floor screensaver | Upload / reorder / delete per surface. Management gated on the `slides.manage` permission, grantable to non-admin curators; display routes are public. | +| `tools` | Technician utilities: barcode and QR label generation, unit conversions, Secure Send | Mostly browser-side by design, so an air-gapped bench gets them with no network. Secure Send is the exception and is why this plugin has a blueprint, a table and a migration chain: see [SECURE-SEND.md](SECURE-SEND.md). | +| `warranty` | Asset warranty tracking | Manual entry now, Dell / Lenovo / HP provider lookups later. Derived coverage status with report buckets. | +| `guides` | Step-by-step procedures written in ShopDB: typed blocks, pasted screenshots, live checkboxes, tags, drafts and revision history | Content is stored as typed blocks rather than markup, so the same guide renders to screen, dark mode and paper. Readers see the PUBLISHED revision only - a technician following a procedure must not have it change under them mid-task. Checkbox ticks are per-viewer and never edit the guide. See [GUIDES.md](GUIDES.md). | +| `endpoints` | Live endpoint telemetry for shop-floor PCs: liveness, services, processes, inventory, and curated Windows event records, reported by the ShopDB agent | Depends on `computers` - an endpoint is telemetry ABOUT a PC asset, never a second record of it. Adds the Fleet console plus the live panels on a PC's detail page. Four separate permissions: `endpoints.view` to read, `endpoints.fleet` for the fleet-wide console, `endpoints.command` to act on one endpoint, and `endpoints.restart` for a reboot. They are split because read access is grantable to guests and the command channel is not. Ingest is not a route in this plugin: agent reports arrive on the generic collector route and authorize on a service token scoped `collector.ingest.endpoints`, not a user JWT, per [ADR-006](adr/ADR-006-collector-contract.md). That scope doubles as the kill switch for the agent key. See [FLEET-ARCHITECTURE.md](FLEET-ARCHITECTURE.md). | +| `backups` | Per-asset configuration backups with revision history: NTLARS/DNC registry and part marker | Kind-pluggable per [BACKUP-KINDS.md](BACKUP-KINDS.md); a kind is one class in `services/registry.py`. Parseable kinds store a JSON projection and re-render to the native format on download; opaque vendor formats stay on the SFLD share with ShopDB holding metadata and the UNC pointer. Fed by the collector, so ingest authorizes on a `collector.ingest` service token, not a user JWT. `backups.view` and `backups.download` are separate permissions because a `.reg` is a restorable artifact. Not to be confused with [BACKUP-RESTORE.md](BACKUP-RESTORE.md), which is the ShopDB database itself. | + +## Plugin permissions + +Plugins may register their own permissions (for example `slides.manage`). Admins +implicitly hold them; grant them to specific roles or users to delegate curation +without admin. Each plugin's registered permissions appear in its `plugin.py` +`get_permissions()`. + +## Building your own + +Guides: + +- [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) - **the contract**, and the only page that defines it: every hook, every manifest field, every importable name. +- [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - start here: generate, customize, install and test a plugin in about 30 minutes. +- [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) - the conventions and traps of a real plugin, and the checklist to walk before calling one done. +- [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md) - developing a plugin in its own repo per ADR-003: repo layout, symlink dev workflow, `core_version` pinning, and the CI harness. +- [PLUGIN-SIGNING.md](PLUGIN-SIGNING.md) - packaging a plugin as a signed artifact and trusting it at a site. +- [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md) - how much of the contract is settled, and how tightly to pin. + +The contract is locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](adr/ADR-002-plugin-versioning.md). + +Quick path: + +```bash +flask plugin new cameras --description "Tracks shop-floor surveillance cameras" +# edit plugins/cameras/models/cameras.py with your fields +flask plugin install cameras +``` + + +## Migrations (per-plugin chains) + +This is the one place the anchor-vs-baseline rule is written down; other pages +link here. + +Each plugin that owns tables carries its own Alembic chain under +`plugins//migrations/`, with a per-plugin version table +`alembic_version_` independent of the core `alembic_version`. Ownership is +split at a fixed cutover (see [ADR-008](adr/ADR-008-plugin-migration-ownership.md)): + +- The core chain (`flask db upgrade`) created every table that existed through + its head, including the bundled-plugin tables. Each bundled plugin's `0001` + migration is a stamp-only no-op anchor recording that fact. +- From the cutover forward, a change to a plugin's schema lands as + `plugins//migrations/versions/000N_*.py`, never in the core chain. The + core chain is reserved for core tables. +- A plugin built AFTER the cutover (`endpoints` and `guides` are the bundled + examples), and every plugin built from now on, is different: the core chain + never created its tables, so its `0001` is a REAL baseline that creates them, + not a no-op anchor. + +Deploys and upgrades run `flask db upgrade` then `flask plugin upgrade-all`. +`upgrade-all` stamps every plugin anchor and applies any later plugin +migrations; it is idempotent. The registry (`instance/plugins.json`) records +which revisions each plugin has applied in `migrations_applied`. + +## Distribution conventions + +For sister-site plugins (per [ADR-003](adr/ADR-003-plugin-distribution.md)): + +- Plugin lives in its own git repo: `//` +- Adopting site clones or symlinks into their `/plugins//` +- Plugin manifest declares `core_version` range matching the framework version they target +- Plugin readme explains: what it tracks, who maintains it, where to file issues + + +## Lean per-site builds + +A site ships only the plugins it chose; a site that never wants warranty / +guides / network never carries that code (see +[ADR-013](adr/ADR-013-plugin-catalog-and-lean-builds.md) and +[ADR-014](adr/ADR-014-schema-lean-per-site.md)). Three layers make a build lean: + +- **Backend code** - `scripts/build-site.sh ` stages `shopdb/core` plus + only the chosen plugins' directories (and their hard-dependency closure). A + plugin a site did not choose is absent from the backend tree. +- **Frontend code** - `SITE_PLUGINS=machines,printers npm run build` (via + `scripts/stage-frontend.mjs`) stages only those plugins' `frontend/` dirs and + codegens the route table. **Exception:** a `plugins//frontend/` dir with + **no `manifest.json`** is a CORE feature (e.g. `applications`), not a per-site + plugin, and is ALWAYS staged regardless of `SITE_PLUGINS` - otherwise a lean + build would lose a core page. +- **Database** - the shared core Alembic baseline creates every plugin's tables, + so a lean site provisions them and then drops the ones it does not use with + `flask plugin prune-schema` (ADR-014). Run it once at provisioning, after + `flask db upgrade` and `flask plugin upgrade-all`; see + [DEPLOY.md](DEPLOY.md). + +**Menus follow the build, not a plugin flag.** The sidebar nav, the settings +rail, and the Displays links all gate on whether the target route was actually +staged into this build (the router's own route table), not on a registry +"enabled" flag. So a lean site never shows a menu entry that dead-ends on a +blank page - an omitted plugin's nav item, settings cards, and kiosk links all +disappear together. Shopfloor Dashboard is a core view but is gated on the +notifications plugin (its only data source), so it drops when notifications is +not in the build. + +## Naming policy + +Plugin names follow the framework's naming convention (lowercase concatenated, no underscores or dashes; full words preferred over acronyms). See [CONTRIBUTING.md](../CONTRIBUTING.md). Plugin name collisions across sites are not enforced; the convention recommends prefixing site-specific plugins with the site code (e.g. `sitecode-shippingstation`) when there is risk of overlap. diff --git a/docs/PRINTER-INSTALLER.md b/docs/PRINTER-INSTALLER.md new file mode 100644 index 00000000..09a4b8b0 --- /dev/null +++ b/docs/PRINTER-INSTALLER.md @@ -0,0 +1,140 @@ + + +# Printer installer map and install endpoints + +How the shop-floor fleet installs network printers from shopdb-flask, replacing +the classic ASP `apiprinters.asp` / `apipcdefaultprinter.asp` / `installprinter.asp` +contract. Shopfloor 2.0 PCs cannot run unsigned `.bat` maps, so a signed +installer EXE (and the public web map page) drives installs from three endpoints +in the printers plugin. + +- Server code: `plugins/printers/api/asset_routes.py` + (`printer_install_list`, `pc_default_printer`, `printer_install_batch`) +- Consumed by whatever lays software on a bay - a fleet configuration tool, an + imaging task, or a scheduled task. ShopDB serves the data; it does not push + the installer. Display kiosks do not get it: they print nothing. + +All three endpoints are `@jwt_required(optional=True)`: an anonymous fleet +client works, and a logged-in browser (the public map page) works too. + +--- + +## 1. The public map page + +`PrinterInstallerMap` is a public (no-login) frontend page: the floor map with +printer hotspots positioned at each printer's `mapx` / `mapy`. The user clicks +the printers they want, and the page requests an install batch. The PC's default +printer is preselected via `pc-default`. + +--- + +## 2. `GET /api/printers/install-list` + +Flat, unpaginated list of active NETWORK printers. A printer counts as network +only if it has a hostname or a non-USB IP; USB-only printers are excluded. + +Fields per row: + +| Field | Notes | +|---|---| +| `printerid` | Printer id (the token `install-batch` takes). | +| `name` | Asset name, else asset number. | +| `machinenumber` | The asset number. | +| `windowsname` | Standardized Windows printer name. | +| `sharename` | Share / CSF name. | +| `hostname` | Print-queue host. | +| `ipaddress` | Primary IP (falls back to any communication row). | +| `vendorname` | Direct vendor, else the model's vendor. | +| `modelnumber` | Model name. | +| `installpath` | Installer path for this printer (see install-batch). | +| `iscsf` | CSF flag. | +| `locationname` | Location name, if the asset has one. | +| `mapx` / `mapy` | Floor-map hotspot position, in the native pixels of `levelid`. | +| `levelid` | Which level (drawing) those pixels belong to. Null when unplaced. | + +`?format=text` returns a pipe-delimited line per printer, one printer per line, +with a fixed field order so the Inno / Pascal installer does a `split()` instead +of parsing JSON: + +``` +printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy|levelid +``` + +Any pipe or newline inside a value is neutralized to a space so the field count +stays fixed. The web map uses the default JSON. + +**Fields 0-7 are frozen.** The shipped installer reads them positionally +(`GetField(Line, 6)` is mapx), so a new field goes on the END and nowhere else - +inserting one shifts every later field and the installer keeps running while +reading a model number as a coordinate. `levelid` is field 8 for that reason, and +installers built before levels existed ignore it. + +An installer that ignores `levelid` draws every printer on whichever single +blueprint it ships, which is correct only while a site has one level. See +ADR-017 and section 5. + +--- + +## 3. `GET /api/printers/pc-default?machine=NNNN` + +The PC's default printer, by machine (asset) number persisted at PXE enrollment. +Parity with classic `apipcdefaultprinter.asp`: the installer preselects a PC's +default-printer hotspot on the site-map wizard. The link is resolved through the +`defaultprinter` asset relationship (PC asset -> printer asset), so it stays +inside the contract surface (no cross-plugin model import). + +Returns `{printerid, windowsname}`, or `{}` when the machine is unknown or has no +active default printer set. + +`?format=text` returns one pipe-delimited line (`printerid|windowsname`), or an +EMPTY body when there is no default (so the installer's split yields nothing). + +--- + +## 4. `GET /api/printers/install-batch?printerids=1,2,3` + +Returns a self-deleting Windows `.bat` that installs the selected printers, +grouped the same way classic `installprinter.asp` grouped them: + +- HP / Xerox: one universal `PrinterInstaller.exe /PRINTER="a,b,c"` call. +- Printers with a `.exe` `installpath`: a PowerShell `Invoke-WebRequest` download + (using the caller's Windows credentials, against the site base URL + the + IIS-served `/installers` folder) followed by running it `/SILENT`. +- No `installpath`, or a non-`.exe` payload (e.g. a `.zip`): listed as a manual + install rather than run blindly. + +`printerids` is required, comma-separated; non-numeric tokens are ignored. An +empty / missing list is a validation error. + +The install name preference is `windowsname`, else `sharename`, else the asset +name / number. + +--- + +## 5. The shipped installer predates levels (known gap) + +The Pascal installer in the `inno` repo (`PrinterInstallerMap`) still assumes one +drawing per site, in two places that must change together: + +- `MAP_SOURCE_W = 3300` / `MAP_SOURCE_H = 2550` are compiled-in constants, and + every hotspot is scaled by them. They are the dimensions of ONE level. +- A downsized copy of that level's blueprint is bundled into the installer + (`880x680`), so the picture is fixed at build time. + +Two consequences, neither of which the installer can detect: + +1. It ignores field 8, so printers on any level are drawn on the bundled image. + Coordinates from a different drawing land somewhere plausible and wrong. +2. When a level's blueprint is replaced with one of different dimensions, the + constants and the bundled image are both stale and every hotspot shifts. + +The API side is ready: `GET /api/maplevels` is deliberately unauthenticated so +the installer can fetch level dimensions and blueprint URLs at runtime rather +than compiling them in, which is what fixes both. Until the installer is +rebuilt against it, treat its map as accurate for the default level only. diff --git a/docs/PROJECT-MAP.md b/docs/PROJECT-MAP.md new file mode 100644 index 00000000..3a88928b --- /dev/null +++ b/docs/PROJECT-MAP.md @@ -0,0 +1,94 @@ + + + +# Project map + +Derived from the code on every regeneration. If something here is wrong, the +code changed and the map was not regenerated - fix it by running the generator, +never by editing this file. + +## Versions + +| series | value | governed by | +|---|---|---| +| product `__version__` | `0.12.0` | ADR-007 | +| plugin contract `__contract_version__` | `0.35.0` | ADR-002 | + +They move independently. A contract bump is not a release. + +## Alembic chains (ADR-008) + +Core runs with `flask db upgrade`; every plugin chain runs +with `flask plugin upgrade-all`. Both are needed on a deploy. + +| chain | head | +|---|---| +| core | `7d51_endpoint_logcollections` | +| backups | `backups0003clearlastseen` | +| computers | `computers0006holdindefinite` | +| endpoints | `endpoints0024bootstraptokens` | +| guides | `guides0001baseline` | +| knowledgebase | `knowledgebase0001anchor` | +| machines | `machines0002rename` | +| network | `network0003prefix` | +| notifications | `notifications0006fleetalerts` | +| printers | `printers0005observedqueues` | +| slides | `slides0001anchor` | +| tools | `tools0001securesend` | +| warranty | `warranty0002proof` | + +## Bundled plugins (12) + +| plugin | version | core_version | owns migrations | +|---|---|---|---| +| backups | 1.0.0 | >=0.16.0,<1.0.0 | yes | +| computers | 1.0.0 | >=0.20.0,<1.0.0 | yes | +| endpoints | 0.1.0 | >=0.34.0,<1.0.0 | yes | +| guides | 1.0.0 | >=0.1.0,<1.0.0 | yes | +| knowledgebase | 1.0.0 | >=0.1.0,<1.0.0 | yes | +| machines | 1.0.0 | >=0.20.0,<1.0.0 | yes | +| network | 1.0.0 | >=0.20.0,<1.0.0 | yes | +| notifications | 1.0.0 | >=0.1.0,<1.0.0 | yes | +| printers | 1.0.0 | >=0.20.0,<1.0.0 | yes | +| slides | 2.0.0 | >=0.2.0,<1.0.0 | yes | +| tools | 1.0.0 | >=0.16.0,<1.0.0 | yes | +| warranty | 1.0.0 | >=0.2.0,<1.0.0 | yes | + +Manifest-less directories under `plugins/` are core frontend surface and always ship: `applications`. + +## Architecture decisions + +| ADR | title | status | +|---|---|---| +| ADR-001-asset-as-platform-contract.md | ADR-001: Asset model is the platform contract | current | +| ADR-002-plugin-versioning.md | ADR-002: Plugin contract versioning | current | +| ADR-003-plugin-distribution.md | ADR-003: Plugin distribution model | current | +| ADR-004-deployment-topology.md | ADR-004: Deployment topology (per-site instances) | current | +| ADR-005-equipment-vs-measuringtools.md | ADR-005: Equipment plugin scope vs measuringtools plugin | historical | +| ADR-006-collector-contract.md | ADR-006: Plugin collector contract pattern | current | +| ADR-007-product-versioning-and-releases.md | ADR-007: Product versioning and releases | current | +| ADR-008-plugin-migration-ownership.md | ADR-008: Plugin migration ownership (per-plugin chains from the cutover) | current | +| ADR-009-frontend-plugin-gating.md | ADR-009: Frontend plugin gating | current | +| ADR-010-frontend-plugin-hooks.md | ADR-010: Frontend plugin hook contract | current | +| ADR-011-machines-rename.md | ADR-011: Rename the equipment domain to machines; retype the models catalog with modeltypes | current | +| ADR-012-geenforce-manifest-ownership.md | ADR-012: GE-Enforce manifest ownership in shopdb | historical | +| ADR-013-plugin-catalog-and-lean-builds.md | ADR-013: Plugin Catalog, Curated Shelf, and Lean Per-Site Builds | current | +| ADR-014-schema-lean-per-site.md | ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, lift plugin tables) | current | +| ADR-015-site-specific-configuration.md | ADR-015: Where a site's own data is allowed to live | current | +| ADR-016-credential-delivery.md | ADR-016: Credential delivery to the fleet | historical | +| ADR-017-buildings-and-levels.md | ADR-017: Buildings and levels as the map model | current | +| ADR-018-metrology-instruments-are-machines.md | ADR-018: Metrology instruments are machines | current | +| ADR-019-per-pc-endpoint-credentials.md | ADR-019: Per-PC endpoint credentials | current | + +## Size + +- test functions defined: **2034** (parametrised cases collect higher) +- documented API paths: **347** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 00000000..3cf781ce --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,210 @@ + + +# Roadmap + +**Hand-written, not generated, and it drifts.** Current contract and product +versions, the plugin inventory, every Alembic chain head and the codebase size +are in [PROJECT-MAP](PROJECT-MAP.md), which IS generated; nothing of that kind +belongs here. This page is a different thing: what the product can do today, and +the short list of what is left. It supersedes nothing in the ADRs. + +shopdb-flask is pre-1.0 on both series. + +## What has shipped + +Read this as the answer to "does it already do X". Each row names the page that +covers it, so this stays a map rather than a second copy of the docs. + +### The platform + +| Capability | Where it is documented | +|---|---| +| One instance per site, single-tenant, with its own database, users, secrets and enabled plugins | [ADR-004](adr/ADR-004-deployment-topology.md) | +| `Asset` as the one model a plugin may build on, with relationships, propagation and map position on it | [ASSET-COMPOSITION](ASSET-COMPOSITION.md), [ADR-001](adr/ADR-001-asset-as-platform-contract.md) | +| A versioned plugin contract, with `core_version` ranges enforced at load | [CONTRACT-STABILITY](CONTRACT-STABILITY.md), [ADR-002](adr/ADR-002-plugin-versioning.md) | +| Manifest-first plugin loader, `shopdb.api` as the only import surface, auto-registered blueprints | [PLUGIN-HOOKS](PLUGIN-HOOKS.md) | +| Frontend hooks - settings cards, asset panels, map overlays, asset presentation - so a plugin contributes UI without editing core Vue | [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) | +| Backend-driven route gating, so a disabled plugin's URL redirects instead of breaking | [ADR-009](adr/ADR-009-frontend-plugin-gating.md) | +| Per-plugin Alembic chains, owned by the plugin, applied by `flask plugin upgrade-all` | [ADR-008](adr/ADR-008-plugin-migration-ownership.md) | +| Lean per-site builds: a site carries only the plugins it chose, and `plugin prune-schema` drops the rest of the tables | [ADR-013](adr/ADR-013-plugin-catalog-and-lean-builds.md), [ADR-014](adr/ADR-014-schema-lean-per-site.md) | +| Plugin signing and a verification gate a site can switch on | [PLUGIN-SIGNING](PLUGIN-SIGNING.md) | +| Plugins shipped from their own repositories, dropped into `plugins/` | [PLUGIN-EXTERNAL-REPO](PLUGIN-EXTERNAL-REPO.md), [ADR-003](adr/ADR-003-plugin-distribution.md) | +| `flask plugin new` scaffolding, with an external-plugin harness to prove one loads | [PLUGIN-QUICKSTART](PLUGIN-QUICKSTART.md), [PLUGIN-EXTERNAL-REPO](PLUGIN-EXTERNAL-REPO.md) | +| RBAC with declared-and-enforced permissions, account lockout and login rate limiting | [CONFIG](CONFIG.md) | +| Every read behind a permission, and `guest` as a seeded role rather than a switch per feature - a site opens a door in the same role grid it uses for everybody else, and what a guest may be granted is bounded | [CONFIG](CONFIG.md), [PLUGINS](PLUGINS.md) | +| An append-only audit log with a retention period, a size-and-impact preview, and a purge that runs only when asked | [CONFIG](CONFIG.md) | +| Generated OpenAPI served at `/api/docs`, with `llms.txt` for machine callers | [llms.txt](llms.txt) | +| A dependency inventory (SBOM) and advisory scanning shipped with every release | [DEPENDENCY-SECURITY](DEPENDENCY-SECURITY.md) | + +### The domain + +| Capability | Where it is documented | +|---|---| +| PCs, printers, machines, network devices, applications, warranties | [PLUGINS](PLUGINS.md) | +| Metrology instruments filed as machines, with the measuringtools split retired | [ADR-018](adr/ADR-018-metrology-instruments-are-machines.md) | +| The equipment domain renamed to machines, and the models catalog retyped by modeltype | [ADR-011](adr/ADR-011-machines-rename.md) | +| Staff names resolved from the site's own HR database rather than copied into ShopDB - global search and audit-log attribution | [CONFIG](CONFIG.md) | +| Several devices under one asset number, as composition rather than duplicate records | [ASSET-COMPOSITION](ASSET-COMPOSITION.md) | +| Floor map as buildings and levels, with landmark-pair transforms to move markers onto a redrawn plan | [FLOOR-MAP](FLOOR-MAP.md), [ADR-017](adr/ADR-017-buildings-and-levels.md) | +| Procedures as typed, checkbox-able blocks inside ShopDB instead of documents on a share | [GUIDES](GUIDES.md) | +| A knowledge base, notifications and boards, slides and display kiosks | [PLUGINS](PLUGINS.md), [EVENTSAVER](EVENTSAVER.md) | +| Secure Send - a one-view expiring link for handing somebody a password | [SECURE-SEND](SECURE-SEND.md) | +| Technician utilities that work on an air-gapped bench: barcode and QR label generation, two-sided labels, unit conversions | [PLUGINS](PLUGINS.md) | +| Search across the inventory from one box, with each domain answering only to a caller holding that domain's permission | [CONFIG](CONFIG.md) | +| Retiring a record from the interface as a soft delete, so a scrapped tool leaves the lists and keeps its history | [ASSET-COMPOSITION](ASSET-COMPOSITION.md) | +| A vendor and model catalog with hygiene commands - dedupe, merge and rename - for the twins legacy data carries in | [CSV-IMPORT](CSV-IMPORT.md) | +| A worst-first dashboard queue with filters | [proposals/dashboard-live-fleet](proposals/dashboard-live-fleet.md) | +| Backups as a first-class feature, with kinds, a staleness card and a restore path | [BACKUP-RESTORE](BACKUP-RESTORE.md), [BACKUP-STALENESS](BACKUP-STALENESS.md) | + +### The fleet + +| Capability | Where it is documented | +|---|---| +| A generic collector contract: a plugin declares a schema and gets an ingest endpoint with auth | [COLLECTOR-PAYLOADS](COLLECTOR-PAYLOADS.md), [ADR-006](adr/ADR-006-collector-contract.md) | +| Shipped PowerShell reporters for PC inventory and printer queues | [COLLECTOR-CLIENT-SETUP](COLLECTOR-CLIENT-SETUP.md) | +| A C# endpoint agent with a command channel, a tray app and staged upgrades | [FLEET-ARCHITECTURE](FLEET-ARCHITECTURE.md) | +| **Per-PC credentials**: each endpoint enrolls, holds its own secret, and can be revoked alone | [COLLECTOR-AUTH](COLLECTOR-AUTH.md), [ADR-019](adr/ADR-019-per-pc-endpoint-credentials.md) | +| Server public-key pinning on the agent, with the pin set authoritative from the registry | [COLLECTOR-AUTH](COLLECTOR-AUTH.md) | +| Printer installation driven from ShopDB, with printers as a property of the machine | [PRINTER-INSTALLER](PRINTER-INSTALLER.md) | +| Display kiosks and the event saver | [EVENTSAVER](EVENTSAVER.md), [FLEET-ARCHITECTURE](FLEET-ARCHITECTURE.md) | +| Adopting the shop-floor tools at a site that is not this one | [ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md) | + +### Getting a site running and filled + +| Capability | Where it is documented | +|---|---| +| Docker deployment: build, migrate, seed and serve behind the stack's own nginx | [DEPLOY](DEPLOY.md) | +| Air-gapped deployment from an image tarball, nothing pulled | [DEPLOY-AIRGAP](DEPLOY-AIRGAP.md) | +| Pre-built frontend builds, for a box where npm cannot reach the registry | [DOCKER-PREBUILT-APPROACH](deployment/DOCKER-PREBUILT-APPROACH.md) | +| Upgrading a running site, with the schema moved forward before the app serves | [UPGRADE](UPGRADE.md) | +| A first-run setup wizard that creates the first admin and captures site identity | [DEPLOY](DEPLOY.md), step 6 | +| Settings-driven branding, ServiceNow integration and site identity, so nothing is hardcoded per site | [ADR-015](adr/ADR-015-site-specific-configuration.md) | +| CSV import for sites with no developer, and a scripted import API for sites with a source database | [CSV-IMPORT](CSV-IMPORT.md), [IMPORT-API](IMPORT-API.md) | +| The completed classic-ASP migration, as a worked mapping | [IMPORT-LEGACY-ASP](IMPORT-LEGACY-ASP.md) | +| Guides that travel: a procedure exports with its images in one file and imports at another site, and an existing HTML article converts into one | [GUIDES](GUIDES.md) | + +## What is left + +Two items. Everything else that was on this list is either done and listed +above, or deferred below. + +### 1. Finalize a distributed release + +The product runs at the site that built it. Making it something another site +receives, rather than something that is stood up by the people who wrote it, is +the remaining work. + +**Docker is the shape a receiving site gets,** and on this line it is the only +one: the Windows/IIS installer topology is not carried here. What is missing: + +- **Published images, pulled rather than built.** `docker-compose.pull.yml` + and `docker-compose.airgap.yml` already run a stack from an image instead of + a source build, and the image-publishing workflow exists with its channels + decided: a release tag publishes `:stable`, the trunk publishes `:edge`, and + both carry a short-SHA tag for rollback. It is not switched on. Its push + triggers are commented out and it runs only by hand, because nobody has + confirmed the registry package exists under this owner or decided who may + pull it. Turning that on, or producing an offline bundle for a site that + cannot reach a registry, with the checksum and the upgrade path written as + one procedure rather than three, is the work. + [DOCKER-PREBUILT-APPROACH](deployment/DOCKER-PREBUILT-APPROACH.md) ends with + the shape of it. +- **One command to stand a site up.** Today a first deploy is a `.env` to + fill, a certificate to place, a plugin profile to apply and an admin to + create. Each step is documented and none is hard; together they are the + reason an adopting site needs us on the call. +- **A receiving site's upgrade story, end to end.** [UPGRADE](UPGRADE.md) + covers a site that has the source. A site that was handed an image needs the + same page written for the artifact it actually holds. +- **Prove it by adopting at a site that is not this one.** + [ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md) is written and has not + been walked by somebody with no access to the authors. + +The 2026-09-07 +[production readiness assessment](deployment/PRODUCTION-READINESS.md) is the +record of why this is the item, and several of the gaps it names have closed +since. + +### 2. Setup wizard polish + +The wizard exists and is the primary path to a first admin: `/setup` runs while +`setup_complete` is false, creates the first administrator behind a setup token +or a loopback request, seeds starter vendors, and captures site identity. +Polishing it is what turns a working wizard into the thing an adopting site is +handed: + +- Carry the steps that are still command-line - choosing and enabling plugins, + the floor plan, the collector key - into the wizard, so a site that never + opens a terminal gets a complete instance. +- Say what each choice costs and what can be changed later. A first-run screen + that quietly makes an irreversible decision is worse than a prompt. +- Make it re-runnable for the parts that are safe to re-run, so a site that + skipped a step is not stuck with the CLI as the only way back. +- Finish it against a genuinely empty instance, repeatedly, and on the air-gap + path where there is no network to lean on. + +## Deferred, deliberately + +Out of scope for 1.0. Each has a reason, and the reason is the point. + +- **Multi-tenancy.** Rejected by [ADR-004](adr/ADR-004-deployment-topology.md). + Revisit only if more than five sites adopt and the per-site overhead becomes + painful. +- **Pip-installable plugins.** Deferred per + [ADR-003](adr/ADR-003-plugin-distribution.md). Filesystem distribution stays + the v1 model. +- **An event bus on `BasePlugin`.** Removed per + [ADR-001](adr/ADR-001-asset-as-platform-contract.md); add it via a new ADR if + a real use case appears. +- **A frontend rebuild** beyond Vue 3 + Pinia + Vite. The stack is fine. +- **Full palette theming.** Brand colors are settings-driven; the rest of the + palette is still in `style.css`. Worth doing, not worth blocking on. +- **A marketplace listing site.** [PLUGINS](PLUGINS.md) is a one-pager, and + that is enough until there are more than three external plugins. +- **Contract surface diff tooling.** Version bumps are manual judgement today. + A CI check that diffs the surface against the previous tag would catch a + missed bump - see [ADR-002](adr/ADR-002-plugin-versioning.md). +- **File sharing by expiring link.** The secret half shipped as + [SECURE-SEND](SECURE-SEND.md); the file half was dropped on purpose, and + dropping it removed the export-control question entirely, along with blob + storage and the need to authenticate a recipient. What would have to be + settled before it comes back, so nobody rediscovers it: a FILE recipient + should be logged in too, which costs nothing on an intranet-only server where + everyone with the link already has an account; sharing a file wants its own + permission separate from sharing a secret, because the risk classes differ; + and zero-knowledge encryption is right for a credential but questionable for + a controlled document, where "the server cannot say what was in it" is a + liability rather than a feature. Export control should confirm internal link + sharing in writing before anything here carries drawings or NC programs. +- **Calibration cycles, maintenance windows, downtime tracking.** Domain + extensions that would live in the `machines` plugin, not framework work. + +## What 1.0.0 means + +Tagging `1.0.0` is a commitment that the contract surface is stable for at least +the next minor version cycle: a plugin author at another site can pin +`core_version: ">=1.0.0,<2.0.0"` and trust the plugin will survive framework +patches. + +Concretely, it ships when: + +1. A site that did not build the product can receive a release, install it and + upgrade it without us - the distributed release item above. +2. The setup wizard takes a site from empty to usable without a terminal. +3. The contract test suite covers every documented hook with both a happy path + and a broken-plugin failure-isolation test. +4. At least one external plugin has been built end to end, from its own + repository, with no contract change needed mid-build. + +## Decision log pointers + +When something here gets prioritized, the why goes in a new ADR and this file +links it. The ADRs are canonical for design decisions and +[adr/README](adr/README.md) is their index, including which ones still hold. +This file is only the prioritized backlog. diff --git a/docs/SECURE-SEND.md b/docs/SECURE-SEND.md new file mode 100644 index 00000000..d26944f9 --- /dev/null +++ b/docs/SECURE-SEND.md @@ -0,0 +1,157 @@ + + +# Secure Send + +Hand somebody a password without leaving it in a chat window. + +Secure Send makes a link that carries one credential, dies when it is opened, +and expires on its own if nobody opens it. It lives in **Tech Tools** at +`/tools/secure-send`, and the person receiving it opens `/s/`. It is the +one tool in that section that is not purely browser-side, because a link that +dies on first read needs somewhere to keep the ciphertext until it is claimed. + +## What the server can and cannot see + +The secret is encrypted **in the sender's browser** with AES-GCM, and the key +travels in the **URL fragment** - the part after the `#`. Browsers never send a +fragment to a server, so the key reaches the recipient's browser and nowhere +else: not this server, not its logs, not a proxy in between. + +What the `securesendsecrets` table holds is a random token, base64 ciphertext, +an IV, and the rules for the row's life. Reading the database yields nothing. + +That cuts both ways, and it is worth being explicit about it: + +- **Nobody can recover a secret that goes missing.** Not an administrator, not + the database owner. If the link is lost, the secret is lost. Create another. +- **The server cannot tell a password from a page of a drawing.** It cannot + look at what it holds, so it cannot enforce a rule about content. The only + lever it has is SIZE, which is the next section. +- **The audit trail records the fact, never the content.** Who created a + secret, when, and when it was read are all recorded. What was in it is not + recoverable by anyone. + +## This is for credentials, not documents + +The ciphertext limit is **4096 characters** (about 2800 characters of plaintext, +which the create page enforces first with a friendlier message). That limit is +not a tuning knob. In a design where the server cannot read what it stores, +size is the entire server-side control over what this feature is for, and +raising it quietly turns a password tool into an unaudited document transfer. + +Everything else the create page does - counting lines, spotting file +signatures, refusing a pasted file - runs in the browser. It guides an honest +person and is bypassable by anyone willing to POST directly. The size limit is +the part that holds. + +If somebody needs to move a file, the answer is a different tool, not a bigger +number here. + +## Who may create, who may read + +**Two different doors, on purpose.** + +**Creating** normally needs a login plus the `tools.secrets` permission (admins +hold it by default; grant it to other roles from Settings > Users & Roles). The +login is not a formality: anonymous creation would make this an unattributable +write endpoint that stores arbitrary ciphertext, and the creator is the only +person these rows can ever name. + +**Reading needs neither an account nor a permission.** The recipient usually has +no account here, and the link is normally handed over on a call or in a chat. +The unguessable token IS the authorisation, which is why it is 128 bits of +randomness and why the row is spent on the first successful read. + +### Guest creation + +A site may open creation to people with no account with the +`securesend_allow_guests` setting (Settings > Tools > Secure Send). It is +**off by default**, because a guest secret has no creator and the audit trail +can then say when and from where but never who. The create page itself is served +without a login either way, so a guest at a site that allows it is not stopped +by a sign-in wall; the server, not the page, decides whether the write lands. + +When it is on, guest creation is rate limited to 10 links per address per 10 +minutes. That is not a security boundary - a determined person has more +addresses - but it stops an open endpoint being an easy place to park data or +fill a disk. + +The setting is read straight from the settings row rather than the cache, and +**fails closed**: if it cannot be read at all, the answer is no. An error must +never be the thing that opens an unauthenticated write endpoint. + +Receiving a secret never needs an account either way. + +## Lifetime + +| Control | Default | Range | +|---------|---------|-------| +| Expiry | 24 hours | 1 to 168 hours | +| Views | 1 | 1 to 100 | + +Both apply, whichever comes first. A spent row is deleted outright, including +its ciphertext. + +Expired secrets are swept opportunistically - whenever anybody creates a secret, +and whenever a dead link is clicked - rather than on a schedule. A secret nobody +ever opens would otherwise be removed by nothing, and an air-gapped site is not +going to have a cron job anybody remembers to add. + +Missing, expired and spent all answer identically. Distinguishing them would +confirm to anyone holding a guessed token whether it ever named anything. + +## Redeeming is a POST + +`POST /api/tools/secrets/` hands over the ciphertext and spends the view. +A GET would be wrong here: chat and mail clients prefetch links to build +previews, and a one-view secret consumed by a preview window is a secret the +recipient never gets to see. + +There is a `GET /api/tools/secrets/` as well, but it reports availability +only and never spends a view. The read page asks it first so it can say "this +secret is waiting, and opening it will destroy it" instead of consuming the only +view to find out whether there was anything there. + +## Before anybody relies on it + +**Send a link to yourself through whatever your site uses to pass it on - +Teams, Outlook, a chat client - and check the key is still there after the `#`.** + +Some mail and chat security products rewrite links. If one strips or mangles the +fragment, the secret is unrecoverable and the server cannot help, which is the +whole point of the design. Find that out on a test secret rather than on a +production credential. + +## Getting the link right + +The link is built from the site's configured `site_base_url`, not from the +address the sender's browser happens to be using. Somebody creating a secret may +have reached the app by an IP, a short hostname, or a name that only resolves +inside one VLAN, while the recipient has to open the link from wherever they +are. Blank falls back to the browsing origin. + +A site served under a subpath (`/shopdb`, `/ops`) needs that subpath reachable +in the link. Setting it as part of `site_base_url` works, and so does leaving it +out; the mount is added only when it is not there already. + +## Where it lives + +| Piece | Path | +|-------|------| +| Plugin | `plugins/tools/` | +| Table | `securesendsecrets` | +| API | `POST /api/tools/secrets`, `POST` and `GET /api/tools/secrets/` | +| Create page | `/tools/secure-send` | +| Read page | `/s/` | +| Setting | `securesend_allow_guests` (public, boolean, category `tools`) | +| Permission | `tools.secrets` | + +The `tools` settings section is in [CONFIG.md](CONFIG.md); the response envelope +these routes use is in [llms.txt](llms.txt) and in the generated spec at +`/api/docs`. diff --git a/docs/START-HERE.md b/docs/START-HERE.md new file mode 100644 index 00000000..45ae8992 --- /dev/null +++ b/docs/START-HERE.md @@ -0,0 +1,106 @@ + + +# Start here + +ShopDB is a per-site asset platform: one instance per plant, plugins for what +each site tracks. These pages document the server, the plugin contract, and the +tools that run on shop-floor PCs. + +This page covers the handful of paths people actually arrive on, and the +warnings worth reading before you pick one. Anything not here is in +[INDEX](INDEX.md), which lists every task, the smallest page set that answers +it, and how many lines that costs. Do not read both: find your row in one of +them and go. + +Each path below is the shortest correct one, not everything that exists. + +## I am standing up ShopDB at a new site + +**Docker is the path, and the only one this line ships.** + +1. [DEPLOY](DEPLOY.md) - clone to first admin on Docker at a connected site. + No internet at all: [DEPLOY-AIRGAP](DEPLOY-AIRGAP.md). +2. Then load the site's data: [CSV-IMPORT](CSV-IMPORT.md) from spreadsheets, or + [IMPORT-API](IMPORT-API.md) if there is a source database to script against. +3. Later versions: [UPGRADE](UPGRADE.md), after + [BACKUP-RESTORE](BACKUP-RESTORE.md). + +Just want to look at a build without standing one up? +[DEV-BUILD-TEST](DEV-BUILD-TEST.md) pulls a prebuilt stack onto a Windows box +with Docker Desktop. + +## I am deploying the shop-floor tools + +Read [FLEET-ARCHITECTURE](FLEET-ARCHITECTURE.md) first - it is one page and it +is what makes the rest make sense. It ends with a symptom table that says which +piece to open when a bay misbehaves. + +[ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md) has worked examples for +Intune and for manual installation, and says where each artifact comes from. +ShopDB serves the data these tools read; getting them onto a bay is your site's +configuration tooling, not ShopDB's. + +## I am writing a plugin + +1. [PLUGIN-QUICKSTART](PLUGIN-QUICKSTART.md) - scaffold and first run. +2. [PLUGIN-HOOKS](PLUGIN-HOOKS.md) - the contract. Authoritative, and the one + page every other plugin page defers to. +3. [PLUGIN-GUIDE](PLUGIN-GUIDE.md) - the conventions every bundled plugin + follows and the traps that have actually bitten someone. Walk its checklist + before you call a plugin done. Read `plugins/warranty/` beside it: it is the + smallest bundled plugin that exercises most of the contract. + +If it lives in its own repository, [PLUGIN-EXTERNAL-REPO](PLUGIN-EXTERNAL-REPO.md), +and [CONTRACT-STABILITY](CONTRACT-STABILITY.md) before you choose a version pin. + +## I am integrating with the API + +1. [llms.txt](llms.txt) (also served at `/api/docs/llms.txt`) - auth, the + response envelope, common recipes. Short, and the envelope section is the + part people get wrong. +2. `GET /api/docs` on any running instance - the full spec, browsable. +3. [COLLECTOR-INTEGRATION](COLLECTOR-INTEGRATION.md) if you are feeding data in + from a PC, and [COLLECTOR-AUTH](COLLECTOR-AUTH.md) for how that PC proves + who it is. + +## Something is broken + +- Server: `docker compose logs api`, then the health checks at the end of + [DEPLOY](DEPLOY.md). +- A PC or a bay: the symptom table at the end of + [FLEET-ARCHITECTURE](FLEET-ARCHITECTURE.md). +- An upgrade that went in and then misbehaved: [UPGRADE](UPGRADE.md), with + [BACKUP-RESTORE](BACKUP-RESTORE.md) if data has to come back too. + +## I am trying to understand why something is built this way + +[The ADRs](adr/README.md). They are the decision record, they say what was +rejected and why, they say whether each decision still holds, and they are the +fastest way to avoid relitigating a settled question. One ADR answers one +question; nothing there is a prerequisite for anything else. + +## Three things people do not know exist + +- [GUIDES](GUIDES.md) - procedures live in ShopDB itself, as typed blocks with + checkboxes a technician ticks as they work, instead of a Word document on a + share. +- [SECURE-SEND](SECURE-SEND.md) - a one-view expiring link for handing somebody + a password. Read its "Before anybody relies on it" section first: there is one + thing to test at your site before the first real credential goes through it. +- [DEPENDENCY-SECURITY](DEPENDENCY-SECURITY.md) - every release installs a full + inventory of third-party components, so a security team can answer "are we + exposed to this advisory" without access to anything here. + +## Before you read anything else here + +Every page opens with a `docmeta` comment saying what it is for and whether it +is still current. A page marked `legacy` is true but superseded, and a page +marked `historical` is a record of a finished migration or a proposal. Neither +is guidance. [INDEX](INDEX.md) lists all of them in one place, at the bottom, +so you never have to read one to find out it does not apply to you. diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md new file mode 100644 index 00000000..f02f7e12 --- /dev/null +++ b/docs/UPGRADE.md @@ -0,0 +1,150 @@ + +# Upgrading an existing site + +> **Topology: Docker, or a bare-metal venv install.** An air-gapped Docker site +> upgrades by loading a newer image tarball - +> [DEPLOY-AIRGAP](DEPLOY-AIRGAP.md#upgrading-to-a-new-version), not this page. +> **Where this sits:** install -> operate -> **upgrade**. +> Back: [DEPLOY](DEPLOY.md), which is also the page for a first-time install. + +Each site is single-tenant (ADR-004), so an upgrade touches only that site's +own stack. Read the ADRs added since your last update (`docs/adr/`) and the +`CHANGELOG.md` before starting; a breaking ADR may require coordinated work. + +## Step 0: Back up first + +Never upgrade without a fresh backup you have tested: a full database dump and a +copy of `instance/`. [BACKUP-RESTORE](BACKUP-RESTORE.md) is the procedure. + +```bash +docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask | gzip > pre-upgrade-$(date +%F).sql.gz +cp -a instance/ instance-backup-$(date +%F)/ +``` + +## Step 1: Get the new code + +```bash +git pull +``` + +The application is distributed as source, and a production site builds its own +image from that checkout. Sites do not pull a published image: CI publishes +channel images to a container registry for dev and pilot workstations only +(`docker-compose.pull.yml`, `.github/workflows/publish-images.yml`). + +## Step 2: Rebuild + +The Docker image builds the Vue frontend in-image, so a container rebuild picks +up frontend changes automatically: + +```bash +docker compose build api +docker compose up -d api +``` + +Bare-metal / venv install: rebuild the frontend by hand and refresh Python +dependencies: + +```bash +source venv/bin/activate +pip install -r requirements.txt +cd frontend && npm ci && npm run build && cd .. +``` + +`up -d api` REPLACES the container, so everything the site has written must be +in the `instance_data` volume. If your stack predates that volume, do the +one-time rescue in [DEPLOY](DEPLOY.md#the-instance_data-volume-and-stacks-that-predate-it) +**before** this step, not after. + +## Step 3: Migrate and re-seed + +**Under compose this is automatic.** `docker compose up -d api` will not start +`api` until the `migrate` service has run `db upgrade`, the plugin bootstrap, +`plugin upgrade-all` and the three seeders and exited 0. Watch it: + +```bash +docker compose logs migrate # the last line is the error, if there was one +``` + +A venv install has no `migrate` service and runs them itself: + +```bash +flask db upgrade +flask plugin upgrade-all +flask seed permissions +flask seed settings +flask seed reference-data +``` + +All of them are idempotent, so running them at head or on an already-seeded +database is a no-op; the seeders add anything a new version introduced and leave +your site's customized values alone. `plugin upgrade-all` is the one people +skip - each plugin owns its own Alembic chain (ADR-008), and missing it surfaces +days later as a 1054 "Unknown column" on the page that uses the new field. +[DEPLOY](DEPLOY.md#step-3-the-schema-and-what-the-migrate-service-already-did) +explains what each command covers. + +## Step 4: Restart and confirm + +```bash +docker compose restart api +# venv: restart your process manager, e.g. pm2 restart shopdb-flask-api shopdb-flask-ui +docker compose exec api flask db current # must match `flask db heads` +``` + +Then log in: the page renders, and `/api/auth/login` returns a +`VALIDATION_ERROR` for an empty body rather than a 500. + +## Version-specific notes + +### Upgrading to v0.11.0: the floor map became buildings and levels + +Nothing to do, and nothing moves on screen - the ordinary Step 3 migration +handles it. `7d33_buildings_and_levels` creates one building ("Main") and one +default level ("Ground floor") carrying the blueprint paths and dimensions from +your `map_*` settings, then assigns every already-placed asset and location to +that level. Unplaced things stay unplaced. The old settings rows are +deliberately left in place, so a downgrade still finds the blueprint. + +Two things to know afterwards: + +- **A second floor or building is a Settings task**, not a migration: add it under + Settings > Buildings and levels, upload its blueprint, then move the markers + that belong on it. `POST /api/mappositions/transform` moves a whole level's + markers onto a redrawn plan from landmark pairs, dry run by default. +- **Replacing a blueprint with one of DIFFERENT dimensions moves every marker on + that level**, because the coordinates are pixels of the old size. Upload it, then + recalibrate with landmarks rather than editing the level's width and height by + hand. The API refuses to adopt a new size on a level that already has markers, + for this reason. + +If you wrote a plugin that stores map positions, see CONTRACT-STABILITY.md: the +contract is 0.20.0 and a position must now carry its `levelid`. + +### Upgrading from before v0.5.0: the bundled floor plan was removed + +Only affects an instance whose `map_blueprint_light` / `map_blueprint_dark` +Settings still point at `/static/images/sitemap2025-*`. Those bundled PNGs are +gone from v0.5 on, so the map 404s them after the upgrade. Re-upload your own +plan in **Settings > Floor Map**. Uploaded plans live under `instance/` and +survive upgrades, so a site that already uploaded one is unaffected. + +```bash +docker compose exec -T api flask shell <<'PY' +from shopdb.core.models.setting import Setting +print(Setting.query.filter(Setting.key.like('map_blueprint%')).all()) +PY +``` + +## See also + +- [BACKUP-RESTORE](BACKUP-RESTORE.md) - what to back up and how to restore +- [CONFIG](CONFIG.md) - environment variables and Setting keys +- [DEPLOY](DEPLOY.md) - first-time deploy runbook +- `CHANGELOG.md` - what changed in each release diff --git a/docs/adr/ADR-001-asset-as-platform-contract.md b/docs/adr/ADR-001-asset-as-platform-contract.md new file mode 100644 index 00000000..bed73b68 --- /dev/null +++ b/docs/adr/ADR-001-asset-as-platform-contract.md @@ -0,0 +1,200 @@ + + +# ADR-001: Asset model is the platform contract + +- **Status:** ACCEPTED +- **Date:** 2026-05-08 +- **Deciders:** ShopDB maintainers +- **Supersedes:** none + +## Context + +shopdb-flask is being shaped as a framework that sister GE Aerospace facilities can adopt. The framework defines a stable core; sites install plugins for the asset classes they care about (equipment, computers, printers, measuring tools, network gear, etc.). + +The codebase ran two parallel object models: + +1. **Legacy `Machine` model** in `shopdb/core/models/machine.py`. Original schema inherited from the classic-ASP shopdb. Tables: `machines`, `pctypes`, `machinetypes`. Plugins like printers stored extension data via `PrinterData` keyed by `machineid`. +2. **New `Asset` model** in `shopdb/core/models/asset.py`. Generic asset abstraction with `AssetType`, `AssetStatus`, `AssetRelationship`. Plugins also exposed asset-based tables keyed by `assetid`. + +For the framework to be adoptable, the platform contract has to be one model, documented, versioned, and stable. + +## Decision + +**`Asset` is the platform contract.** Plugin authors target the asset-based API. The `Machine` model and its dependents (`PCType`, `MachineType`, `PrinterData` keyed by `machineid`) are legacy and will be retired through a tracked migration. + +### Platform contract surface + +The following are the public, versioned surface. Plugin authors may depend on them. Breaking changes require a major version bump per ADR-002. + +#### Models + +- `Asset` (core entity) +- `AssetType` (asset classification, registered by plugins) +- `AssetStatus` (active / inactive / decommissioned / retired) +- `AssetRelationship` + `RelationshipType` (cross-plugin links) +- `Vendor`, `Location`, `LocationType`, `BusinessUnit`, `Model`, `OperatingSystem` (shared reference data) + +#### Helpers + +- `AuditLog` API: `audit_log(action, entitytype, entityid, ...)` for plugins to record audit entries with consistent schema +- `Setting` API: `plugin.get_setting(key)` and `plugin.set_setting(key, value)` for plugin-scoped config persisted via the core `Setting` model +- `resolve_asset_position(asset)` for the documented position-resolution algorithm + +#### Import surface (`shopdb.api`) - expanded in __contract_version__ 0.3.0 + +`shopdb.api` is the ONLY core module plugins may import (plus `shopdb.plugins.base` +for the ABC). Deep imports of `shopdb.core.*`, `shopdb.extensions`, or +`shopdb.utils.*` are contract violations enforced by the +`test_plugins_only_import_contract_surface` test. The surface re-exports: + +- Infrastructure: `db`, `cache` +- Model bases: `BaseModel`, `AuditMixin` +- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`, + `Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`, + `Application`, `AppVersion`, `OperatingSystem` +- Responses: `success_response`, `error_response`, `paginated_response`, `ErrorCodes` +- Pagination: `get_pagination_params`, `paginate_query` +- Helpers: `audit_log`, `resolve_asset_position`; legacy `employee_connection` + +Adding a name here is a minor (additive) contract change; removing one is major. + +#### Plugin contract + +- `BasePlugin` ABC and its hooks (navigation, dashboard widgets, collector schema + `apply_collector_payload`). Note: `get_searchable_fields` was removed in contract 0.4.0 - global search is a core concern over the asset model, not a per-plugin hook. + +#### Excluded from the contract for v1 + +- Event bus (`get_event_handlers` removed from `BasePlugin`). Add later if a real use case appears. + +### Relationship types (seeded core values) + +`RelationshipType` is seeded with three rows. Plugin authors do not add new types in v1. + +| Type | Meaning | Boundary rule | +|------|---------|---------------| +| `partof` | Composition, siblings, sub-assemblies | Parent dies without it | +| `controls` | One asset has operational authority over another | PC commands a machine, measuring tool, etc. | +| `connectedto` | Network or data link without operational authority | Cable, switch port, NAS mount | + +### AssetRelationship columns + +``` +AssetRelationship +- relationshipid PK +- sourceassetid FK to assets +- targetassetid FK to assets +- relationshiptypeid FK to relationshiptypes (one of partof, controls, connectedto) +- label text, free description (e.g., "ethernet PoE", "DNC feed") +- inheritsposition bool, true means resolved-position walk follows this edge +- propagatesthroughid FK to relationshiptypes, nullable, see propagation below +- notes text +- isactive bool +- createddate, modifieddate +``` + +Free-text `label` carries the domain nuance (`controls` with label "DNC feed" vs `controls` with label "operator workstation"). Avoids inflating the type list. + +### Sibling propagation + +When a relationship is created or deleted, the framework checks `RelationshipType.propagatesthroughid`. If set, it finds all assets related to the source via the propagation type and applies the same change. + +Default seeding: + +| RelationshipType | propagatesthroughid | +|------------------|---------------------| +| `partof` | null (the propagation rail itself) | +| `controls` | `partof` (controls relationships propagate across siblings) | +| `connectedto` | null (network paths don't propagate) | + +Cycle protection: max walk depth 3, visited-set during traversal. + +### Position resolution + +Resolved map position for any asset follows this priority chain: + +1. Asset-specific override (`assets.mapx`, `assets.mapy` if non-null) +2. Walk relationships where `inheritsposition=true`, ordered by relationship type priority (`partof` first, then `controls`), recursively resolve the related asset's position with cycle detection +3. Fall back to the asset's location coords (`locations.mapx`, `locations.mapy` via `assets.locationid`) +4. If none of the above, asset is "unplaced" and rendered in a tray, not on the map + +API exposes resolved position with a source indicator: `{"mapx": 234, "mapy": 567, "positionsource": "self|related|location|none"}`. + +### Hierarchical locations + +`locations` is extended with `parentlocationid` (self-FK, nullable). Sites define their own location tree (cells, sub-cells, network closets, meeting rooms, labs). + +`locationtypes` lookup is added with seeded values: `section`, `cell`, `subcell`, `meetingroom`, `lab`, `office`, `storage`, `hallway`, `networkcloset`, `building`. Sites can extend. + +Asset-to-location is the existing `assets.locationid` FK only (single primary location for v1). Multi-location and transient placement are deferred. + +## Migration scope (narrowed) + +Only one class of legacy data migrates: physical manufacturing equipment with a `machinenumber` (5-axis mills, lathes, broachers, heat treatment, CMMs, etc.). + +Migration filter: + +```sql +SELECT * FROM legacy.machines +WHERE category = 'Equipment' + AND machinenumber IS NOT NULL +``` + +Rows without `machinenumber`: **skipped.** Add manually post-migration if any matter. + +Migrated rows become `assets` with `assettype='Equipment'` plus a row in the equipment plugin's `equipment` table. Legacy `machinetypeid` is preserved on the equipment row to enable later reclassification (some "Equipment" rows are actually measuring tools, see ADR-005). + +Skipped from migration: + +- PCs (rebuilt via PXE collector pipeline, see ADR-006) +- Printers, USB devices, network gear, notifications, KB articles (operational or re-collectable, decision per class deferred until needed) + +## Consequences + +### Positive + +- Plugin authors at sister sites have a single, documented contract to target +- Cross-plugin features (relationships, search, map) work uniformly +- Three relationship types are easy to learn; free-text label captures nuance +- Sibling propagation handles dual-path machines without code changes per use case +- Position resolution gives users one map without per-plugin map code + +### Negative + +- Migration of equipment data must run cleanly; `migrating-asset-schema` skill owns the procedure +- Frontend `views/machines/` will be repointed or replaced; effort tracked in Phase 5 +- Legacy `core/api/machines.py` (641 LOC) becomes deprecated; deletion or shim deferred until equipment is migrated + +### Neutral + +- `Machine`, `PCType`, `MachineType`, `PrinterData` remain in the schema until the migration completes, behind a deprecation notice. New code does not touch them. + +## Alternatives considered + +1. **Keep both models indefinitely.** Doubles test surface, confuses contract docs, breaks cross-plugin features. Rejected. +2. **Make `Machine` the contract; retire `Asset`.** `Machine` has shop-floor-specific assumptions that don't generalize. Rejected. +3. **Define a higher abstraction above both.** Yet another layer. `Asset` is already the abstraction. Rejected. +4. **More relationship types** (`controls`, `operates`, `monitors`, `dncfeeds`, `mountedon`, `connectedvia`, `inspects`, `siblingbay`). Eight types proved unwieldy. Collapsed to three with free-text labels and per-row inheritance/propagation flags. + +## Open questions deferred + +- Lifecycle relationship type (`replaces`, `replacedby`). Add when first needed. +- Multi-location asset placement, transient placement (calibration trip, off-site repair). Add when first needed. +- Map editing UI (drag locations onto floor plan). Phase 6 polish. +- Plugin-extensible relationship types beyond the three seeded. Add when a real cross-plugin case can't be expressed via labels. + +## References + +- `shopdb/core/models/asset.py` +- (core `Machine` model retired per this decision; machine data now owned by the machines plugin at `plugins/machines/models/machine.py`) +- `shopdb/plugins/base.py` +- ADR-002 (versioning of the surface) +- ADR-003 (plugin distribution) +- ADR-004 (deployment topology) +- ADR-005 (equipment vs measuring tools split) +- ADR-006 (collector contract pattern) diff --git a/docs/adr/ADR-002-plugin-versioning.md b/docs/adr/ADR-002-plugin-versioning.md new file mode 100644 index 00000000..c92eda97 --- /dev/null +++ b/docs/adr/ADR-002-plugin-versioning.md @@ -0,0 +1,68 @@ + + +# ADR-002: Plugin contract versioning + +- **Status:** ACCEPTED +- **Date:** 2026-05-08 +- **Deciders:** ShopDB maintainers +- **Supersedes:** none + +## Context + +Once sister sites start writing their own plugins (or pulling community plugins), the framework's plugin contract becomes a public API. Without a versioning story, any change to `BasePlugin` or the core platform models can silently break installed plugins at remote sites. + +The existing `BasePlugin` and `PluginMeta` already declare a `core_version` field (default `">=1.0.0"`), but it is not enforced anywhere. The plugin loader does not check it before instantiation. + +## Decision + +The framework adopts **semantic versioning for the plugin contract**, declared in two places: + +1. **Framework version** (`shopdb/__init__.py`): a single `__contract_version__` constant. This is the version of the platform contract as defined in ADR-001. Bumped according to semver: + - **Major**: breaking change to `BasePlugin` ABC, `PluginMeta` schema, or any model in the platform contract (`Asset`, `AssetType`, `AssetStatus`, `AssetRelationship`, `Vendor`, `Location`, `BusinessUnit`, `Model`, `OperatingSystem`). + - **Minor**: additive change (new optional hook, new field on a contract model with default). + - **Patch**: bug fix, no contract surface change. +2. **Plugin requirement** (`plugins//manifest.json`): the existing `core_version` field, expressed as a semver range (e.g., `">=1.0.0,<2.0.0"`). + +The plugin loader (`shopdb/plugins/loader.py`) checks `core_version` against `__contract_version__` at load time. Mismatch in dev = re-raise (fail loud). Mismatch in prod = log error, mark plugin as incompatible, exclude from registration. + +The `__contract_version__` starts at **`1.0.0`** when ADR-001 is accepted and the `Machine` retirement migration is complete (whichever comes later). Until then, the framework is pre-1.0; plugins should declare `core_version: ">=0.1.0,<1.0.0"`. + +## Consequences + +### Positive + +- Sister sites can pin a known-good framework version. They will not be silently broken when the framework is upgraded. +- Plugin authors know what counts as a breaking change because the contract surface is enumerated in ADR-001. +- The loader fails predictably: a mismatched plugin is reported, not silently disabled. + +### Negative / cost + +- Discipline required: every change to the contract surface must be classified (major / minor / patch). Adding a `version-bump` skill (or a check in code review) reduces the chance of mis-classification. +- `__contract_version__` becomes a coupling point. Forgetting to bump it after a breaking change means downstream plugins crash silently at runtime instead of failing at install. + +### Neutral + +- Existing plugins (`plugins/printers/`, etc.) ship as part of the framework, so their `core_version` is always the current `__contract_version__`. The discipline matters mostly for external / sister-site plugins. + +## Alternatives considered + +1. **No versioning, just trust.** Works for an in-tree-only world. Fails the moment a sister site ships its own plugin. Rejected. +2. **Calendar versioning** (e.g., `2026.05.0`). Easier to bump, harder to communicate breaking changes. Rejected; semver is the industry standard for library-like contracts. +3. **Per-hook versioning.** Each hook has its own version. Too granular; plugins still couple to multiple hooks. Rejected. + +## Open questions + +- When does the framework declare `1.0.0`? Tied to ADR-001 (Asset retirement of Machine) and the framework being deemed "ready for sister sites". Best-effort target: end of Phase 5 in the refactor plan. +- Should `core_version` accept commercial-grade ranges (`^1.0.0`) or stick to PEP 440 / npm-style ranges? Recommend pip-style (`>=,<`) to match Python ecosystem. + +## References + +- `shopdb/plugins/base.py` (PluginMeta declaration) +- `shopdb/plugins/loader.py` (where the version check belongs) +- ADR-001 (defines what is in the contract) diff --git a/docs/adr/ADR-003-plugin-distribution.md b/docs/adr/ADR-003-plugin-distribution.md new file mode 100644 index 00000000..1675bd0c --- /dev/null +++ b/docs/adr/ADR-003-plugin-distribution.md @@ -0,0 +1,76 @@ + + +# ADR-003: Plugin distribution model + +- **Status:** ACCEPTED +- **Date:** 2026-05-08 +- **Deciders:** ShopDB maintainers + +## Context + +Sister sites adopting shopdb-flask need a way to: + +1. Install the framework +2. Pick which plugins they want +3. Build their own plugins for site-specific equipment +4. Receive updates to both framework and plugins + +Today, every plugin lives in the `plugins/` directory of the framework repo. There is no separation between framework code and plugin code, no install / uninstall, and no way for a site to develop a plugin without forking the whole repo. + +Three viable distribution models: + +| Model | How a site installs a plugin | +|---|---| +| **In-tree only** | Fork the framework repo, add plugin under `plugins/`, run their own deploy. No separation. | +| **Pip-installable plugins** | Each plugin published as a Python package. Site does `pip install shopdb-printers shopdb-network` etc. Discovery via Python entry points. Framework loads any installed plugin that registers itself. | +| **Git-based plugins** | Each plugin lives in its own git repo. Site clones / submodules into `plugins//`. Loader picks them up from the directory. | + +## Decision + +Use a **hybrid model** with two clearly-labeled paths. + +1. **Bundled plugins**: a small set of plugins ships with the framework, in-tree at `plugins/`. These are the reference implementations and the default install (printers, computers, network, equipment, usb, notifications). A site that wants only what's bundled needs no extra work. +2. **External plugins**: sister sites or third parties build plugins in their own git repos. The site running the framework drops the plugin into `plugins//` (clone, submodule, or symlink) and runs `flask plugin install `. No pip packaging required for v1. + +Pip-installable plugins (Python entry-point discovery) are deferred to v2. The complexity is not justified until at least two sites are running their own plugins. + +## Consequences + +### Positive + +- v1 is simple: filesystem-based discovery (already implemented in `shopdb/plugins/loader.py`), works for both bundled and external plugins. +- Sites can develop plugins without changing the framework repo. +- The `plugins/` directory is already the canonical location, so no architectural change is needed. + +### Negative / cost + +- No automatic update path for external plugins. Sites must `git pull` in each plugin directory manually. Acceptable for v1; revisit when plugin count grows. +- Multiple plugin authors writing in parallel can collide on namespace (e.g., two plugins both registering an `AssetType` named "Equipment"). Need a naming policy: plugin names and asset-type names should be prefixed with the site or org if not in the bundled set. + +### Neutral + +- The existing in-tree pattern keeps working. This decision just formalizes it and clarifies the path for outside-the-tree plugins. + +## Alternatives considered + +1. **Pip-installable from day one.** Cleaner for the long term but adds packaging, entry-point registration, and CI steps. Premature for current scale (one site running, no sister-site plugins yet). +2. **In-tree only forever.** Forces every site to fork. Doesn't scale beyond two or three sites. +3. **Submodules only.** Forces git-submodule discipline on every adopting site. Submodules are notoriously fiddly. Rejected. + +## Open questions + +- For external plugins, should there be a manifest field (`source_url`) declaring where the plugin can be cloned from, so `flask plugin install` could pull it for the site? Defer; manual clone is fine for v1. +- Naming convention for non-bundled plugin directory names: prefix with site? (`gea-wjsf-shipping`)? Adopt if and when we hit a name collision. + +## References + +- `shopdb/plugins/loader.py` (filesystem discovery) +- `shopdb/plugins/cli.py` (plugin install / uninstall command) +- ADR-001 (defines what plugins target) +- ADR-002 (defines plugin version compatibility) diff --git a/docs/adr/ADR-004-deployment-topology.md b/docs/adr/ADR-004-deployment-topology.md new file mode 100644 index 00000000..567eac4f --- /dev/null +++ b/docs/adr/ADR-004-deployment-topology.md @@ -0,0 +1,100 @@ + + +# ADR-004: Deployment topology (per-site instances) + +- **Status:** ACCEPTED +- **Date:** 2026-05-08 +- **Deciders:** ShopDB maintainers + +## Context + +shopdb-flask manages shop-floor inventory. If multiple GE Aerospace sites adopt it, the deployment can take one of two shapes: + +| Model | How it works | +|---|---| +| **Per-site instances** | Each site runs its own Flask + MySQL + Vue stack. Each site has its own DB, its own users, its own enabled-plugin list, its own deploy. Sites are isolated. | +| **Multi-tenant single instance** | One central Flask + MySQL + Vue stack serves all sites. A `siteid` foreign key on every asset partitions data. Auth distinguishes which site a user belongs to. | + +The codebase today is single-tenant per deployment. There is no `siteid` column, no tenant filter, no cross-site auth model. Plugins can be enabled / disabled but only globally for the running instance. + +## Decision + +**Per-site instances.** Each adopting site runs its own dedicated stack. The framework does not support multi-tenancy. + +Each site: + +- Owns its database (own credentials, own backup policy, own retention). The + database charset is part of the contract: it must be **utf8mb4** + (`utf8mb4_unicode_ci`). The migration chain creates every table utf8mb4, and + the connection pins `?charset=utf8mb4`. A site that creates the database with + a different default charset (older MySQL defaults to latin1) gets a schema + that silently diverges from every other site. See `docs/DEPLOY.md`. +- Picks its own enabled plugins +- Configures its own JWT secret, CORS allowlist, Zabbix integration, Active Directory binding +- Deploys at its own cadence + +The framework provides: + +- A `Dockerfile` and `docker-compose.yml` template suitable for a single-site deploy +- A `.env.example` listing all required environment variables +- A `docs/DEPLOY.md` walking through a fresh-site install + +## Consequences + +### Positive + +- Simpler code: no tenant filter on every query, no cross-tenant auth, no shared-state partitioning bugs. +- Sites are independent. A schema change at one site does not affect another. A plugin crash at one site does not blast radius to other sites. +- Clear ownership: each site's IT team owns their own stack and data. Compliance and audit boundaries match operational boundaries. +- Aligns with how GE Aerospace sites already operate (independent IT, independent shop floors). + +### Negative / cost + +- No cross-site reporting out of the box. If GE corporate ever wants a fleet-wide view, it has to be built on top (e.g., a roll-up dashboard that queries each site's API). That layer is out of scope for the framework. +- Each site administers its own stack. Higher operational overhead than a single central instance, but each site already runs its own infrastructure. +- Updates require visiting each site's deploy. Fine for the current adoption model; revisit if dozens of sites adopt. + +### Neutral + +- No `siteid` column needed. The existence of one DB per site is the partition. + +## Alternatives considered + +1. **Multi-tenant single instance.** Lower operational overhead at scale, easier cross-site reporting, but adds significant code complexity and risk: every query needs a tenant filter, auth gets complex, schema migrations affect every site at once, and a bug at one site can leak data across sites. Rejected for v1; revisit if and only if more than five sites adopt and operational overhead becomes painful. +2. **Hybrid: per-site DB but central app server.** Adds the operational complexity of multi-tenancy without isolating the failure domain (one app crash = all sites down). Rejected. + +## Migration strategy (resolved) + +Deploys run two commands: `flask db upgrade` then `flask plugin upgrade-all` +(lean/ADR-014 sites add an optional `flask plugin prune-schema` at initial +provisioning). The core Alembic chain applied by `flask db upgrade` creates the +full core AND bundled-plugin schema through the chain head (this includes +migration `7c04_fold_plugin_schema`). But every bundled plugin still carries its +own Alembic chain per ADR-008: `flask plugin upgrade-all` stamps each plugin's +own chain (the `alembic_version_` tables) and applies any plugin-specific +migrations added after the ownership cutover. The earlier Phase 7B state that +folded everything into core with no per-plugin chains was superseded by ADR-008's +per-plugin ownership. A fresh `flask db upgrade` reproduces the live core schema +exactly (verified on a scratch DB). + +External (out-of-tree) plugins per ADR-003 ship their own migrations too; the +framework runs the same per-plugin chain mechanism (ADR-008) for both bundled and +external plugins. + +## Open questions + +- Should the framework provide an optional **read-only fleet roll-up** mode where a "central" instance can pull aggregate metrics from each site's API? Defer. Out of scope for v1. +- Backup strategy per site: framework recommendation, or each site decides? Framework should publish a recommended backup runbook (mysqldump + offsite copy) but not enforce. +- Auth federation: each site has its own user table, or sites can share an LDAP / SSO? Recommend documenting the LDAP config knob in `.env.example` so sites can plug in their own auth without code change. + +## References + +- `shopdb/config.py` (currently single-tenant, no `siteid`) +- ADR-001 (asset model is per-site, not cross-site) +- ADR-003 (plugin distribution per site) diff --git a/docs/adr/ADR-005-equipment-vs-measuringtools.md b/docs/adr/ADR-005-equipment-vs-measuringtools.md new file mode 100644 index 00000000..b76b6163 --- /dev/null +++ b/docs/adr/ADR-005-equipment-vs-measuringtools.md @@ -0,0 +1,163 @@ + + +# ADR-005: Equipment plugin scope vs measuringtools plugin + +- **Status:** SUPERSEDED by [ADR-018](ADR-018-metrology-instruments-are-machines.md) +- **Date:** 2026-05-08 +- **Deciders:** ShopDB maintainers +- **Supersedes:** none + +> **Do not build against this.** ADR-018 (2026-09-04) retired the +> `measuringtools` plugin and made metrology instruments machines. The split +> below was sound for the population it described, and that population never +> arrived: the plugin held zero rows. Kept as the record of why the split was +> made and why it was undone. + +## Context + +ADR-001 narrowed the migration to physical manufacturing equipment with a `machinenumber`. In practice, the legacy `category='Equipment'` rows contain two distinct asset classes: + +1. **Manufacturing machinery** (5-axis mills, lathes, broachers, heat treatment ovens). These produce parts. +2. **Metrology and inspection instruments** (CMMs, Keyence vision systems, wax-and-trace surface profilometers, GenSpec instruments). These measure parts. + +Both share `Asset` properties (vendor, model, location, controller). They differ in domain fields (axes vs measurement accuracy, cycle time vs calibration interval, controller protocol vs measurement software). + +Mixing them under one plugin pollutes the schema and confuses cross-plugin queries ("show me all measuring tools" requires an enumeration of measuring-instrument equipmenttype values, which scales badly). + +## Decision + +Two plugins, separate concerns, shared platform contract. + +### `equipment` plugin + +Tracks manufacturing machinery. Bundled, in-tree. + +Schema (per ADR-001 contract): + +``` +equipment +- assetid FK to assets, PK +- equipmenttypeid FK to equipmenttypes (5-axis mill, lathe, broacher, heat treat, ...) +- vendorid FK to vendors (platform) +- modelid FK to models (platform) +- controllertypeid FK to controllertypes (equipment plugin) +- controllerosid FK to controlleros (equipment plugin) +- (other shared fields: spindle count, axes, max workpiece size, ...) + +equipmenttypes (lookup, equipment plugin) +- equipmenttypeid, name (5-axis mill, lathe, broacher, heat treat, ...) + +controllertypes (lookup, equipment plugin) +- controllertypeid, name (Fanuc 31i, Siemens 840D, Mitsubishi M70, Heidenhain TNC640, ...) +- vendorid (FK to vendors) + +controlleros (lookup, equipment plugin - separate from PC OS) +- controllerosid, name (FAPT, VxWorks, embedded Windows, Linux RT, ...) + +equipmentfocas (subtype, optional, present only when FOCAS-equipped) +- assetid PK, FK to equipment +- focasipaddress text +- focasport integer +- focasversion text +- focasmachinenumber text + +equipmentclm (subtype, optional, present only when CLM-equipped) +- assetid PK, FK to equipment +- (CLM-specific: address, port, station ID - finalize when plugin is built) + +equipmentmtconnect (subtype, optional, present only when MTConnect-equipped) +- assetid PK, FK to equipment +- mtconnectagenturl text +- mtconnectdevicename text +``` + +The `equipment.protocol` enum field is deliberately **not** included. Presence or absence of a subtype row indicates which protocol applies. Avoids a denormalized field that can drift out of sync. + +### `measuringtools` plugin + +Tracks metrology and inspection instruments. Bundled, in-tree (built in Phase 3-4 of the refactor as the first new plugin built using the framework scaffold). + +Schema (initial draft, refined when plugin is built): + +``` +measuringtools +- assetid FK to assets, PK +- measuringtooltypeid FK to measuringtooltypes (CMM, vision system, profilometer, surface tester, ...) +- vendorid FK to vendors (platform) +- modelid FK to models (platform) +- measurementaxes integer (e.g., 3 for a 3-axis CMM) +- accuracyspec text (e.g., "+/-0.5um") +- calibrationintervaldays integer +- lastcalibrationdate date +- nextcalibrationdate date (computed) +- (other domain fields as needed) + +measuringtooltypes (lookup, measuringtools plugin) +- measuringtooltypeid, name (CMM, vision system, surface profilometer, gage block, ...) +``` + +Future extension: subtype tables for measurement-software integrations (PC-DMIS, Keyence, GenSpec). Same pattern as equipment subtype tables. + +### Subtype-table pattern (general) + +Both plugins use the same pattern for protocol- or software-specific fields: + +- Core plugin table carries shared, common fields +- Optional subtype tables (one per protocol or software) hold extension fields +- Each subtype table is keyed by `assetid` (PK), one-to-one with the parent +- Subtype row exists if and only if the asset uses that protocol or software +- Sister sites add new subtype tables for their own integrations without touching core + +## Reclassification of legacy data + +ADR-001's migration moves all legacy `category='Equipment' AND machinenumber IS NOT NULL` rows to `assets` with `assettype='Equipment'` and into the equipment plugin's `equipment` table. This includes both manufacturing machinery and measuring tools. + +After the equipment migration, when the measuringtools plugin is built: + +1. Build a mapping table: legacy `machinetypeid` values that are measuring tools (CMM type, Keyence type, etc.) +2. Run a reclassification script: + - For each `assets` row where the original `machinetypeid` is in the measuring-tool mapping + - Change `assets.assettype` to `'MeasuringTool'` + - Move the row from `equipment` to `measuringtools` + - Map domain fields where they differ (e.g., legacy `axes` field maps to `measurementaxes`) +3. Verify counts pre- and post-reclassification +4. Audit log entry per reclassified row + +Reclassification is one-shot, run once, archived. Like the original migration script. + +## Consequences + +### Positive + +- Manufacturing machinery and measuring tools are first-class plugins, each with appropriate domain fields +- Sister sites can install one or both depending on what they track +- Subtype-table pattern is the canonical example for protocol-specific data and extends naturally to other plugins +- Building `measuringtools` mid-refactor validates the plugin scaffold tooling against a real new plugin + +### Negative + +- Reclassification is a second migration step. Lower risk than the initial migration because it is data-only (no schema change beyond moving rows between two tables that share the same `assetid` link). +- Sites that adopt the framework before `measuringtools` ships need to either keep measuring tools in `equipment` (workable but suboptimal) or wait for the plugin + +### Neutral + +- Legacy `machinetypeid` is preserved on the equipment row during migration to enable reclassification + +## Alternatives considered + +1. **Single equipment plugin with sub-typed assets.** Use `equipment.equipmenttypeid` to discriminate manufacturing vs metrology. Rejected: domain fields differ enough that a single table is wide and full of NULLs. +2. **Migrate split (build mapping before initial migration).** Cleaner end state but requires the `measuringtools` plugin to exist before the migration runs, which delays Phase 5. Rejected. +3. **JSON blob for protocol data instead of subtype tables.** Considered for both plugins. Rejected: weak typing, awkward queries, no schema validation. + +## References + +- ADR-001 (Asset is platform contract) +- ADR-002 (versioning of the surface) +- `plugins/machines/` (equipment plugin, renamed per ADR-011) +- `plugins/computers/` (existing example of plugin pattern) diff --git a/docs/adr/ADR-006-collector-contract.md b/docs/adr/ADR-006-collector-contract.md new file mode 100644 index 00000000..0cf0eebf --- /dev/null +++ b/docs/adr/ADR-006-collector-contract.md @@ -0,0 +1,156 @@ + + +# ADR-006: Plugin collector contract pattern + +- **Status:** ACCEPTED +- **Date:** 2026-05-08 +- **Deciders:** ShopDB maintainers +- **Supersedes:** none + +## Context + +PC inventory data was collected by PowerShell scripts pushing to `/api/collector/pc` (`shopdb/core/api/collector.py`, ~374 LOC). The endpoint is hardcoded for PCs: it accepts a fixed schema and writes to the legacy `Machine` model. + +Per ADR-001, `Machine` is being retired in favor of `Asset`. Per the project shift to PXE-driven imaging, PC inventory is moving to a new collection pipeline (PXE / GE-Enforce / manifest engine produces JSON about each PC). Other asset classes may want similar collector pipelines (printers via Zabbix, network gear via SNMP scan). + +This calls for a generalizable contract: any plugin that wants to accept external collector input declares a JSON schema, and the framework wires the endpoint, auth, and idempotency. + +## Decision + +`BasePlugin` gets two new hooks (added in __contract_version__ 0.2.x -> the surface is carried at 0.3.0): + +```python +def get_collector_schema(self) -> Optional[dict]: + """Return JSON Schema describing the collector payload for this plugin. + Return None if the plugin does not accept collector input. + + The schema must include: + - 'identityfield': name of the field that uniquely identifies an asset + across submissions (e.g., 'hostname' for PCs, 'macaddress' for network + devices). Used for idempotent upsert. + - 'fields': JSON Schema definitions for the rest of the payload. + """ + return None + +def apply_collector_payload(self, payload: dict) -> dict: + """Idempotently upsert an asset from a validated collector payload. + + Called by /api/collector/ after identity validation. + CONDITIONAL hook: required only when get_collector_schema returns + non-None. Default raises NotImplementedError (the dispatcher returns + 500) so a schema-without-upsert fails loud. Returns a dict with at + least 'action' ('created'|'updated'|'noop'), 'assetid', 'warnings'. + """ + raise NotImplementedError +``` + +The pairing (schema present => apply implemented) is enforced by the +`test_schema_declaring_plugins_implement_apply` contract test. + +A single dynamic dispatch route `/api/collector/` serves every +plugin that returns a schema (rather than registering a blueprint per plugin), +because Flask forbids `register_blueprint` after the first request and plugins +can be enabled at runtime. Auth is API-key, separate from JWT. Per-plugin keys via env vars: + +- `COLLECTOR_API_KEY_` (preferred, plugin-specific) +- `COLLECTOR_API_KEY` (fallback, shared) + +### Idempotent upsert + +The endpoint uses the `identityfield` to find an existing `Asset` for the same identity. Found = update. Not found = insert. Existing relationships are preserved on update. + +### Response contract + +```json +{ + "status": "ok", + "action": "created" | "updated" | "noop", + "assetid": 12345, + "identityvalue": "PC-1234", + "warnings": [] +} +``` + +### Audit logging + +Every collector submission produces an audit log entry: `{action, plugin, identityvalue, before/after diff}`. Audit retention per site policy. + +### Schema discovery + +The framework exposes the registered schemas at `/api/collector/_schemas` (read-only, JWT-protected) so external collector authors can introspect what payloads are accepted by which plugins. + +## Concrete first user: computers plugin + +The `computers` plugin is the first to implement `get_collector_schema`. The PXE pipeline conforms. + +Initial computers collector schema (sketch, finalized when plugin is built): + +```json +{ + "identityfield": "hostname", + "fields": { + "hostname": "string, required", + "macaddress": "string, optional, secondary identity", + "osname": "string", + "osversion": "string", + "lastboottime": "datetime", + "currentuser": "string", + "ipaddress": "string", + "memorygb": "number", + "cputype": "string", + "imagename": "string (PXE image deployed)", + "imageappliedat": "datetime", + "installedsoftware": "array of {name, version}" + } +} +``` + +The PC re-image case is handled by the identity field: a freshly imaged PC keeps its hostname, so the existing `Asset` row is updated rather than duplicated. Existing `AssetRelationship` rows pointing at that PC (e.g., `controls` to a machine) are preserved across re-images. + +## Migration of the existing endpoint + +`shopdb/core/api/collector.py` (`/api/collector/pc`) is **deprecated** in v1 and **removed** before v1.0. + +Migration path: + +1. Implement `get_collector_schema` on the `computers` plugin. New endpoint `/api/collector/computers` is auto-registered. +2. Run both endpoints in parallel for one cycle of PXE imaging across the floor. PXE pipeline switches to `/api/collector/computers`. +3. Remove `shopdb/core/api/collector.py` and the legacy blueprint registration. + +## Consequences + +### Positive + +- Generalizable across plugins. Sister sites adopting `printers`, `network`, etc. can wire their own collectors with no core change. +- Identity-based idempotency makes PC re-imaging safe by default. +- Audit logging is uniform across plugins. +- Schema discovery enables external tools to validate before submission. + +### Negative + +- Plugin authors must write a JSON schema. Slight learning curve, but JSON Schema is widely understood and the framework can ship a few examples. +- The `/api/collector/_schemas` endpoint plus per-plugin endpoints expand the public API surface; minor maintenance cost. + +### Neutral + +- API-key auth pattern stays as it is today (separate from JWT). Sites manage their own collector keys per plugin via env vars. + +## Alternatives considered + +1. **Keep `/api/collector/pc` and add new plugin-specific endpoints alongside.** Two ways to send PC data, plugin authors confused. Rejected. +2. **Use JWT for collectors instead of API key.** Collectors are headless processes (PXE pipeline, scripts), not interactive users. JWT lifecycle (refresh tokens, expiry) is the wrong tool. API key is simpler. Rejected. +3. **Plugins write directly to the database, no collector endpoint.** Skips audit logging and schema validation. Rejected. + +## References + +- `shopdb/core/api/collector.py` (legacy endpoint to be removed) +- `shopdb/plugins/base.py` (`get_collector_schema` + `apply_collector_payload` hooks) +- ADR-001 (asset model the collectors target) +- ADR-002 (collector schema is part of plugin contract; changes to the hook signature are major bumps) +- The PXE project (the PXE imaging project) which feeds the computers collector diff --git a/docs/adr/ADR-007-product-versioning-and-releases.md b/docs/adr/ADR-007-product-versioning-and-releases.md new file mode 100644 index 00000000..ddfb536c --- /dev/null +++ b/docs/adr/ADR-007-product-versioning-and-releases.md @@ -0,0 +1,123 @@ + + +# ADR-007: Product versioning and releases + +- **Status:** ACCEPTED +- **Date:** 2026-07-10 +- **Deciders:** ShopDB maintainers +- **Supersedes:** none + +## Context + +ADR-002 established semantic versioning for the plugin contract via +`__contract_version__` in `shopdb/__init__.py`. That number answers one +question only: is a given plugin compatible with this platform build. It +says nothing about the state of the product as a whole. A sister site +standing up its own instance needs a different answer: which release am I +running, and what changed since the last one. + +Until now the product had no version, no tags, no changelog, and no CI. +ADR-002's pinning model implicitly assumes that a downstream site can +pin a known-good build, but there were no tags to pin to. Adopters had no +release record to read before upgrading, and no automated gate confirming +that a given commit builds and passes tests. + +## Decision + +The product carries its own release version, separate from the plugin +contract version. + +1. **Product version** (`shopdb/__init__.py`): a single `__version__` + constant. This is the version of the shopdb-flask product as a whole. + It follows semantic versioning of the product's user-visible and + operator-visible behavior. It is deliberately NOT part of the + `shopdb.api` contract surface, so it is never re-exported through + `shopdb.api`; exporting it would itself be a contract change under + ADR-002. + +2. **Plugin contract version** (`__contract_version__`): unchanged from + ADR-002. It moves only when the plugin contract surface changes, per + the major/minor/patch rules in ADR-002. + +The two are distinct series with independent bump rules. They happen to +coincide at `0.5.0` for this release; that is a coincidence of timing, +not a coupling. A product release that changes no contract surface bumps +`__version__` while leaving `__contract_version__` fixed, and vice versa. + +3. **Release record** (`CHANGELOG.md`, repo root): the canonical, + human-readable record of what changed in each release, in + Keep-a-Changelog format. Every release has an entry; work in flight + accumulates under `## [Unreleased]`. + +4. **Git tags**: each product release is tagged `vX.Y.Z`, where `X.Y.Z` + matches `__version__` at the tagged commit. Tags are what ADR-002's + downstream-pinning model pins to. + +5. **Frontend version** (`frontend/package.json`): kept in lock-step with + `__version__` so the shipped single-page app reports the same product + version as the backend that serves it. + +### Release procedure + +To cut release `X.Y.Z`: + +1. Bump `__version__` in `shopdb/__init__.py` to `X.Y.Z`. +2. Bump `version` in `frontend/package.json` to the same `X.Y.Z`. +3. Move the accumulated `## [Unreleased]` notes in `CHANGELOG.md` into a + new `## [X.Y.Z] - YYYY-MM-DD` section, leaving a fresh empty + `## [Unreleased]` above it. +4. If the plugin contract surface changed this release, bump + `__contract_version__` per ADR-002 (independently of `__version__`). +5. Commit, then tag: `git tag -a vX.Y.Z -m "shopdb-flask X.Y.Z"`. +6. Push the commit and the tag. + +## Consequences + +### Positive + +- Adopters have a real release to name in bug reports and a changelog to + read before upgrading. +- ADR-002's pinning story finally has tags to pin to. +- Product and contract can evolve at their own pace without one dragging + the other into a misleading bump. + +### Negative / cost + +- Two version numbers to keep straight. The comment in + `shopdb/__init__.py` and this ADR exist to keep the distinction clear. +- Release discipline: the changelog and both version constants must be + updated together, or the tagged build misreports itself. + +### Neutral + +- The internal CI workflow runs the backend tests, the naming/style + gate, and the frontend build on push and PR. It is best-effort: the internal CI + Actions availability on the host is unverified, so the workflow is + config-only until a runner is confirmed. + +## Alternatives considered + +1. **Reuse `__contract_version__` as the product version.** Conflates two + independent concerns; a docs-only or UI-only release would either + falsely bump the contract or leave the product looking unchanged. + Rejected. +2. **Calendar versioning for the product** (e.g. `2026.07.0`). Easy to + bump but poor at signaling breaking operator-facing changes. Rejected + for the same reasons ADR-002 rejected it for the contract. +3. **No product version, rely on git SHAs.** Opaque to adopters and + unpinnable in any human-meaningful way. Rejected. + +## References + +- ADR-001 (defines the contract surface) +- ADR-002 (plugin contract versioning; `__contract_version__` bump rules) +- `shopdb/__init__.py` (`__version__`, `__contract_version__`) +- `CHANGELOG.md` (release record) +- `frontend/package.json` (frontend version, kept in lock-step) +- the internal CI workflow (CI gate) diff --git a/docs/adr/ADR-008-plugin-migration-ownership.md b/docs/adr/ADR-008-plugin-migration-ownership.md new file mode 100644 index 00000000..65c20fad --- /dev/null +++ b/docs/adr/ADR-008-plugin-migration-ownership.md @@ -0,0 +1,131 @@ + + +# ADR-008: Plugin migration ownership (per-plugin chains from the cutover) + +- **Status:** ACCEPTED +- **Date:** 2026-07-10 +- **Deciders:** ShopDB maintainers +- **Supersedes:** the "Migration strategy (resolved)" section of ADR-004 + +## Context + +ADR-004 resolved a Phase 7B footgun by folding every bundled plugin's tables +into the single core Alembic chain (migration `7c04_fold_plugin_schema`), and +later core migrations (`7d04`..`7d16`) kept adding plugin schema directly to the +core chain. At the time this was the safe choice: bundled-plugin baselines and +the core baseline had both been creating the same tables, so +`flask plugin upgrade-all` would collide with `flask db upgrade`. + +That resolution left the per-plugin Alembic engine +(`shopdb/plugins/migrations.py`, `shopdb/plugins/alembic_template.py`, the +`alembic_version_` version tables, and `flask plugin upgrade-all`) fully +built but unused for the bundled plugins. The framework is the product (per the +project's charter); a plugin that cannot own its own schema is not really a +plugin. Sister sites that adopt or fork a plugin need its schema history to +travel with the plugin, not be entangled in the host's core chain. Keeping every +future plugin table change in the core chain also means the core chain grows +without bound and a plugin can never be cleanly removed. + +The blocker ADR-004 worried about (double table creation) only exists while a +plugin's own migration tries to CREATE tables the core chain already created. +That is avoidable: history is immutable, so the tables already built by the core +chain stay owned by the core chain; only NEW schema needs a new home. + +## Decision + +Ownership splits at a fixed cutover, the current core-chain head. + +1. **Core chain owns history through its head.** The core Alembic chain + (baseline `68b3947ae14f` .. head `7d16_directoryemployees`) remains + authoritative for every table that exists at the cutover, including the + bundled-plugin tables it created. Those migrations are immutable and are not + rewritten. `flask db upgrade` continues to reproduce the full schema. + +2. **Plugin chains own plugin schema going forward.** From the cutover forward, + any change to a plugin's schema lands as + `plugins//migrations/versions/000N_*.py` in that plugin's own chain, + never in the core chain. The core chain is reserved for core tables. + +3. **Every table-owning bundled plugin gets a `0001` anchor.** Each such plugin + carries a migration chain whose first revision is a stamp-only no-op: + `upgrade()` does nothing because the core chain already created the tables. + The anchor exists so the plugin chain has a base that + `flask plugin upgrade-all` can stamp into the per-plugin version table + `alembic_version_`. Blueprint-only plugins that own no tables get no + chain. + +4. **Deploy and upgrade sequence.** A deploy runs `flask db upgrade` + (core chain, creates everything through the head) then + `flask plugin upgrade-all` (stamps every plugin anchor and applies any later + per-plugin migrations). The same two commands upgrade an existing install; + both are idempotent. The registry (`instance/plugins.json`) tracks each + plugin's applied revisions in `migrations_applied`. + +5. **Table-ownership registry.** `PLUGIN_TABLE_OWNERS` in + `shopdb/plugins/alembic_template.py` is the explicit map of which tables each + plugin owns; it is kept in sync with the plugins' `__tablename__` declarations + and pinned by `tests/test_plugin_migrations.py`. + +External (out-of-tree) plugins per ADR-003 already shipped their own chains; +this ADR brings the bundled plugins onto the same model, so there is one rule +for all plugins. + +## Consequences + +### Positive + +- A plugin's schema history travels with the plugin. Adopting or forking sites + get the plugin's migrations, not a slice of someone else's core chain. +- The core chain stops accreting plugin schema; it stays about core tables. +- A plugin can be evolved (or, with its own downgrade, removed) independently. +- The long-built per-plugin Alembic engine is finally exercised on every deploy, + so it cannot silently rot. + +### Negative / cost + +- Two migrate commands per deploy instead of one. Documented in `docs/DEPLOY.md` + and `docs/UPGRADE.md`; both are idempotent so the cost is one extra safe call. +- A plugin author must now put new tables in the plugin chain and register them + in `PLUGIN_TABLE_OWNERS`, rather than autogenerating into the core chain. The + `flask plugin new` guidance and this ADR spell that out. +- The cutover is a discontinuity: tables created before it are core-owned, + tables created after it are plugin-owned. The line is the core-chain head at + this ADR's date, recorded here so it is unambiguous. + +### Neutral + +- No schema changes and no data migration: the anchors are no-ops. A fresh + install and an existing install converge to the same state. + +## Alternatives considered + +1. **Keep everything in the core chain (status quo per ADR-004).** Simplest + operationally but defeats the plugin-as-product goal: plugin schema cannot + travel, the core chain grows without bound, and plugins can never be cleanly + removed. Rejected. +2. **Rewrite history so plugin tables move out of the core chain into plugin + `0001` CREATE migrations.** Would make each plugin chain self-contained from + empty, but breaks the immutability rule, forces every existing site to + re-run a rewritten chain, and re-introduces the exact double-creation footgun + ADR-004 fixed. Rejected. +3. **Anchor that CREATEs tables with `IF NOT EXISTS` guards.** Lets a from-empty + install build plugin tables from the plugin chain, but then two chains both + claim the same tables and drift can diverge silently. The no-op anchor keeps + a single authoritative creator (the core chain) for cutover-era tables. + Rejected. + +## References + +- ADR-003 (plugin distribution; external plugins already ship chains) +- ADR-004 (deployment topology; this ADR supersedes its migration-strategy note) +- `shopdb/plugins/alembic_template.py` (`PLUGIN_TABLE_OWNERS`, shared env runner) +- `shopdb/plugins/migrations.py`, `shopdb/plugins/cli.py` (`upgrade-all`) +- `plugins//migrations/` (per-plugin chains and `0001` anchors) +- `tests/test_plugin_migrations.py` (ownership + chain + idempotency guards) +- `docs/DEPLOY.md`, `docs/UPGRADE.md`, `docs/PLUGINS.md` (deploy sequence) diff --git a/docs/adr/ADR-009-frontend-plugin-gating.md b/docs/adr/ADR-009-frontend-plugin-gating.md new file mode 100644 index 00000000..093b5ed8 --- /dev/null +++ b/docs/adr/ADR-009-frontend-plugin-gating.md @@ -0,0 +1,156 @@ + + +# ADR-009: Frontend plugin gating + +- **Status:** ACCEPTED +- **Date:** 2026-07-10 +- **Deciders:** ShopDB maintainers +- **Supersedes:** none + +## Context + +The backend plugin system (ADR-002, ADR-003) lets an operator disable a +plugin. Disabling it unregisters the plugin's API blueprint, drops its +rows from global search (see `test_search_disabled`), and removes its +entry from `get_navigation_items()`, so the disabled feature's sidebar +link disappears. + +The frontend told a different story. Every plugin's Vue routes and views +ship inside the core bundle. `frontend/src/router/index.js` auto-discovers +them with `import.meta.glob('./routes/*.js')`, so a route like `/usb` or +`/printers/3` is registered regardless of whether the owning backend +plugin is enabled. A user who typed the URL, followed a stale bookmark, +or clicked a cross-link reached a page whose API calls all 404, landing +on a broken shell instead of a clean redirect. The navigation was already +data-driven; direct-URL reachability was the gap. + +There is no frontend plugin system yet. The product is packaged as one +core Flask app plus backend-only plugins; the Vue app is monolithic and +build-time static. Any gating has to live in core frontend code because +that is the only place the plugin routes exist. + +## Decision + +### Step 1 (this ADR, implemented): route-level gating + +Plugin-owned frontend routes are gated against the backend's enabled-plugin +list. This is the whole of what ships now. + +1. **Enabled-list endpoint.** A new `GET /api/plugins/enabled` + (`jwt_required(optional=True)`) returns a flat JSON array of enabled + plugin name strings and nothing else. It is a cheap registry read + (`registry.get_enabled_plugins()`), no database access. Exposing it to + anonymous callers is safe because `GET /api/dashboard/navigation` + already leaks the same enabled/disabled signal, and unauthenticated + kiosk routes (`/tv`) need the answer too. It carries no metadata, so it + reveals strictly less than the equally-anonymous but metadata-carrying + `GET /api/plugins` (also `jwt_required(optional=True)`; only the + `PUT /api/plugins/` toggle is admin-gated). + +2. **Route tagging.** Every plugin-owned route carries `meta.plugin = + ''`. This covers the per-plugin route modules + (`routes/computers.js`, `routes/equipment.js`, ...) and the + plugin-owned routes that physically live in core files: the + PC-relationships and toner reports, the slide manager and `/tv` + dashboard (slides), the printer-QR and USB-label print pages, and the + employee-detail page. Genuinely core routes (dashboard, search, map, + applications, reference-data settings) stay untagged and are never + gated. + +3. **Cached fetch, fail-open.** A composable + (`composables/enabledPlugins.js`) fetches the list exactly once behind + a cached promise. If the fetch fails or returns a non-array, the code + fails **open**: every plugin is treated as enabled. A transient API + error must never brick navigation. The cost is that a disabled + plugin's page is briefly reachable during an outage, which is + acceptable because its API calls would fail anyway and the next + successful fetch closes the gap. + +4. **Router guard.** `router.beforeEach` awaits the cached fetch when the + target route has `meta.plugin`. If that plugin is not enabled it + redirects to `/` and raises an info toast. The endpoint is + jwt-optional, so the guard works for both authenticated pages and the + unauthenticated `/tv` kiosk route. + +This is intentionally a thin layer. It does not change the plugin +contract surface, so `__contract_version__` does not move: adding a core +HTTP endpoint and tagging core-shipped routes are not plugin-contract +changes. Plugins still ship no frontend code of their own. + +### Non-goals for step 1 + +- No build-time or runtime loading of plugin-authored Vue code. +- No per-permission or per-role route gating (that stays with the + existing `requiresAuth` / `requiresAdmin` meta flags). +- No removal of disabled routes from the route table; they remain + registered and are intercepted by the guard. Keeping them registered + avoids a rebuild when a plugin is toggled and keeps the redirect path + simple. + +## Future direction (PROPOSED, not implemented) + +Step 1 gates routes that core already owns. The longer-term goal is a +real frontend-plugin contract where a plugin ships its own frontend and +core discovers it, mirroring the backend model. Sketch: + +1. **Plugin-owned frontend tree.** Each plugin gains a + `plugins//frontend/` directory holding its route module, views, + and any plugin-specific components. Core stops carrying `views/usb`, + `views/printers`, and so on. + +2. **Build-time discovery.** The Vite build discovers plugin frontends + with a glob over `plugins/*/frontend/routes.js` (analogous to today's + `import.meta.glob('./routes/*.js')`), so a plugin's presence in the + tree is what puts its routes in the bundle. Combined with the step-1 + enabled-list gate, a plugin that is absent from the build ships no + code and a plugin that is present-but-disabled is route-gated at + runtime. + +3. **Shared component + registration contract.** Plugins register into + named extension points instead of editing core files: an `iconMap` + registration for nav/asset icons, asset-detail panels, map-marker + renderers, and search-result renderers (the "Frontend hook contract" + already listed as deferred in docs/ROADMAP.md). Core exposes a + stable set of shared components (form controls, detail-page shells, + table primitives) as the plugin frontend's only allowed core imports, + the frontend analogue of the `shopdb.api` namespace. + +4. **Versioned frontend contract.** The shared-component and + registration surface would be versioned the same way the backend + contract is (ADR-002), so a plugin frontend can declare the core + frontend range it needs. + +### Tradeoffs of the future direction + +- **Pro:** true plugin self-containment; a site can drop in or remove a + plugin (frontend and backend together) without patching core; smaller + core; clearer ownership. +- **Con:** significant build-system work (per-plugin Vite entry + discovery, code-splitting, dev-server HMR across the plugin tree); a + new versioned frontend contract to maintain and document; a migration + that moves ten plugins' worth of views out of core; risk of a leaky + shared-component surface becoming an accidental contract. The payoff + only matters once external (out-of-tree) plugins with their own + frontends are a real requirement. Until then, step 1's route gating + delivers the user-visible correctness (no reachable dead pages) at a + fraction of the cost. + +## Consequences + +- Disabling a backend plugin now makes its frontend routes redirect to + the dashboard instead of loading a broken shell. Behavior matches the + already-dynamic navigation. +- One extra lightweight request at app boot (`GET /api/plugins/enabled`), + cached for the session. +- Fail-open means gating is a UX guardrail, not a security control. It is + not a substitute for backend authorization: the API still enforces auth + and the disabled plugin's endpoints are simply unregistered. Never rely + on route gating to protect data. +- The future frontend-plugin contract remains open work; this ADR records + the direction and its cost so a later decision can pick it up. diff --git a/docs/adr/ADR-010-frontend-plugin-hooks.md b/docs/adr/ADR-010-frontend-plugin-hooks.md new file mode 100644 index 00000000..8331b8f2 --- /dev/null +++ b/docs/adr/ADR-010-frontend-plugin-hooks.md @@ -0,0 +1,326 @@ + + +# ADR-010: Frontend plugin hook contract + +- **Status:** ACCEPTED +- **Date:** 2026-07-11 +- **Accepted:** 2026-07-11 +- **Deciders:** ShopDB maintainers +- **Supersedes:** none + +## Context + +The backend plugin contract is settled (ADR-002 surface, ADR-006 collector, +ADR-008 migrations, ADR-009 route gating). The one undefined piece before 1.0 is +the frontend: how a plugin contributes UI without hand-editing core Vue files. +CONTRACT-STABILITY.md names this the single biggest churn item ("no server-side +hook for asset-detail panels, map markers, or search-result rendering; a plugin +that needs custom UI still hand-edits the Vue frontend"). + +The `measuringtools` plugin (bundled 2026-07-11, whose construction is narrated +in docs/PLUGIN-GUIDE.md) is the fresh evidence. Integrating it needed exactly +**two hand edits to core frontend files**: + +1. `frontend/src/api/index.js` - an api-client block appended after + `warrantyApi` (PLUGIN-GUIDE.md section 9). The scaffolder now emits a + paste-in snippet, but it is still a hand edit to a shared, churn-heavy file. +2. `frontend/src/views/settings/settingsNav.js` - a card entry in the hardcoded + `settingsGroups` catalog (the "Measuring Tools" group, ~line 51), consumed by + `SettingsLayout` (left rail) and `SettingsIndex` (landing overview). + +Everything else integrated with **zero** core edits, because a mechanism already +existed for it: + +| Capability | Existing mechanism | +|---|---| +| Views / routes | Auto-discovery: `frontend/src/router/index.js` globs `router/routes/*.js` via `import.meta.glob`; a route starting `settings/` auto-nests under the settings shell. | +| Disabled-plugin gating | `meta: { plugin: '' }` per ADR-009; router guard redirects when the backend plugin is off. | +| Sidebar / dashboard / reports | Declarative backend hooks rendered by core: `get_navigation_items`, `get_dashboard_widgets`, `get_reports` on `BasePlugin` (shopdb/plugins/base.py), merged by `shopdb/core/api/dashboard.py` and `shopdb/core/api/reports.py`. | +| Map placement | Data-driven off asset types + resolved positions (ADR-001); a typed, positioned asset appears on the map with its type color and no plugin-side map code. | + +Capabilities a plugin **cannot have at all today** - no hook exists, and adding +one would mean forking a core view: + +3. **Asset-detail extension panels.** A warranty-coverage section on a PC or + printer detail page, a calibration-status card on a measuring tool. Today + `WarrantyPanel.vue` is composed in by hand-editing each detail view. +4. **Map marker / overlay contributions.** A calibration-due badge on the + shop-floor map (`frontend/src/views/MapView.vue`). The map draws type + colors but has no plugin decoration path. +5. **Search-result rendering / routing for plugin asset types.** Global search + returns assets, but core hardcodes how each type renders and where its detail + link points; a plugin asset type has no way to declare its icon or route. + +There is also an unsolved distribution wrinkle from ADR-003: external plugins +symlink into `plugins//` backend-only, but any frontend file they carry +must be physically copied into `frontend/src/`, because Vite compiles the tree +at build time and cannot reach outside it. + +## Options considered + +### A. Runtime dynamic component registration + +Plugins ship real Vue components that core loads and mounts at runtime into +named extension points (an `iconMap` registration, a panel registry, a marker +renderer registry). This is the richest model and the one ADR-009's "Future +direction" sketched. + +- Pro: a plugin can render anything; no core generic-renderer ceiling. +- Con: requires runtime module loading of plugin-authored code (dynamic import + of built chunks), a versioned shared-component surface that becomes an + accidental contract the moment it leaks, and it does nothing about the + build-time-only reach of Vite for external plugins. It is the most code for + the least near-term payoff. ADR-009 already priced this and deferred it. + +### B. Declarative data-only hooks rendered by core generic components + +Plugins return plain dicts from new `BasePlugin` hooks; a generic core component +renders them. This is the exact precedent already proven three times: +`get_navigation_items`, `get_dashboard_widgets`, and `get_reports` (0.6.0) all +return dicts that a core consumer endpoint merges and a core Vue component +renders. No plugin ships frontend code. + +- Pro: additive, minor-bump changes under ADR-002; identical access pattern to + the existing consumers (skip disabled, fail-loud in dev, isolate in prod); + works untouched for external symlink-only plugins, because the data crosses + the wire and core owns the renderer. +- Con: bounded to what a generic renderer can draw. A panel that needs a bespoke + chart or a custom-interaction map overlay does not fit. + +### C. Build-time file-convention discovery + +Extend the `import.meta.glob` precedent from `./routes/*.js` to a plugin-owned +frontend tree (`plugins/*/frontend/`), so a plugin's real components (full +list/detail/form views, its api-client module) live with the plugin and the +build picks them up by convention. This is ADR-009 "Future direction" steps 1-2. + +- Pro: true self-containment for genuine components; the natural home for the + api-client block (friction 1) and full views. +- Con: significant build-system work (per-plugin Vite entry discovery, + code-splitting, dev-server HMR across the tree) and it does not by itself + solve the external-plugin wrinkle - a symlinked out-of-tree `frontend/` is + still outside Vite's compiled root. Only pays off once out-of-tree plugins + with frontends are a real requirement. + +### Hybrid + +The evidence splits cleanly. The five friction points fall into two buckets: +surfaces where a generic renderer fed by plugin data is sufficient (2, 3, 4, 5), +and surfaces where a real component is genuinely unavoidable (full views, and +the api-client module in friction 1). Option B fits the first bucket exactly and +is cheap and additive. Option C is the right long-term answer for the second but +is expensive and, per ADR-009, gated on external-plugin demand. Option A buys +nothing B does not, at the highest cost. The decision is B now, C deferred, A +not pursued. + +## Decision + +**DECISION:** adopt a hybrid. Add **data-only declarative hooks** (Option B) for +the four presentation surfaces a generic core renderer can serve, and keep +**file-convention glob discovery** (Option C) as the deferred mechanism for the +residual cases where a real component is unavoidable. Do not pursue runtime +component registration (Option A). + +### New hooks (Option B, data-only, additive) + +Each is a new optional `BasePlugin` method returning a list of dicts, merged by +a core consumer endpoint using the same pattern as `get_reports` (inject +`plugin` name, skip disabled, re-raise in dev/test, log-and-isolate in prod), +and rendered by a generic core component. Icon values are string keys mapped to +Lucide components core-side, exactly like `get_navigation_items`. + +**`get_settings_cards`** - resolves friction point 2 (settingsNav.js hand edit). + +``` +{ + 'group': 'Measuring Tools', # rail group title (created if new) + 'to': '/settings/measuringtooltypes', + 'icon': 'ruler', # string key, mapped core-side + 'title': 'Measuring Tool Types', + 'description': 'Manage measuring-tool subtypes + map colors', + 'position': 22, # order within the group +} +``` + +Consumer: a new `GET /api/settings/cards` merges enabled plugins' cards into the +core `settingsGroups` catalog; `SettingsLayout` and `SettingsIndex` read the +merged catalog instead of the hardcoded JS array. The catalog's own core groups +stay in `settingsNav.js`; plugin groups are appended. + +**`get_asset_panels`** - resolves friction point 3 (asset-detail panels). + +``` +{ + 'id': 'calibration', + 'title': 'Calibration', + 'assettypes': ['measuring_tool'], # detail pages it appears on; ['*'] = all + 'endpoint': '/api/measuringtools/{assetid}/calibration-panel', + 'render': 'keyvalue', # 'keyvalue' | 'table' | 'badge' + 'position': 20, +} +``` + +Consumer: `GET /api/assets/{assetid}/panels` returns the panels whose +`assettypes` match that asset's type; a generic `AssetPanel` component on the +detail page fetches each `endpoint` and renders it in the declared style. This +covers the warranty-coverage and calibration-status cases. A panel that needs +bespoke UI (a chart) is out of scope for the data-only hook and falls to the +deferred component mechanism below - stated honestly, not hidden. + +**`get_map_overlays`** - resolves friction point 4 (map marker/overlay badges). + +``` +{ + 'id': 'calibration-due', + 'label': 'Calibration due', # legend label + 'endpoint': '/api/measuringtools/map-overlay', # -> [{assetid, color, label}] + 'style': 'badge', # 'badge' | 'ring' + 'legend': True, +} +``` + +Consumer: `MapView.vue` fetches enabled plugins' overlay endpoints and decorates +the already-placed markers; legend entries append to the existing legend. The +map stays data-driven; plugins add decoration data, not map code. + +**`get_asset_presentation`** - resolves friction point 5 (search rendering / +routing) and, as a bonus, removes the `AppLayout.vue` iconMap hand edit called +out in PLUGIN-GUIDE.md section 9. + +``` +{ + 'assettype': 'measuring_tool', # AssetType.assettype key the plugin owns + 'icon': 'ruler', + 'label': 'Measuring Tool', + 'route': '/measuringtools/{assetid}', # detail-route pattern +} +``` + +Consumer: `GET /api/assets/presentation` returns the type-to-presentation map; +global-search result rows and any asset cross-link use it to pick the icon and +build the detail link, so core never hardcodes a plugin's route or icon. + +### Friction map + +| Friction point | Mechanism | Hook / change | +|---|---|---| +| 1. api-client block in api/index.js | C (deferred) | plugin-owned `frontend/` tree, glob-discovered; scaffolder snippet is the near-term mitigation | +| 2. settingsNav.js card entry | B (now) | `get_settings_cards` | +| 3. asset-detail panels | B (now) | `get_asset_panels` (bespoke panels -> C, deferred) | +| 4. map markers / overlays | B (now) | `get_map_overlays` (bespoke overlays -> C, deferred) | +| 5. search-result rendering / routing | B (now) | `get_asset_presentation` | +| views (full list/detail/form) | C (deferred) | already glob-discovered under core `routes/*.js`; long-term move to `plugins/*/frontend/` per ADR-009 | + +### Deferred: file-convention frontend tree (Option C) + +Extending `import.meta.glob` from `./routes/*.js` to `plugins/*/frontend/` (so +views and the api-client module live with the plugin) is ADR-009 "Future +direction" steps 1-2. It is deferred for the same reasons ADR-009 gave: it is +build-system-heavy and the payoff only lands once out-of-tree plugins with their +own frontends are a real requirement. Until then, friction 1 stays mitigated by +the scaffolder snippet, and full views keep shipping in the core bundle and +gated by ADR-009 `meta.plugin`. + +This is also the honest limit on the external-plugin wrinkle. The four data-only +hooks need **zero** frontend files from a plugin, so an external symlink-only +(backend) plugin gets settings cards, detail panels, map overlays, and search +presentation with no copy-into-`frontend/src/` step at all. The wrinkle survives +only for the residual component-backed cases, which is exactly the deferred +Option C work; pushing external-repo frontend distribution to that later ADR +matches ADR-003's posture of deferring out-of-tree packaging until two sites run +their own plugins. + +### Contract-version impact (ADR-002) + +Each of the four hooks is a new optional `BasePlugin` method - an additive +change, so a **minor** bump per ADR-002, the same classification `get_reports` +took at 0.6.0. Landing all four together is a single minor bump (proposed +**0.7.0**); landing them incrementally is one minor bump each. Adding the core +consumer endpoints and the generic renderer components is core-internal and does +not itself move `__contract_version__`. The deferred Option C introduces a +separate, versioned **frontend** contract (ADR-009 step 4), tracked apart from +the backend `__contract_version__`; it is not part of this proposal's bumps. + +## Consequences + +### Positive + +- Closes the CONTRACT-STABILITY.md "single biggest gap" for the four surfaces a + generic renderer can serve, using the already-proven declarative pattern - low + risk, low cost, additive-only. +- External symlink-only plugins get four presentation surfaces with no + copy-into-core step, shrinking (not yet eliminating) the ADR-003 wrinkle. +- Removes three of the two-plus hand edits the exemplar needed (settingsNav + card, plus the AppLayout iconMap edit from section 9), moving them to data. +- Docs-drift guard forces documentation: `tests/test_docs_contract.py` fails if + any new public `BasePlugin` hook is missing from `docs/PLUGIN-HOOKS.md`, so the + hooks cannot ship undocumented. + +### Negative + +- Four more hooks and four more core consumer endpoints to maintain, each with + the skip-disabled / fail-loud-in-dev / isolate-in-prod discipline. +- The generic renderers set a ceiling: bespoke panels and overlays still have no + home until Option C lands, so the contract is honest-but-partial, not total. +- One extra request per surface on the pages that use it (a detail page fetches + its panels; the map fetches overlays), on top of ADR-009's enabled-list fetch. + +### Neutral + +- Bundled plugins keep their current in-core views; migrating them to the hooks + is opt-in and incremental (see Adoption plan), not a flag-day rewrite. +- `meta.plugin` route gating (ADR-009) is unchanged and still gates the views; + these hooks add presentation, not routing. +- Contract tests (`tests/test_plugin_contract.py`) already assert every public + hook is exercised; the new hooks slot into that harness. + +## Adoption plan + +Prove the contract on bundled plugins before declaring it settled for sister +sites. + +1. **`get_asset_panels` first, via `warranty`.** Warranty is asset-general and + already composes `WarrantyPanel.vue` onto multiple detail pages by hand, with + a clean per-asset endpoint behind it. Migrating it to a declarative + `get_asset_panels` entry rendered by the generic `AssetPanel` is the + lowest-risk proof and immediately removes hand edits from every detail view + that shows warranty. This is the recommended first migration. +2. **`get_settings_cards`, `get_map_overlays`, `get_asset_presentation` via + `measuringtools`.** The guide exemplar already needs a settings card, a + calibration-due map badge, and search routing for its `measuring_tool` type, + so it exercises all three at once and its PLUGIN-GUIDE.md walkthrough becomes + the reference for the new hooks. +3. Only after both plugins run on the hooks in a real build: bump + `__contract_version__` to 0.7.0, document the hooks in + `docs/PLUGIN-HOOKS.md`, and mark this ADR ACCEPTED. + +## Open questions + +- Should `get_asset_panels` `render` styles stay a small closed set + (`keyvalue` / `table` / `badge`), or grow? A closed set keeps the renderer + generic; growth pressure is the signal that a case actually needs Option C. +- Should the four consumer endpoints collapse into one bundled + `GET /api/plugins/frontend-contributions` call to save round-trips, or stay + separate per surface for cache locality? Defer until the per-surface request + cost is measured. +- When Option C lands, does the api-client module move under + `plugins/*/frontend/` or get replaced entirely by a generated client from the + backend blueprint? Out of scope here; belongs to the ADR-009 frontend-contract + follow-up. + +## References + +- ADR-001 (asset model as the map/search data source) +- ADR-002 (bump classification for the new hooks) +- ADR-003 (external-plugin distribution posture) +- ADR-009 (route gating; "Future direction" is the deferred Option C) +- docs/PLUGIN-GUIDE.md sections 9-10 (the measuringtools frontend hand edits) +- docs/CONTRACT-STABILITY.md (the expected-churn line this ADR answers) +- shopdb/plugins/base.py (existing declarative hooks this pattern extends) +- shopdb/core/api/dashboard.py, shopdb/core/api/reports.py (consumer precedent) diff --git a/docs/adr/ADR-011-machines-rename.md b/docs/adr/ADR-011-machines-rename.md new file mode 100644 index 00000000..89c1f824 --- /dev/null +++ b/docs/adr/ADR-011-machines-rename.md @@ -0,0 +1,75 @@ + + +# ADR-011: Rename the equipment domain to machines; retype the models catalog with modeltypes + +- **Status:** ACCEPTED +- **Date:** 2026-07-11 +- **Deciders:** ShopDB maintainers +- **Relates to:** ADR-005 (equipment vs measuringtools scope), ADR-008 (plugin migration ownership) + +## Context + +ADR-005 split the old "equipment" concept: gage-lab instruments moved to the +measuringtools plugin, leaving the equipment plugin holding exactly one thing - +shop-floor machines (lathes, mills, CMMs, grinders, furnaces). The UI, the +routes (`/machines`), the frontend view folder (`views/machines/`), and even +the data vocabulary (asset numbers headed "MACHINE #") already said machines. +Only the code identity still said equipment: the plugin name, the API prefix, +the `equipment`/`equipmenttypes` tables, and the `assettype='equipment'` data +value. Every future contributor would pay a small permanent translation tax. + +A display-label-only swap was shipped first (2026-07-11) and immediately showed +the dissonance: UI Machines over code equipment forever. + +Separately, the legacy `machinetypes` table survived the Machine-model +retirement for one real reason: it types the vendor **models catalog** +(`models.machinetypeid`, 95 of 127 models) with fine-grained, cross-category +types (Lathe, Switch, Laser Printer). Its name was a misnomer - it never typed +machines specifically - and it squatted on the name the machines plugin's +subtype table wants. + +The economics favor doing this now and never again: the plugin contract is +pre-1.0 (breaking changes are cheap per ADR-002), no sister site has adopted +yet, there is no production instance, and the test suite plus CI give the +strongest safety net the project has had. + +## Decision + +Rename the domain in one coordinated pass: + +| Current | New | Rationale | +|---|---|---| +| `plugins/equipment/`, `/api/equipment`, `equipment.*` perms | `plugins/machines/`, `/api/machines`, `machines.*` | plugin identity matches the domain | +| `equipment` table, `equipmentid` | `machines`, `machineid` | main entity | +| `equipmenttypes`, `equipmenttypeid`, `equipmenttype` | `machinetypes`, `machinetypeid`, `machinetype` | subtypes take the freed name | +| legacy `machinetypes`, `models.machinetypeid` | `modeltypes`, `models.modeltypeid` | role-accurate: it types the models catalog, losslessly (no collapse to the 4 asset types) | +| `assettypes.assettype = 'equipment'` (data) | `'machine'` | the string compare sites flip with it | + +Migration placement follows ADR-008: the modeltypes rename and the data flips +land in the core chain (core tables, core data); the plugin table renames land +in the machines plugin's own chain, idempotently guarded so fresh installs +(whose core chain still creates the old names at `7c04`) rename on first +`flask plugin upgrade-all`. The `alembic_version_equipment` version table is +renamed to `alembic_version_machines` by the core migration so the plugin chain +resumes seamlessly on existing installs. The plugin registry auto-migrates an +`equipment` key to `machines` on load. + +Historical migrations and accepted ADRs are not rewritten; old ids and ADR-005 +wording remain as history. + +## Consequences + +- Upgrading instances run `flask db upgrade` then `flask plugin upgrade-all`, + same as every deploy; both migrations are idempotent. +- `/api/equipment` and `equipmentApi` cease to exist - a breaking change made + while there is nobody to break. After contract 1.0 this class of rename + would be a major-version event; this is the last cheap moment. +- The "Machine Types" settings card becomes "Model Types" and finally says + what it does; the machines plugin's subtype card owns the machine-types name. +- Contributors stop translating between UI vocabulary and code vocabulary. diff --git a/docs/adr/ADR-012-geenforce-manifest-ownership.md b/docs/adr/ADR-012-geenforce-manifest-ownership.md new file mode 100644 index 00000000..9cc99f8c --- /dev/null +++ b/docs/adr/ADR-012-geenforce-manifest-ownership.md @@ -0,0 +1,137 @@ + + +# ADR-012: GE-Enforce manifest ownership in shopdb + +- **Status:** ACCEPTED +- **Date:** 2026-07-13 +- **Deciders:** ShopDB maintainers +- **Relates to:** ADR-002 (plugin contract versioning), ADR-004 (per-site + deployment), ADR-006 (collector contract), ADR-008 (per-plugin Alembic chains) + +> **Not bundled on this line.** The `geenforce` plugin this ADR decided is not +> part of this product build, so nothing here is a procedure to follow. It is +> kept because it is a decision record and because ADR-013 and the resource-scope +> design on API tokens both trace back to it. The plugin, its client and its +> pages live on the site line that still runs them. + +## Context + +GE-Enforce is a desired-state enforcement system for shopfloor PCs: a PowerShell +engine (`Install-FromManifest.ps1`) reads per-PC-type `manifest.json` files off +an SMB share every logon and installs / self-heals what they declare. Authoring +those manifests today means hand-editing JSON on a file share, and there is no +central view of what each PC actually did. + +We want shopdb to own the manifests as data (author, version, publish, roll +back) and to observe fleet compliance, while NOT taking on the GE-Enforce engine +itself (which is the GE-Enforce framework's, maintained separately) and NOT +dictating any site's imaging path (per ADR-004, each site is single-tenant with +its own provisioning - PXE at the reference site, OOBE provisioning packages at +others). + +The manifests are an enforcement PROGRAM, not an application inventory: entry +`Type` is not an app/config discriminator and entry `Name` is a manifest label, +not a Windows ARP DisplayName. Any design that treats them as an app catalog is +wrong. + +## Decision + +Build a bundled `geenforce` plugin that owns the manifest as shopdb data, with a +client kit and a deployment bootstrap. Specifically: + +1. **Data model.** One wide `manifestentries` table with an `entrytype` + discriminator and nullable per-type columns (not SQLAlchemy STI, not a JSON + blob - the fleet is ~64 entries, so sparse columns are free and stay + queryable). Scopes are `manifestscopes`, unique on `(scopename, phase)`; + runtime is per-pctype scopes, preinstall is one flat scope. Multi-value gates + (PCTypes / hostnames / machine numbers) and the nested InUseCheck are child + tables. `sortorder` is the execution-order contract. RegValue is stored as a + raw JSON literal so DWord-vs-string typing survives. Per-plugin Alembic chain + (ADR-008). + +2. **Published snapshots.** Editing touches a DRAFT only. Publish freezes the + rendered JSON document into an immutable `manifestpublishedversions` row; the + client is ALWAYS served the current published snapshot, never the draft; + rollback flips `iscurrent` to an older version. Freezing the document (not + row-mirroring) makes immutability structural. + +3. **Behavioral-parity gate, not byte-identity.** A DB-free harness + (`parity.py`) imports each real manifest and renders it back, then proves + BEHAVIORAL equivalence (same ordered entries with identical detection / + targeting, and the same entries fire across machine-profile fixtures) - never + byte equality, which re-serialization would never satisfy. This gates any + build that touches the model. + +4. **Filter mirror; engine is the single source of truth.** `filters.py` + mirrors the engine's four gate functions and alias graph for the "what would + this PC get" simulator and parity. The engine lib stays authoritative; + shopdb mirrors it (never the reverse). PCTypesStrict is honored only for the + preinstall phase, matching the runners. + +5. **Payload integrity is separate from detection.** For `http`/`inline` + payloads a dedicated `payloadsha256` is verified before running - independent + of `DetectionMethod` (DetectionValue is a hash only for `Hash` detection). + `smb` payloads keep the share ACL as their trust boundary. Large binaries + stay on SMB; small config/scripts may move to http/inline later. + +6. **Observed-state reporting.** Each PC POSTs its enforcement result; + `manifestenforcementreports` (+ results) records the applied version + (received-latest) and per-entry self-heal / failure. Status derives from + explicit self-heal flags only, never the raw installed count (Always/no- + detection scripts install every cycle without being drift corrections). + +7. **Service-token auth.** Client endpoints authorize via managed service + tokens scoped `geenforce.fetch` / `geenforce.report`, through a new + `service_token_authorized(scope)` on the `shopdb.api` contract surface + (contract 0.11.0). Admin CRUD uses `geenforce.manage` / `geenforce.publish`. + +8. **Client + deployment, engine referenced not vendored.** shopdb ships the + fetch/report kit (`plugins/geenforce/client/`) and a site-neutral bootstrap + (`Install-GEEnforce.ps1`) that provisions a PC's identity + (`C:\Enrollment\pc-type.txt` etc. - what determines the PC type; there is no + auto-detection, the provisioner supplies it), the shopdb registry config, and + the scheduled task. The GE-Enforce ENGINE is referenced (`-EngineSource`), + not carried by shopdb. Deployment is provisioning-path independent (PXE step, + OOBE ppkg, Intune, manual); the runtime task is fail-safe. + +9. **Milestone 1 = export to share; staged cutover.** Until a site cuts its + client over to shopdb-sourced manifests, the plugin publishes and EXPORTS the + manifest to the share (with a `_meta/history` backup, atomic write); the + unchanged engine picks it up. Cutover is staged: shadow mode (fetch from + shopdb AND read the share, log diffs, install from share) then read cutover. + +10. **No application auto-seeding.** The core Applications catalog already + tracks these apps (from the classic-shopdb migration) with version + histories; auto-creating Applications from manifest labels produced + duplicates and misclassified config drops. Application linkage, if wanted, is + a curated manifest-entry -> existing-Application link, not label scraping. + +## Consequences + +- **Positive.** Manifests become validated, versioned, publishable data with + one-click rollback and a fleet-compliance view; desired-state and observed- + state live in one system. The parity gate + published snapshots + separate + payload hash make a fleet-wide-SYSTEM system safe to author. The plugin is + provisioning-agnostic, so any GE Aerospace site can adopt it regardless of + imaging path. Validated end to end: parity green against the real manifests, + and the client kit + installer proven on a Windows VM (PS 5.1) and Linux + pwsh 7. +- **Boundaries / risks.** The engine remains the GE-Enforce framework's, so + shopdb's parity mirror must be kept in sync with the lib (guarded by the parity + fixtures; the plugin pins lib >= 2.6 for `_CmmVersion`). Provisioning writes + the PC identity - shopdb cannot set a PC's type at imaging (a PC is unknown + until it enrolls and reports). Manifest-label vs ARP-name mismatch means the + catalog link, when built, needs a curated alias layer. +- **Deferred.** Desired-vs-observed per-entry compliance (needs a collector + installedVersions field); curated manifest-entry -> Application linking; the + live client cutover (a site operational decision); inline payload upload. + +The design note, the concepts page, the fetch/report contract and the client +deployment page were all written against this plugin and stay with it on the +site line that carries it. None of them is in this repository. diff --git a/docs/adr/ADR-013-plugin-catalog-and-lean-builds.md b/docs/adr/ADR-013-plugin-catalog-and-lean-builds.md new file mode 100644 index 00000000..ba7bc3fe --- /dev/null +++ b/docs/adr/ADR-013-plugin-catalog-and-lean-builds.md @@ -0,0 +1,435 @@ + + +# ADR-013: Plugin Catalog, Curated Shelf, and Lean Per-Site Builds + +- Status: ACCEPTED +- Date: 2026-07-18 +- Deciders: ShopDB maintainers +- Relates to: ADR-002 (contract versioning), ADR-003 (plugin distribution), ADR-004 (per-site instances), ADR-008 (per-plugin migrations), ADR-009 (frontend plugin gating), ADR-010 (frontend hook contract) + +## Context + +Every site today ships identical code. The backend image contains all 13 plugin +directories (Dockerfile COPY at line 55; the header comment listing "eleven core +plugins" is stale), and the SPA compiles every plugin's routes and views via a +static glob (frontend/src/router/index.js:11) plus hardcoded imports. A site's +"chosen set" exists only as runtime enable flags in instance/plugins.json. +Disabled is not absent: a site that never wants printedparts/usb/network still +ships, and can execute, that code. + +Distribution per ADR-003 is "drop a directory into plugins/ by hand". There is +no artifact format, no signing, no catalog, no validate gate, and the loader +trusts whatever it finds on disk (loader.py:61-73 discovers any folder with a +plugin.py; loader.py:185-224 loads it; migrations.py:29-62 runs its DDL with +full DB privileges). Plugins run in-process with the full shopdb.api surface +including db, so the only tenable security model on air-gapped GE networks is +curation plus cryptographic provenance, enforced everywhere code can execute, +not sandboxing. + +Known defects this ADR also resolves: + +- upgrade_all_plugins checks hasattr(registry, 'list_installed') which never + exists (registry has only get_all/get_enabled_plugins), so it always falls + back to migrating every folder on disk, adopted or not (__init__.py:94). +- PILOT-DEPLOY.md enables plugins that were never installed; enable refuses. + There is no declarative "apply this chosen set" operation. +- Reverse-dependency checks on uninstall/disable read only LOADED plugin + instances, so an installed-but-unloaded dependent is invisible. +- Dependency install/enable is check-only; nothing computes a closure, and + install-a-dependent can fail at its own load step because the dependency was + installed disabled (default_enabled=false on employees). +- The dependency sort has no cycle detection. +- Soft couplings (geenforce -> computers, notifications -> employees) are + invisible to the manifest graph. + +Frontend reality check (this drove the design below): plugin UI is NOT one +folder per plugin. Routes live in routes/.js (slides has none), inside +the shared core.js (computers report, printers toner report, employees detail, +slides settings), and as six hardcoded top-level imports in index.js itself +(/parts-kiosk, /tv, /print/printer-qr x2, /print/usb-labels, +/print/printedparts-labels). View dirs mismatch plugin names (computers -> +views/pcs). Plugin settings cards sit in shared views/settings/ +(DellWarrantySettings, ZabbixSettings, SlideManager, EmployeeDirectory, +MeasuringToolTypesList, PrintedPartsSettings), plugin print views in shared +views/print/, and some views span plugins (AssetLabel.vue serves five asset +types; PCDetail imports WarrantyPanel). Any lean-frontend design that only +moves routes/*.js and views// fails the build the moment a plugin is +pruned. This ADR scopes that work honestly instead of calling it mechanical. + +## Decision + +### 1. Tiering: mandatory core is the core package; all plugins are catalog-optional + +- The mandatory core is the non-plugin shopdb/core/ package (auth, users, + assets, locations, vendors, models, settings, audit, dashboard, search, + reports, plugin management). It already survives every plugin being absent + via hasattr/lazy-import guards. No plugin is promoted into it. +- New optional manifest field `tier: "core" | "optional"`, default "optional". + All 13 existing manifests are unchanged and unchanged in meaning. The + lifecycle gains a guard: uninstall_plugin and disable_plugin refuse a + tier:core plugin (alongside the reverse-dependency checks at + __init__.py:269-278 and :351-360). No plugin ships tier:core initially; the + field and guard exist so a future curation decision is a manifest edit, not a + framework change. +- Per-site mandates live in the site profile (section 5): a `locked` list the + profile applier refuses to remove. This preserves ADR-004 site autonomy: a + wing site can mandate usb without the framework mandating it fleet-wide. +- New manifest field `optional_dependencies: []` (names only, loader-ignored). + Declared for the verified soft couplings: geenforce lists computers + (service.py:32-43 loses app-detection gates without it), notifications lists + employees (routes.py:151-171 loses name/photo enrichment). Catalog listing + and adopt WARN on unmet optional deps; nothing blocks. +- Hard `dependencies` gains optional PEP440 ranges ("employees>=1.1"). + validate/adopt honor ranges; the runtime loader keeps name-only semantics + (specifier stripped) so no loader behavior changes. The single existing hard + edge printedparts -> employees stays as-is; whether it can relax to optional + (badges.py has an external HR fallback) is a follow-up product question, not + blocked on this ADR. +- Dependency plumbing fixes: _sort_by_dependencies gains cycle detection + (raise PluginDependencyError on a back edge); reverse-dependency checks read + manifests of ALL installed plugins from disk, not loaded instances. + +### 2. Packaging: signed, versioned artifacts + +Artifact: `-.shopdbplugin` (a zip of the plugin directory: +manifest.json, plugin.py, api/, models/, migrations/, and frontend/ once +section 6 lands) plus two members generated at pack time: + +- `PROVENANCE.json`: plugin name, version, publisher id, build timestamp, and + a sorted map of every packaged file path to its SHA-256. PROVENANCE.json is + not listed in its own map, so there is no circular-hash problem and no zip + canonicalization needed; determinism comes from sorted per-file hashes. +- `PROVENANCE.sig`: detached ed25519 signature over the exact PROVENANCE.json + bytes. + +New CLI: + +- `flask plugin pack --key ` (producer side): runs validate on the + directory, then emits the artifact. +- `flask plugin validate ` (the missing pre-publish gate), + fail-closed pipeline: signature (artifact mode) -> per-file hashes -> manifest + against a new docs/plugin-manifest.schema.json -> name == directory -> + core_version parses as a specifier and admits the target contract version -> + static import-surface scan reusing tests/test_plugin_contract.py logic -> + alembic versions parse. The schema types the known fields (name, version, + description, dependencies, optional_dependencies, tier, core_version, + api_prefix, display_name, default_enabled, provides, settings) and PERMITS + additional properties, so all 13 existing manifests pass unmodified. + +The import-surface scan is documented as a lint, not a security control; it is +trivially bypassed by dynamic import. The security control is human review +before signing (section 4). + +### 3. The shelf: a read-only folder, transport-agnostic by design + +- One config knob: `PLUGIN_SHELF_DIR`. The app only ever reads this folder. It + never speaks SharePoint, OneDrive, or any network protocol. +- Transport is explicitly out of scope and explicitly untrusted. On networks + that can reach corporate M365, a SharePoint document library sync populates + the folder. On strictly air-gapped floors where no sync agent can run, the + folder is populated by robocopy/USB. Both are equally supported and equally + untrusted, because every decision-bearing byte is signed: swapping transport + changes nothing about the trust model. +- Layout: `//-.shopdbplugin` plus + `shelf-index.json` and `shelf-index.sig`. +- The index is SIGNED with the same publisher key and carries a monotonically + increasing `serial` plus a `revoked` list of name-version pairs. Each site + records the last-seen serial in instance state and refuses an index with a + lower serial (anti-rollback of the catalog itself). The index also carries + per-entry version/tier/core_version so `flask plugin shelf-list` can display + compatibility without unpacking, but the index is a BROWSE layer only: + adopt reads dependencies, tier, and core_version from the signed manifest + inside the verified artifact, never from the index. +- Trusted keys: `PLUGIN_TRUSTED_KEYS` is a list of pinned public keys delivered + out-of-band in the site's deployed config/image. Keys are NEVER read from the + shelf; a folder that can be written by an attacker must not also carry the + keys that authenticate it. Multiple pinned keys allow overlap rotation. + Revocation of an artifact rides the signed index `revoked` list; a + `flask plugin audit` command warns when an installed version appears there. +- Partial-sync robustness: adopt copies the artifact to a temp location, + verifies signature and every file hash there, then unpacks to + plugins/.staging/ and renames into place atomically. OneDrive + placeholder stubs, zero-byte files, or an index referencing not-yet-synced + artifacts all fail closed with a clear "artifact not fully synced/verified" + error. +- `flask plugin adopt [==version]`: resolve version from the shelf, + verify, compute the hard-dependency closure from signed manifests, then for + each closure member in topological order: unpack, INSTALL, and ENABLE (not + install-only; the load gate at loader.py:201-206 checks is_enabled, so an + install-only closure with default_enabled=false deps would fail its own + load). Migrations run via the unchanged per-plugin chain (ADR-008). Refuses + to adopt a version lower than the installed one unless + `--force-downgrade` is given interactively. Prints the restart notice. +- Adopt/install/uninstall remain CLI-only. The admin HTTP surface stays a + read-only catalog view plus the existing enable/disable toggle; because + Flask cannot register blueprints after the first request, any adopt or + enable takes full effect only on restart, and the UI says so. There is no + "install button" that pretends otherwise. + +### 4. Trust model: verify at adopt AND at every load and migrate + +Signing that gates only adoption is bypassable through every other write path +into plugins/ (git clone, symlink, USB drop) and defeated by post-adoption +tampering. Therefore verification is enforced where code executes: + +- Adoption leaves PROVENANCE.json and PROVENANCE.sig inside plugins// + and records publisher + artifact hash in the registry entry. +- load_plugin verifies the signature against PLUGIN_TRUSTED_KEYS and re-hashes + the plugin tree against the provenance file map BEFORE importing plugin.py + (new step ahead of loader.py:185). Missing or invalid provenance is a + fail-closed refusal in production. +- run_plugin_migrations performs the same verification before executing any + revision, so a routine `flask plugin upgrade-all` can never run DDL from an + unverified folder. +- upgrade_all_plugins iterates registry.get_all() (fixing the phantom + list_installed fallback at __init__.py:94), so unadopted on-disk folders are + never migrated as a side effect of deploys. +- Development and the ADR-003 external-repo/symlink workflow (including + scripts/test-external-plugin.sh) are preserved via `PLUGIN_DEV_TRUST_DIRS`, + honored ONLY when DEBUG or TESTING is set. Production ignores it. +- Cost: hashing 13 small plugin trees at boot is milliseconds; accepted. + +What signing does NOT claim: a valid signature proves the artifact is exactly +what a curator reviewed and signed, nothing more. Plugins remain in-process +Python with full DB access. The actual safety control is the human review +before signing; the signature makes that review's verdict tamper-evident all +the way to execution. + +### 5. Declarative site profiles and lean backend builds + +- `site-profile.json` per site (kept in the site's deploy config): site name, + list of chosen plugins, optional `locked` list. `flask plugin apply-profile + ` resolves the closure, installs AND enables in dependency order, runs + migrations, reports which changes need a restart. This replaces the + imperative CLI sequences in DEPLOY.md/PILOT-DEPLOY.md and fixes the + enable-without-install bug. +- Lean backend image: `scripts/build-site.sh` reads the profile and stages + only core + chosen plugin directories into the Docker build context + (correcting the Dockerfile COPY and its stale header comment). Discovery + needs no change; it already scans whatever exists. +- Prerequisite the naive version misses: core hardcodes plugin imports. + shopdb/core/api/search.py (~15 sites), reports.py, assets.py, collector.py, + applications.py, auditlogs.py, and shopdb/cli/__init__.py import + plugins..* lazily. Some already guard ImportError; ALL must, with + graceful degradation, before any site prunes a folder. This is audited and + enforced by a new CI job that deletes one plugin directory and runs the full + test suite (repeated per plugin). Longer term these aggregators should move + to registry-driven contract hooks (get_search_providers/get_report_sources) + so a new catalog plugin can join search/reports without core edits; that is + scoped as follow-up work, not a blocker for lean builds. +- Schema-lean is DEFERRED to its own ADR. The core baseline 68b3947ae14f + unconditionally creates the 10 pre-cutover plugins' tables, and lifting them + into plugin baselines collides with cross-plugin foreign keys (the + computers-owned installedapps table FKs machines.machineid while computers + declares no dependency on machines). Reversing the cutover would either + introduce undeclared hard deps or drop FKs; neither is decided here. A lean + site therefore carries a handful of empty pre-cutover tables. Accepted. + +### 6. Frontend delivery: Path C for rich UIs, Path A for simple ones, Path B rejected + +Path B (runtime-loaded JS / module federation) is REJECTED: it moves executable +UI delivery from a signed, statically auditable build artifact to runtime +fetching, which is exactly the wrong direction for an air-gapped, +review-then-sign posture, for zero benefit given restarts are already required. + +Path A (declarative JSON UI over generic renderers) is COMMITTED and scheduled +EARLY: the three unwired ADR-010 endpoints (pluginui.py asset-panels:62, +map-overlays:88, asset-presentation:100) get generic core renderers, joining +the already-consumed settings-cards. After this, a simple plugin ships JSON-only +UI with zero frontend build involvement. Sequencing this before the relocation +gives every plugin an escape hatch during the migration instead of after it. + +Path C (self-contained plugin frontend) is the primary mechanism, scoped +against the real code, not the idealized layout: + +- Canonical home: plugins//frontend/ containing routes.js (the plugin's + complete route array, INCLUDING routes currently embedded in index.js and + core.js), views/, and settings views. +- A pre-Vite staging step (scripts/stage-frontend.mjs, run by build-site.sh + and the dev script) copies the CHOSEN plugins' frontend/ into + frontend/src/.plugins-staged// (gitignored) and generates two files + inside the Vite root: routes.gen.js (aggregated plugin routes) and + meta.gen.js (plugin-supplied icon names, title spellings, settings-standalone + flags, replacing the hardcoded iconMap/TITLE_SPELLINGS/SETTINGS_STANDALONE in + AppLayout.vue, settingsCatalog.js, and index.js). This exists because + import.meta.glob requires a static literal inside the project root and + cannot select a per-site subset by itself. +- ONE-TIME core-router surgery, done first and called what it is: the six + hardcoded plugin-view imports in index.js (PartsKiosk, TVDashboard, + PrinterQRBatch/Single, USBLabelBatch, PrintedPartsLabels) and the plugin + routes embedded in core.js move into their owning plugins' routes.js. Without + this, pruning slides/printers/usb/printedparts fails the Vite build on + unresolvable imports; no amount of glob work fixes it. +- Per-plugin relocation PRs (13), each REAL WORK, not a file move: carve routes + out of shared files, move views (handling name mismatches like computers -> + views/pcs), move the plugin's settings views out of shared views/settings/, + and rewrite relative ../../ imports of core shared code to the @/ alias + (relative paths break at the staged depth). A lint rule enforces alias-only + core imports in plugin frontend code from then on. +- Shared plugin-aware code STAYS CORE and ships to every site: AssetLabel.vue + (spans five asset types), views/print helpers (assetLabel.js, qrLogo.js), + MachineBadge.vue, and cross-plugin panels like WarrantyPanel used by + PCDetail. These already null-guard or gate via isPluginEnabled and must keep + degrading when a peer plugin is absent; over time they migrate to ADR-010 + asset-panels so the data becomes plugin-supplied. Lean v1 therefore prunes + plugin-EXCLUSIVE code; a small plugin-aware core remainder is accepted and + shrinks as Path A absorbs it. +- Dual-location transition: the staging step unions legacy locations + (routes/*.js glob, views//) with plugins//frontend/ until each + plugin has moved. The SPA builds green at every commit; each plugin's move is + independently revertable until the legacy glob is removed at the end. +- Nav and settings cards are already server-driven (dashboardApi.navigation, + settings-cards); the remaining hardcoded plugin entries in settingsNav.js + (/settings/zabbix, /settings/dellwarranty) move to those plugins' + get_settings_cards so pruning leaves no dead links. + +### 7. Effect on the 13 existing plugins + +- Backend: ZERO code changes required. tier/optional_dependencies/provenance + are additive; pack zips the directory as-is; all plugins keep passing + tests/test_plugin_contract.py. Bundled plugins in a site's image get + provenance stamped at build time by pack, so verify-at-load applies to them + identically. +- Frontend: one relocation PR each, of the honest scope above. Until a + plugin's PR lands it keeps working from its legacy location. +- Operationally nothing changes for a site that does nothing: default builds + remain all-plugins, apply-profile is opt-in, and enable/disable semantics + (including the restart requirement) are unchanged. + +## Consequences + +### Positive + +- A real catalog: sites declare their set in site-profile.json and apply it in + one idempotent command; the chosen set drives backend image, SPA bundle, and + runtime state from one source of truth. +- Curated marketplace with end-to-end provenance: review -> sign -> any + transport -> verify at adopt, at load, and at migrate. Transport (SharePoint + sync or sneakernet) is untrusted and interchangeable, which is exactly right + for air-gapped sites. +- Lean per-site builds: unchosen plugins exist in neither the image nor the + bundle, shrinking attack surface and download size. +- Fixes shipped along the way: upgrade-all migrating unadopted folders, + PILOT-DEPLOY install/enable ordering, reverse-dep checks blind to unloaded + plugins, missing cycle detection, missing dependency closure, hardcoded + frontend plugin metadata. +- Path A completion makes simple plugins UI-capable with no build glue, which + is the cheapest possible marketplace onboarding. + +### Negative + +- Key management is a per-site operational burden: pinned keys delivered + out-of-band, rotation is a config change everywhere. Accepted as the price of + not trusting the distribution folder. +- The frontend re-org is the long pole: one core-router surgery plus 13 + non-trivial PRs. It is sequenced to be always-green and per-plugin + revertable, but it is weeks of work, not a rename. +- Schema is not lean: pre-cutover plugin tables still appear at every site + until the deferred baseline re-org ADR. +- Restarts remain required after adopt/enable (Flask blueprint constraint); + the marketplace UX is honest about it rather than working around it. +- Boot adds a signature + tree-hash check per enabled plugin (milliseconds, + but nonzero). + +### Risks + +- Key compromise or curation failure: a signature proves provenance, not + safety; a compromised pinned key or a rubber-stamp review signs malware that + every gate will happily pass. Mitigations: multi-key pinning with overlap + rotation, signed revocation list with monotonic index serial, and keeping the + signing key offline with the curator. The static import scan is a lint and + must never be presented as a boundary. +- Rollback/downgrade: mitigated three ways: index serial monotonicity, adopt + refusing version downgrades without interactive --force-downgrade, and the + signed revoked list. Residual risk: a site that never syncs a newer index + cannot learn of revocations; `flask plugin audit` at deploy time narrows the + window. +- Version skew across ADR-004 sites: one shelf serves sites at different + contract versions. Adopt checks core_version from the signed manifest against + the site's own __contract_version__ (authoritative); shelf-list shows an + advisory compatibility column from the index. Incompatible artifacts are + listable but not adoptable. +- Partial/placeholder sync files: fail closed on hash verification; the error + message distinguishes "not fully synced" from "tampered" only by wording, + intentionally, since the app cannot tell. +- Dev-trust misuse: PLUGIN_DEV_TRUST_DIRS silently ignored outside + DEBUG/TESTING; a prod config carrying it gets a startup warning. +- Frontend closure drift: plugin views importing cross-plugin components is a + graph the manifest does not model. The lint rule (plugin frontend may import + core @/ paths and its own tree only, never another plugin's) prevents new + edges; existing shared plugin-aware code is explicitly core-owned. + +## Implementation phases + +- Phase 0, groundwork (small, days): upgrade_all_plugins uses + registry.get_all(); reverse-dep checks read installed manifests from disk; + cycle detection in _sort_by_dependencies; shopdb/plugins/manifest_schema.json + + `flask plugin validate` (directory mode); `flask plugin apply-profile` with + install+enable closure ordering; fix Dockerfile stale comment. All additive, + zero risk to running sites. +- Phase 1, packaging and signing (medium, about a week): PROVENANCE format, + `flask plugin pack`, validate artifact mode, PLUGIN_TRUSTED_KEYS config, + ed25519 signing tooling and curator docs. No runtime behavior change yet. +- Phase 2, shelf and enforcement (medium-large, one to two weeks): + PLUGIN_SHELF_DIR, signed shelf-index with serial + revoked list, + `flask plugin shelf-list` / `adopt` / `audit` with atomic verified unpack; + verify-at-load in load_plugin and verify-at-migrate in + run_plugin_migrations, fail-closed in prod; PLUGIN_DEV_TRUST_DIRS for + dev/test and the external-repo harness; tier:core lifecycle guard; + provenance stamping of bundled plugins at build. This phase completes the + security model; everything after it is delivery optimization. +- Phase 3, Path A completion (medium, one to two weeks): generic renderers for + asset-panels, map-overlays, asset-presentation; migrate settingsNav.js + hardcoded plugin cards to get_settings_cards. Done BEFORE relocation so + JSON-only UI is available during the migration. +- Phase 4, frontend re-org (large, the long pole, several weeks elapsed): + stage-frontend.mjs staging + routes.gen.js/meta.gen.js codegen; ONE core PR + moving the six index.js hardcoded plugin imports and the core.js-embedded + plugin routes into plugin route files; then 13 per-plugin relocation PRs + (views, settings views, name-mismatch dirs, @/ alias rewrite) under the + dual-location union; lint rule for plugin frontend imports. Always-green, + per-plugin revertable. +- Phase 5, lean builds end to end (medium, about a week after Phase 4): + build-site.sh staging backend dirs + frontend staging from site-profile.json; + core lazy-import guard audit finished, enforced by the delete-a-plugin CI + matrix; remove the legacy glob; pilot one real lean site (a location without + printedparts/usb/network) and diff its image and bundle against a full build. + +Deferred, each to its own future decision: schema-lean core-baseline re-org +(blocked on the installedapps -> machines FK question), pip/entry-point +distribution (ADR-003 v2), hook-based search/report aggregation contract, and +any revisit of Path B. + +## Amendment, 2026-08-14 + +Status corrected from PROPOSED to ACCEPTED. The lean-build half of this decision +shipped some time ago - `scripts/build-site.sh`, `SITE_PLUGINS` staging, +`flask plugin prune-schema` and `default_enabled: false` are all live, and +ADR-014 was accepted on top of it - while the record still said the decision was +under consideration. A decision that has been implemented and depended upon is +not proposed, whatever the header says, and leaving it that way makes every +other status in the index worth less. + +The catalog and signed-artifact parts of the decision remain unbuilt. That is +the ordinary state of an accepted decision: accepted means settled, not +delivered. + +## Note for this product line, 2026-09-19 + +This line bundles no plugin it withholds: the tree and the shelf are the same +set. So the omitted set a lean build leaves out is chosen by the receiving +SITE, not by this repository, and there is no bundled-but-unshipped code here +for a gate to catch. + +That changes what the CI lean-build job can prove. It now builds a genuine +SUBSET of this tree's own plugins and asserts the rest are absent, so what it +exercises is the SUBSETTING MECHANISM - `scripts/build-site.sh`, +`scripts/resolve_plugin_closure.py` and the frontend staging - rather than one +site's chosen set. The omitted names are picked to have no dependants, so the +closure stays empty and the assertion stays honest. diff --git a/docs/adr/ADR-014-schema-lean-per-site.md b/docs/adr/ADR-014-schema-lean-per-site.md new file mode 100644 index 00000000..0a25d2c9 --- /dev/null +++ b/docs/adr/ADR-014-schema-lean-per-site.md @@ -0,0 +1,150 @@ + + +# ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, lift plugin tables) + +- Status: ACCEPTED +- Date: 2026-07-19 +- Deciders: ShopDB maintainers +- Relates to: ADR-008 (per-plugin migration ownership), ADR-013 (plugin catalog + lean per-site builds), ADR-001 (asset model) + +## Context + +ADR-013 delivered lean per-site builds for plugin CODE (backend tree + frontend +bundle carry only chosen plugins). One residual was left, explicitly deferred: +the DATABASE. The core Alembic baseline (68b3947ae14f) creates EVERY table, +including ~30 plugin-owned tables (PLUGIN_TABLE_OWNERS). Each plugin's own +baseline is a stamp-only no-op (the core chain already made its tables, per +ADR-008). So a lean site that omits a plugin still creates that plugin's tables, +empty and unused. + +The deferral cited a blocker: "the computers-owned installedapps table FKs +machines.machineid while computers declares no dependency on machines; reversing +the cutover would introduce undeclared hard deps or drop FKs; neither is +decided." Investigation refined this: + +The cross-boundary foreign keys into the machines plugin table are ALL held by +DEAD legacy tables/columns that predate the asset model (ADR-001) and the +per-plugin cutover (ADR-008), and are queried nowhere in the codebase: + +- `machinerelationships` (child/parentmachineid -> machines) - superseded by + `assetrelationships`. No model, no query. +- `printerdata` (machineid -> machines) - the pre-cutover printers table, + superseded by the printers plugin. No model, no query. +- `installedapps` (machineid -> machines) - a standalone machine-app link table; + the live relationship is `computerinstalledapps` (FK to computers only). The + standalone table has no model, no query. +- `communications.machineid` (-> machines) - a legacy column on the core + communications table (which is now assetid-based). Not read anywhere. + +No LIVE plugin table hard-FKs another plugin's table. computerinstalledapps FKs +only computers.computerid (intra-plugin). So the blocker is dead cruft, not +live design. + +## Decision + +Two phases, both leaving existing databases correct. + +### Phase 1: retire the dead cross-boundary cruft - ALREADY DONE + +Investigation found this is already accomplished by existing migrations: +`7a01_adr001_position_contract` and `7c01_drop_legacy_machine` drop +`machinerelationships`, `printerdata`, `installedapps`, and +`communications.machineid` (with its FK). The current schema (verified on the +dev database) has none of them. So the cross-plugin FK blocker ADR-013 cited no +longer exists in the live schema - only in the baseline's transient +create-then-later-drop. No new migration is needed for Phase 1. + +Precedent: ADR-001 dropped a cross-plugin FK the same way +(usbcheckouts.machineid -> machines became a soft sentinel). + +### Enabling change (executed now): idempotent create_plugin_tables + +`shopdb/plugins/alembic_template.py:create_plugin_tables` now skips any table +that already exists (inspects the bind first) instead of raising. This is the +mechanism Phase 2 needs: a plugin anchor can create its tables on a fresh lean +install AND be a safe no-op on an existing database that already has them from +the pre-cutover core baseline. Correct and inert regardless of Phase 2 (no +current caller creates against a populated schema). Verified against the +plugin-migration suite. + +### Phase 2 (executed): prune not-installed plugin tables after upgrade + +Two mechanisms were weighed to make a lean site's database carry only +core + chosen-plugin tables: + +- **Relocate** (rejected): pull every plugin-table create/alter out of the core + chain into the plugin baselines, so the core chain never creates a + not-installed plugin's table. Measurement killed this: plugin tables are + created and altered across ~15 released core migrations (baseline plus 7c04, + 7d05, 7d08, 7d13, 7d15, 7d16, 7d17, ...), not just the baseline. Because the + whole core chain runs before any plugin chain, removing a table's create from + core while a later core migration still alters it breaks FULL installs too, so + relocation means surgically rewriting ~15 released migrations - the highest + blast radius in the project - for a purely cosmetic gain (the omitted tables + are empty and the lean CODE build already never loads the plugin). + +- **Prune-after-upgrade** (chosen): leave the entire core chain untouched. Add + `flask plugin prune-schema`, which drops the tables of every plugin in + PLUGIN_TABLE_OWNERS that is not installed on this site. Run once at deploy, + after `flask db upgrade` and `flask plugin upgrade-all`. Same end state + (core + chosen tables) with near-zero blast radius: no released migration is + edited, and an existing full site is unaffected because it never runs the + command. + +`prune-schema` drops by table name (no plugin-code import), so it works on a +lean image where the omitted plugin's directory is absent. It is a dry-run by +default and refuses to drop a table that holds rows unless `--force`, so a +misfire on a populated site cannot silently delete data. Because the core chain +seeds a few plugin reference tables (e.g. 7d05 inserts default access +protocols), initial lean provisioning uses `--force` - at that point the tables +hold only migration-seeded defaults, before any site data exists. + +The idempotent `create_plugin_tables` (enabling change above) is what lets a +lean site later ADD an omitted plugin: its anchor recreates the pruned tables. + +Verified end to end on MySQL: fresh full install (86 tables) then prune is a +no-op; fresh lean install (machines + printers) then prune drops the other 19 +plugin tables, leaving core + chosen; second prune is a no-op; the non-empty +guard refuses without `--force`. Four SQLite regression tests pin the behavior +(tests/test_plugin_prune_schema.py), running in the backend CI job via the real +CLI runner: drop-only-not-installed, full-site no-op, refuse-non-empty, and +force-drops-non-empty. + +## Consequences + +### Positive + +- A lean site's database contains only core + chosen-plugin tables, with no edit + to any released migration (near-zero blast radius). +- The cross-plugin FK blocker ADR-013 cited is gone (dead cruft, dropped by + existing migrations), so plugin schemas are already FK-independent. +- Adding an omitted plugin to a lean site later just works: the idempotent + anchor recreates its tables. + +### Negative / risk + +- prune-schema is destructive by nature; the row-count guard + dry-run default + + required `--force` for non-empty tables contain that. It is a deploy-time + provisioning step, not something to run casually on a live populated site. +- A lean fresh install still transiently creates then drops the omitted plugins' + tables (the core chain builds them, prune removes them). Harmless and one-time + at provisioning; the trade for not touching the released baseline. + +## Implementation + +- Phase 1: nothing to do - the dead cross-boundary FK objects were already + dropped by existing migrations `7a01_adr001_position_contract` and + `7c01_drop_legacy_machine`; verified absent on a fresh full MySQL upgrade. +- Enabling change: `create_plugin_tables` made idempotent + (`shopdb/plugins/alembic_template.py`). +- Phase 2: `flask plugin prune-schema` (`shopdb/plugins/cli.py`), dry-run by + default, `--yes` to execute, `--force` for non-empty tables. Deploy order: + `flask db upgrade` -> `flask plugin upgrade-all` -> `flask plugin prune-schema + --yes --force`. Regression tests in `tests/test_plugin_prune_schema.py` (run in + the backend CI job). diff --git a/docs/adr/ADR-015-site-specific-configuration.md b/docs/adr/ADR-015-site-specific-configuration.md new file mode 100644 index 00000000..80861398 --- /dev/null +++ b/docs/adr/ADR-015-site-specific-configuration.md @@ -0,0 +1,156 @@ + + +# ADR-015: Where a site's own data is allowed to live + +- Status: ACCEPTED +- Date: 2026-08-11 +- Deciders: ShopDB maintainers +- Relates to: ADR-004 (per-site instances, not multi-tenant), ADR-013 (plugin catalog + lean per-site builds), ADR-014 (schema-lean per-site builds) + +## Context + +ShopDB is built as a product several GE Aerospace sites can adopt (ADR-004: +each site runs its own instance). It is also developed at one site, against +that site's live fleet. Every feature therefore arrives +carrying that site's hostnames, networks, share paths and vocabulary, and the +fastest way to ship any of them is to write the value where the code needs it. + +That is not a hypothetical drift. A scan of `plugins/` and `shopdb/` for a site +host, a site FQDN or a site network returns 19 hits, of which roughly 8 are a +shipped default or a hardcode rather than a comment: + +- `plugins/geenforce/seed_display_scope.py` baked one site's own ShopDB URL and + `device.geaerospace.net` into an inline PowerShell payload. A sister site + could not override either without editing the source. FIXED 2026-08-14: the + kiosk base URL now comes from the `site_base_url` setting at seed time and + from HKLM at run time, and the dispatcher refuses rather than guessing when + neither is set. +- `plugins/backups/services/registry.py` defaults the backup share root to one site's own + UNC path. +- `shopdb/core/api/settings.py` ships `device.geaerospace.net` and + `Printer-{ip}.printer.geaerospace.net` as SETTING DEFAULTS, so another site + inherits that site's domain silently until someone notices. +- `shopdb/core/models/dashboarddefault.py` holds the same domain a second time + as a module constant, and `plugins/computers/api/routes.py` a third time as a + literal fallback. + +The problem is not that site-specific values exist - they must. It is that the +repository already has three good mechanisms for them and no rule about which to +use, so each new value lands wherever was convenient. The result reads as drift +because it is unmanaged, not because any single choice was wrong. + +Nothing here is a security matter: these are internal hostnames on an air-gapped +network, and `tests/test_docs_publishable.py` already gates what reaches the +public wiki. This is about whether a sister site can adopt the product without +reading our source. + +## Decision + +A site's own data lives in one of three places, in this order of preference. + +**1. A setting, with a NEUTRAL default.** Anything an operator could plausibly +change: hostnames, domains, share paths, networks, base URLs, label prefixes. +The default that ships must be blank or generic, never one site's value. Blank +must be a working state - code reads the setting and degrades sensibly, the way +`display_fqdn_domain` already treats blank as "use the hostname as-is". A +site-specific default is the failure mode this ADR exists to stop: it is +invisible, it looks deliberate, and it is wrong everywhere except here. + +**2. A site-namespaced directory.** Anything that is genuinely one site's and +will never be another's, such as the reference site's import loader at +`scripts/site_imports/wjf/`. Never reached by another site's build. + +**3. Seed data, not code.** Vocabularies and catalogs, loaded by `flask seed` +and editable per site afterwards. + +Fleet-wide convention is NOT site-specific. The `gea-shopfloor-*` imaging types +are a GE fleet taxonomy, not one site's data, and they already carry a per-site +override through the `pctypemap_*` settings. They are out of scope here. + +Comments and documentation may name a site where it explains WHY - "part markers +share a machine number at the reference site" is a fact a maintainer needs. Prefer +phrasing the rule generally and the example specifically. + +## Enforcement + +`scripts/check-naming-and-style.sh`, which already runs in CI, greps `plugins/` +and `shopdb/` for a site host, a site FQDN or a site network, excluding tests +and `site_imports/`. + +It shipped in REPORT-ONLY mode, because turning a 19-item backlog into a hard +gate on the day it lands blocks every unrelated commit until someone works +through it. See the amendment below: it now fails the build. + +## Consequences + +Positive: + +- A sister site can adopt without reading our source for values it must change. +- The backlog is visible and counted rather than felt. +- New site-specifics are caught at commit time, in the same place the naming + rules are already enforced, so there is nothing new to remember. + +Negative: + +- Report-only means the rule is advisory until someone flips it, and an + advisory rule can be ignored. +- A neutral default is more code than a working one: every read needs a blank + case, which is a real cost paid at every call site. +- The pattern list is a denylist and will miss a site value it has not been + taught. It catches drift, not everything. + +## Backlog + +CLEARED - see the amendment below. Kept as the record of what the decision was +answering: + +- `plugins/geenforce/seed_display_scope.py` - kiosk base URL, FQDN domain, and + the legacy-shortcut sweep pattern +- `plugins/backups/services/registry.py` - `DEFAULTSHAREROOT` +- `shopdb/core/api/settings.py` - `display_fqdn_domain` and the printer + hostname pattern defaults +- `shopdb/core/models/dashboarddefault.py` - `DEFAULT_DISPLAY_FQDN_DOMAIN` +- `plugins/computers/api/routes.py` - literal fallback duplicating that default + +The domain appears three times in three files, which is its own defect: one +setting, one default, one read. + +Cosmetic mentions in comments and UI placeholder text are acceptable under this +ADR and are not part of the backlog, with one exception worth fixing when +convenient: the GE-Enforce settings page shows one site's real CIDRs as its +placeholder, which every site sees. + +## Amendment, 2026-08-14 + +The backlog above is cleared and the scanner now ENFORCES: it fails the build, +and `SITE_LITERALS_ENFORCE=0` is the escape hatch rather than the default. The +report-only period ended for the reason the Consequences section predicted - the +hit count did not move for weeks, because a rule that only prints is read as no +rule. + +Three things changed beyond clearing the list: + +- The scan covers PowerShell, the installer, the seeds, generated JSON and the + frontend, case-insensitively, across `plugins/`, `shopdb/`, `scripts/`, + `deploy/`, `frontend/src/` and `tools/`. Its original scope - three file types + under two directories - missed every literal that actually reached a second + site, because those were all in the client scripts and the installer. +- A line may declare itself deliberate with a trailing `ADR-015-OK: ` + marker. That is for an organisation-wide default which is right at every site + and configurable anyway, such as the corporate device domain. It is not a + way to defer work: the marker puts the claim in the diff where a reviewer + sees it. +- Both publication gates - the export scrub and the docs publishability test - + carry the site patterns, which neither did. The ADR said this was not a + security matter because the values were internal to an air-gapped network; + that was true of the fleet and false of the public mirror, which had been + carrying a production hostname and a share path. + +This amendment is appended rather than edited into the text above, because what +the decision looked like when it was taken is the part worth keeping. diff --git a/docs/adr/ADR-016-credential-delivery.md b/docs/adr/ADR-016-credential-delivery.md new file mode 100644 index 00000000..9ab52b4a --- /dev/null +++ b/docs/adr/ADR-016-credential-delivery.md @@ -0,0 +1,204 @@ + + +# ADR-016: Credential delivery to the fleet + +- Status: SUPERSEDED by [ADR-019](ADR-019-per-pc-endpoint-credentials.md) +- Date: 2026-08-11 +- Deciders: ShopDB maintainers +- Relates to: ADR-006 (collector contract), ADR-012 (GE-Enforce manifest ownership), ADR-015 (site-specific configuration) + +> **Do not build against this. Nothing in it was built.** The model below has a +> PC ask ShopDB for credentials chosen by server-side targeting rules. The +> `GET /api/collector/credentials` endpoint, the `credentials.fetch` and +> `credentials.manage` permissions and the rule store do not exist in the code, +> and a reader searching for them will not find them. +> +> What shipped instead is the opposite shape: the endpoint holds ONE secret of +> its own, obtained by enrolling, and there are no targeting rules to resolve. +> [ADR-019](ADR-019-per-pc-endpoint-credentials.md) records that decision. +> +> The PROBLEM stated below is still the real problem, and ADR-019 cites it. +> Kept as the record of a design that was decided and then not taken. + +## Context + +Shopfloor bays need credentials they cannot prompt for. A collections bay loses +the Windows Credential Manager entry for its controller subnet periodically - +suspected Defender or Intune scrub - and without it, controller-side +connections need an interactive prompt, so an unattended bay simply stops +working. A machine whose NTLARS config points at a program share needs a +credential to reach that share for the same reason. + +Today this is solved by `Set-ControllerCredential.ps1` on the SFLD share: + +```powershell +$Target = '192.168.1.1' +$Username = 'CHANGEME' +$Password = 'CHANGEME' +``` + +A person edits the values into the file, GE-Enforce runs it every cycle, and +`cmdkey /add` re-applies it. It works, and it has three problems. + +The password is **cleartext in a file on a share**, readable by anything that +can read the share - a wider exposure than the machine-local secret it becomes. +Rotating it means editing that file and hoping: there is no way to ask which +bays have picked up the new value. And it is **one value for everything**, +while the requirement is per-bay variation: sites will have machines needing +different credentials from their neighbours. + +Per-bay variation is what rules out the obvious alternative. GE-Enforce scopes +are per PC TYPE, so expressing per-machine credentials as manifest scopes means +a scope per bay, which is not a model. The manifest can deliver a value +(`Registry` entries with inline payloads already do this for the display +scope), but it cannot decide WHICH value a given PC should get. + +## Decision + +ShopDB owns credential definitions and their targeting. A PC asks for its own +credentials and receives a decided answer; it never receives rules to evaluate. + +### Targeting + +A credential rule carries a targeting expression using the SAME axes +GE-Enforce already filters on, plus those the asset model knows: + +| Axis | Source | Notes | +|---|---|---| +| hostname | reported | exact or `-like` glob (`WJRP*`) | +| machine | PC -> machine link | the machine the PC drives | +| model | asset model | e.g. every Okuma of one model | +| controller OS | asset / reported | version-valued, like the existing CmmVersion gate | +| PC type | ComputerType | with GE-Enforce ALIAS EXPANSION | +| GE-Enforce profile | manifest scope | the imaging profile | +| DNC/NTLARS setting | stored backup projection | e.g. any machine whose `DataHost` is a given UNC | +| site default | setting | the fallback | + +The DNC axis is not a special case bolted on: the NTLARS projection ShopDB +already stores contains `DataHost`, `DataPath` and `MarkMasterPath`, so "every +machine that talks to \\ntshare" is a selector over data already held. It is +also the axis that answers "which machines even need a credential", which +nothing answers today. + +### Precedence + +Overlapping matches are guaranteed, not exceptional - a hostname glob and a +per-model rule will both hit the same bay. Precedence is therefore EXPLICIT, +never emergent: + +1. Each rule carries an integer `priority`. Higher wins. This is the only + thing an operator needs to reason about. +2. Ties break on scope specificity, in the order of the table above (hostname + most specific, site default least). +3. Remaining ties break on the lowest rule id, so the result is stable rather + than dependent on row order. + +A resolve endpoint must be able to EXPLAIN itself - "this PC got rule 12, +matched on model, beating rule 4 on priority" - or nobody will trust it. The +GE-Enforce simulator (`applicable_entry_names`) already set this precedent. + +### Reuse the existing matcher + +`plugins/geenforce/filters.py` already mirrors the engine's targeting: alias +groups, hostname globs, the rule that a machine-number filter with no machine +number EXCLUDES rather than defaults. The resolver reuses it. It must not +become a third implementation of the same matching logic - there are already +two (the PowerShell engine and its Python mirror), kept honest by a parity +harness, and a third would drift silently. + +Alias expansion in particular is not optional: if a rule says `PCTypes: +Standard`, it must mean what it means in GE-Enforce, or the two systems will +disagree about who a rule covers. + +### Targeting is not detection + +Who gets a credential and whether it is already applied are separate +questions, exactly as `PCTypes` and `DetectionMethod` are separate in the +manifest. The client decides the second locally - is this `cmdkey` entry +already present and correct - and rewrites only on drift. Conflating them +rewrites the credential every cycle instead of healing it, and turns the audit +trail into noise. + +### Storage + +- Encrypted at rest with Fernet. `cryptography` is already a dependency. +- The key lives OUTSIDE the database - instance file or environment, like + `SECRET_KEY`. A key in the database it protects is not encryption. +- Write-only through the API. Masked on read, as `smtp_password` already is. A + fleet PC receives a value; a human never reads one back. +- A dedicated `credentials.manage` permission for authoring. + +### Delivery + +``` +GET /api/collector/credentials X-API-Key: +``` + +- A DEDICATED `credentials.fetch` scope, not `collector.ingest`. A leaked + collector key must not yield controller passwords. +- The caller is resolved by hostname; it receives only what it is entitled to, + and never the rule set. +- Every fetch is audited: which PC, which credential, when. The audit log + exists; this belongs in it. +- The client half is a shared helper alongside `ShopdbBackupClient.psm1`: + fetch, compare, `cmdkey /add` on drift, log once per state. + +## Consequences + +Positive: + +- The cleartext credential leaves the share. +- Rotation becomes a settings change plus a report of who has picked it up, + instead of a file edit and a hope. +- Per-bay, per-model and per-share credentials become expressible, which is the + actual requirement. +- "Which machines need a credential" becomes answerable from the DNC data + already stored. + +Negative, and these are real: + +- **ShopDB becomes a credential store.** That is a different security posture + from an asset database, and it earns the obligations above rather than + choosing them. +- **The key is a new single point of loss.** Restore the database without it + and every stored credential is unrecoverable. `docs/BACKUP-RESTORE.md` must + say so IN THE SAME CHANGE, not later. +- A PC that cannot reach ShopDB gets no credential. The client must keep the + last known good value rather than clearing a working entry on a failed fetch. +- Targeting complexity is real complexity. Eight axes with priorities will + produce a rule set someone has to debug, which is why the explain endpoint is + a requirement and not a nicety. + +## Alternatives rejected + +**Keep editing the script on the share.** Cannot express per-bay values, keeps +the cleartext, no rotation story. + +**A manifest scope per bay.** Per-machine credentials as GE-Enforce scopes is a +spreadsheet, not a model, and the manifest cannot resolve WHICH value applies. + +**Encrypted payload in the manifest.** DPAPI is per-machine, and one manifest +serves many machines, so the payload cannot be encrypted to its readers. + +**Registry-only provisioning (no ShopDB).** A worthwhile FIRST STEP - it moves +the secret off the share immediately and needs no new endpoint - but it leaves +provisioning per-bay by hand and offers no rotation. Recommended as tier one +regardless, since the client helper is the same either way. + +## Implementation status + +Never implemented, and now superseded. The fetch endpoint and the +`credentials.*` permissions described above do not exist in the code. + +The note that stood here until 2026-09-18 said the decision still stood and a +plugin author designing against it was designing correctly. That is no longer +true: design against +[ADR-019](ADR-019-per-pc-endpoint-credentials.md). What credentials the fleet +uses today, and where they live, is in +[FLEET-ARCHITECTURE](../FLEET-ARCHITECTURE.md). diff --git a/docs/adr/ADR-017-buildings-and-levels.md b/docs/adr/ADR-017-buildings-and-levels.md new file mode 100644 index 00000000..584259d8 --- /dev/null +++ b/docs/adr/ADR-017-buildings-and-levels.md @@ -0,0 +1,151 @@ + + +# ADR-017: Buildings and levels as the map model + +- Status: ACCEPTED +- Date: 2026-08-17 +- Deciders: ShopDB maintainers +- Relates to: ADR-001 (asset as the platform contract), ADR-004 (per-site + instances), ADR-010 (frontend plugin hooks), ADR-015 (site-specific + configuration) +- Supersedes: the site-wide `map_blueprint_light` / `map_blueprint_dark` / + `map_width` / `map_height` settings + +## Context + +A site's floor map is one image. Four settings describe it - two blueprints +(light and dark) and the native pixel width and height - and `assets.mapx` / +`assets.mapy` are absolute pixel coordinates in that image's space. + +That model has one floor in one building, which was true when it was written and +is no longer. The reference site is adding a second level, and a second building +is likely within a year or two. The immediate trigger is a new blueprint at +3308x4000 where the old one was 3300x2550. + +The obvious cheap answer is to stack levels on one canvas: draw the second level +below the first and keep one image. It works, and it was seriously considered. +It was rejected because it makes the level an inference rather than a fact: + +- "Which level is this asset on" becomes `mapy > 2550`. Every per-level filter, + count, report and export has to know that constant, and re-exporting the + drawing at a different height silently changes the answer everywhere. Not + broken - wrong, which is worse. +- Distance between two markers becomes computable and meaningless, so anything + doing nearest-asset or clustering quietly answers nonsense across levels. +- One canvas forces one scale. A mezzanine drawn at a different scale than the + floor below cannot be expressed at all. +- 3308x4000 is portrait for a building whose floor is landscape. At a zoom where + a marker is clickable, roughly half a level is visible, and "fit to level" is + not expressible. +- A third level makes each of these worse, and adds a second threshold. + +Timing decided it. Because the level layout changed and machines moved, every +marker is going to be repositioned anyway. Introducing levels now costs one pass +over the positions; stacking now and splitting later costs two, and in between +every position placed encodes the threshold into real data - which a later +migration would then have to un-guess by comparing Y against it. + +## Decision + +Two tables, and assets reference the level. + +``` +buildings buildingid, buildingname, sortorder, isactive +maplevels levelid, buildingid, levelname, sortorder, + blueprintlight, blueprintdark, mapwidth, mapheight, + isdefault, isactive +assets levelid (nullable; the default level for existing rows) +``` + +**1. An asset references the level, never the building.** The building derives +from the level, so the two cannot disagree. Storing both would be a fact +recorded twice with no difference in granularity to justify it. + +**2. Blueprints and native dimensions belong to the level.** This is what the +site-wide settings could not express: two levels in one building may be drawn at +different sizes and scales, and two buildings certainly are. + +**3. Name and order are separate.** `levelname` is text and `sortorder` is an +integer. Levels are not reliably numbered - basement, ground, mezzanine, roof, +tunnel - and sort order gives adjacency and up/down navigation without +pretending the names are ordinal. It also lets a mezzanine be inserted between +two existing levels without renumbering anything. + +**4. `mapx` / `mapy` keep their meaning, scoped to the level.** They stay +absolute pixels in the native coordinate space of the level's blueprint. No +normalisation to fractions: pixels are what the drawing tools produce, what an +operator can read off an image, and what the existing data already holds. + +**5. A level is required to render a position, and absence is not a default.** +Where a payload carries a position without a level, the UI renders "level +unknown" rather than falling back to the default level. Falling back draws one +building's ground floor with a marker positioned for another building's +mezzanine: it renders perfectly and points at the wrong place, and nothing about +the result looks wrong. A visible gap is worth more than a confident wrong +answer. + +**6. The level list is readable without authentication.** The printer installer +map runs optional-auth, before anyone logs in, and it needs a blueprint. This +follows the precedent already set for printer install-list and the slide feed. +Blueprint paths and level names are not secrets; the positions of assets on them +already render on public kiosk pages. + +**7. Levels are not Locations.** Assets carry `locationid` already, and reusing +it is tempting. A Location answers "which operation owns this"; a level answers +"which drawing renders it, at what native size". Overloading Location with +blueprint images and pixel dimensions makes both concepts worse. They coexist: +an asset on level 2 in operation 0613. + +## Migration + +The existing four settings become one building and one level, marked default, +and every asset with a position points at it. Nothing renders differently on the +day it lands. The settings keys are then retired rather than left as a second +source of truth that can disagree with the rows. + +## Consequences + +Positive: + +- A second building costs a row and a blueprint. So does a third level. +- Per-level export, per-level zoom, and per-level calibration all become + expressible. Calibrating one level against its own landmarks is more correct + than transforming a whole site at once. +- Level becomes queryable - counts and filters per level or per building are + ordinary queries rather than coordinate arithmetic. + +Negative: + +- Every surface that draws a marker learns there is more than one drawing. That + is two components, seven views, four asset position pickers, the printer + installer map, the PDF export, and eight API payloads that must emit `levelid` + beside `mapx`. A payload that forgets it produces the silent-wrong-map failure + described above, which is why rule 5 exists and why a build gate checks that + the two fields travel together. +- `get_map_overlays` gains level context, so this is a plugin contract change + (0.20.0) and plugins rendering overlays need to declare which level they are + for. +- One more admin surface: levels and buildings have to be managed somewhere, and + the existing floor-map settings page becomes that. + +## Alternatives considered + +**Stack levels on one canvas.** Rejected above. Zero cost today, and it makes +the level an inference over a magic number. + +**Normalise coordinates to fractions of the image.** Would make a rescaled +blueprint self-correcting, which is genuinely attractive. Rejected for now +because it converts every existing integer position through a lossy division, +and because it does not help the actual problem: the levels changed and machines +moved, so the positions need human review regardless. Worth revisiting +independently. + +**A `level` string on the asset, with one blueprint per level in settings.** +Rejected: settings keyed by level name is a table with extra steps, and it gives +no place for per-level dimensions or ordering. diff --git a/docs/adr/ADR-018-metrology-instruments-are-machines.md b/docs/adr/ADR-018-metrology-instruments-are-machines.md new file mode 100644 index 00000000..b79659a1 --- /dev/null +++ b/docs/adr/ADR-018-metrology-instruments-are-machines.md @@ -0,0 +1,132 @@ + + +# ADR-018: Metrology instruments are machines + +- Status: ACCEPTED +- Date: 2026-09-04 +- Deciders: ShopDB maintainers +- Relates to: ADR-001 (asset as the platform contract), ADR-006 (collector + contract), ADR-008 (a plugin owns its tables and its migration chain) +- Supersedes: ADR-005 (equipment plugin scope vs measuringtools plugin) + +## Context + +ADR-005 split manufacturing machinery from metrology instruments into two +plugins on the grounds that they are two asset classes: machines make parts, +measuring tools measure them. It named the metrology population as "CMMs, +Keyence vision systems, wax-and-trace surface profilometers, GenSpec +instruments", and the `measuringtools` plugin was built against a docstring +naming a different population again: "calipers, micrometers, thread gages, +bore gages, height gages". + +Those two lists are not the same thing, and the difference is the whole +decision. One is bay-mounted capital equipment with a controlling PC. The other +is hand tools in a drawer. The plugin was built for the second and populated +with neither. + +Sixteen months on, the evidence is in: + +- `measuringtools` holds **zero rows**, against 204 machines, 305 computers, + 73 network devices and 53 printers. +- The metrology inventory actually waiting to be entered is 134 instruments: + 35 CMM, 32 form tracer, 23 vision, 18 wax trace, 14 measuring, 8 Genspect, + 2 eddy current, 1 spline. Every one is bay-mounted with a controlling PC. +- Not one caliper, micrometer, thread gage, bore gage or height gage. The + population the plugin's own docstring justifies it with does not exist at + the reference site and is not planned. +- `CMM` already exists as a `machinetype`, and `Part Marker` is already + classed as a machine while `Form Tracer` is classed as a measuring tool. + Two bay instruments with a controlling PC, split across two plugins, with + the line drawn somewhere no operator can predict. + +The split has therefore been paying an ongoing cost - a duplicate type page, a +settings group, a nav section, a migration chain, a searcher, dashboard cards, +map colours, label printing and four collector `pctypemap` entries - to model a +distinction that the inventory does not make. + +## Decision + +Metrology and inspection instruments are machines. The `measuringtools` plugin +is retired and the `measuring_tool` asset type is removed. + +### Calibration moves to machines, and is not lost + +`machines` gains the calibration lifecycle as nullable columns: + +``` +machines +- calibrationintervaldays integer, null +- lastcalibrationdate date, null +- nextcalibrationdate date, null +- calibrationprovider varchar(150), null +``` + +Calibration status stays **derived at read time** from `nextcalibrationdate` +(`overdue` / `duesoon` / `current` / `unknown`, 30-day window), exactly as +`measuringtools` derived it and for the same reason: a stored status is stale +the day after it is written. The helper moves across unchanged. + +This is a net gain rather than a port. A machine could not record calibration +at all before, and a part marker with a certified fixture, or a machine with a +calibrated probe, had nowhere to put it. + +### `machinetypes.ismetrology` answers ADR-005's objection + +ADR-005 made one concrete technical argument, and it deserves a concrete +answer rather than silence: + +> "show me all measuring tools" requires an enumeration of measuring-instrument +> equipmenttype values, which scales badly. + +That is correct about enumeration and it is why `machinetypes` gains a boolean +`ismetrology`. The metrology view stays a first-class single-column filter - +`WHERE mt.ismetrology = 1` - not a hardcoded list of type names that drifts the +first time someone adds an instrument type. The calibration report, the gage +lab view and any compliance export all hang off that flag. + +### What does not change + +- `assets.gaugelabreference` stays on the core asset. The gage tag was never a + `measuringtools` column, so it survives untouched and stays searchable for + every asset type. +- The collector's device identity file and the `measuringtoolid` payload alias + stay accepted. Bays staged with `measuringtool-id.txt` keep reporting; only + the asset type they resolve to changes. +- Instrument types keep their names. A Form Tracer is still a Form Tracer, now + as a `machinetype` with `ismetrology = 1`. + +## Consequences + +**The migration is free today and expensive later.** `measuringtools` has zero +rows, so retirement is a `DROP TABLE`, not a data migration. Once the 134 +staged instruments are entered they carry relationships, audit history, map +positions and collector links, and every one of those would have to be +rewritten. This ADR is being taken now specifically because the window is open. + +**A site that did populate it needs a real migration.** The reference site did +not and the sister site shows no sign of having done so - its branch contains +no deliberate work on the plugin in its entire history - but that is inference, +not a query against their database. Confirm before the removal ships to another +site. If rows exist, the retirement migration must copy them into `machines` +with `ismetrology` types rather than drop them. + +**Four collector `pctypemap` entries change asset type.** `gea-shopfloor-cmm`, +`-keyence`, `-genspect` and `-waxtrace` resolve to `machine` instead of +`measuring_tool`. The `collector:measuringtool` relationship label is kept as a +recognised origin marker so links a collector already created are still +archived correctly rather than orphaned. + +**One fewer of everything.** A duplicate type page, a settings group, a plugin +migration chain, a gated searcher, a set of dashboard cards, a map colour entry +and a label-printing branch all go. That is the point. + +**ADR-005 is superseded, not reversed in principle.** Its reasoning was sound +for the population it described. The population never arrived. If a site ever +does track hand gages in quantity, the answer is a `handgages` plugin against +that real inventory - not the reinstatement of this one. diff --git a/docs/adr/ADR-019-per-pc-endpoint-credentials.md b/docs/adr/ADR-019-per-pc-endpoint-credentials.md new file mode 100644 index 00000000..868469ae --- /dev/null +++ b/docs/adr/ADR-019-per-pc-endpoint-credentials.md @@ -0,0 +1,217 @@ + + +# ADR-019: Per-PC endpoint credentials + +- Status: ACCEPTED +- Date: 2026-09-18 +- Deciders: ShopDB maintainers +- Relates to: ADR-006 (collector contract), ADR-012 (GE-Enforce manifest + ownership), ADR-015 (site-specific configuration) +- Supersedes: ADR-016 (credential delivery to the fleet) + +## Context + +The endpoints plugin authenticates every agent-facing call with ONE key. The +same `X-API-Key` value is on every PC in the fleet, and the server cannot tell +one caller from another: a request names its own hostname and is believed. + +That key is not only an inventory-ingest key. It is also the key on the command +channel (`AGENT-CONTRACT.md` section 8), where the server hands an agent running +as LocalSystem an allowlisted action to execute - including `run_script`, which +fetches a reviewed PowerShell body and runs it. Anyone holding the key can claim +commands as any PC and post results as any PC. + +And the key is readable. The agent installer writes it to +`HKLM\SOFTWARE\GE\ShopDB` `CollectorKey`, and the DACL that subkey inherits by +default grants `BUILTIN\Users` read, so any logged-on user on any bay could read +the fleet key (the reason is recorded in `plugins/endpoints/api/siteagent.py` +beside the code that now locks the key down). The same value also lived in +`appsettings.json` in the agent directory, in a text file on the GE-Enforce +share, and in the `Args` of the manifest entry that wrote it. + +One fleet-wide secret, readable by a standard user on any bay, authenticating a +SYSTEM command channel. Rotating it meant touching every one of those places at +once, and nothing could say which PCs had picked the new value up. + +ADR-016 answered a different question about credentials - which controller +password a given bay should receive, decided by server-side targeting rules - and +was never built. The question underneath it is "which PC is this", and it needs +no targeting at all. + +## Decision + +Each endpoint holds its OWN secret, obtained by enrolling, and the server +derives the calling asset from the secret. + +### The credential + +- `X-Endpoint-Credential: sdbe_` + 43 characters of base64url (32 random bytes). +- The server stores only the SHA-256 of the secret plus its first 12 characters + as a display prefix (`EndpointCredential` in `plugins/endpoints/models/credential.py`). + The full value exists once, in the enroll response. +- One row per asset, so an asset never carries two credentials. Re-issuing after + a revoke updates the same row. +- A request whose credential resolves to one asset while naming another is + refused `403 ASSET_MISMATCH`. A PC can no longer report as another PC. + +### Enrollment + +`POST /api/endpoints/enroll` never refuses a call for its authentication. The +shared key, if present and still allowed, only decides whether the credential is +issued automatically. Auto-issue requires all of: the shared key authenticated +the call, `plugin.endpoints.autoenroll` is on, the hostname matches exactly one +computer record, the serial on record matches, and the asset has no active +credential. Anything else records a PENDING request an admin approves in the UI. + +The agent generates an enrollment nonce once and persists it. The nonce is +recorded on first sight and an approval is bound to it, so a second machine +claiming the same hostname and serial cannot collect the approved credential. A +rejected request binds to the identity instead, so a reinstall cannot argue its +way back into the queue. + +### Storage on the endpoint + +`%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin`, DPAPI +`LocalMachine`, in a directory ACLed to SYSTEM and Administrators only. The tray +holds no secret at all: it runs as the interactive operator and asks the SYSTEM +service over a local named pipe. `HKLM\SOFTWARE\GE\ShopDB` is locked to SYSTEM +and Administrators in both registry views. + +### Server pinning + +The agent pins the server's public key: the lowercase hex SHA-256 of the leaf +certificate's SubjectPublicKeyInfo, in `ServerPins` (REG_MULTI_SZ, both views), +authoritative whenever the value exists even when empty. Locking the registry +key is what makes the registry safe to treat as authoritative, since a standard +user can no longer write a pin. + +### The shared key stays, deliberately + +`plugin.endpoints.sharedkeyallowed` keeps the shared key working for PCs that +have NOT enrolled. It is always refused for a PC that holds an active credential +(`401 SHARED_KEY_REFUSED`). A PC whose credential was REVOKED falls back to the +shared key, which is what makes a rollback to an older agent possible; +`plugin.endpoints.revokeddeniessharedkey` turns that off and is off by default, +because it is only safe once the fleet is off the shared key. + +## Alternatives rejected + +**Client certificates, 2026-09-14.** This was not a thought experiment: it was +built and then removed three days later. `client_cert.py`, certificate renewal, +expiry monitoring, an unauthenticated `/test/client-cert` route, nginx +client-certificate forwarding and an agent certificate script all shipped on a +branch and were all deleted. + +Three reasons, and the first is fatal on its own: + +1. **The header was forgeable.** Behind IIS the client certificate reaches the + app as a request header, and nothing stopped a caller from simply sending + that header itself. +2. **The private key was readable by users** on the endpoint, so the certificate + was no better protected than the key it replaced. +3. **The shared key remained underneath it** as a fallback, so the weakest path + was still open and the certificate bought nothing. + +It also cost more than it looked. The attempt put five underscore-spelled +certificate columns on the CORE `Asset` model for a plugin's feature and +retracted them again, with no contract bump in either direction - an ADR-001 and +ADR-002 breach inside an unreleased branch. Its migrations +(`endpoints0021clientcerts`, `0022`) survive as retired no-ops so a database +that ran them still upgrades; `0023` drops the columns where a database has +them. + +**ShopDB-resolved credential targeting (ADR-016).** Decided 2026-08-11 and never +implemented. It gave a PC a credential chosen by rules over eight targeting axes +with an explicit priority order and an explain endpoint to debug the result. The +targeting was the whole design, and the exposure that needed closing was not a +targeting problem: the fleet needed identity, not a rule engine. ADR-016 is +superseded rather than deleted because the problem it opens with - a cleartext +controller password on a share, with no rotation story - is still unsolved. This +ADR does not solve it. + +**Keep one key and just lock down where it lives.** The registry ACL is part of +this decision anyway, and it is worth having on its own. It does not help: one +secret shared by every PC still means the server cannot identify a caller, a +revoked bay cannot be evicted without rotating the whole fleet, and any +administrator or any process running as SYSTEM on any bay still reads the key +that drives every other bay's command channel. + +## Consequences + +**The shared key remains until every consumer is migrated.** That is the main +cost of this decision and it is not short. The key lives in a GE-Enforce +manifest entry's `Args`, in `CollectorKey` in both registry views, in a text file +in the share's `common/configs`, in `appsettings.json` in the agent directory, in +every generated `install.ps1` that was kept, and on display kiosks. Each one has +to be cleared in a specific order - retire the entry that rewrites the value +before deleting the value - and the agent never deletes the shared key itself, +because other SYSTEM scripts may still read it. The runbook +(`plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md`) carries the table. + +**Turning the shared key off is wider than the agent.** It is also refused on +`/api/collector/computers` and `/api/collector/printers`, so a PC with reporter +scripts but no enrolled agent goes silent at cutover. + +**Enrollment is a new admin queue.** Anything that cannot be auto-issued waits +for a person: unknown or duplicate hostname, missing or mismatched serial, a +one-off PC nobody wrote a manifest entry for. A dashboard row counts PCs waiting +and PCs blocked from re-enrolling. + +**Pins have to arrive before the agent does.** An agent that pins with no pin set +falls back to plain Windows chain validation, which a self-signed or internal-CA +server may fail, and a pin set after the fact is a per-bay debugging job. +Rotating the server key becomes a two-phase fleet operation: publish old and new +pins everywhere, switch the certificate, then retire the old pin. Switching +first cuts off every pinned PC, including its route to the corrected pin list. + +**GE-Enforce can undo an upgrade.** Agents installed from the share are held to a +file hash by a manifest entry. A store upgrade that lands a newer agent while the +entry still carries the old hash is reverted on the next cycle, so the entries +have to be re-stamped or removed before the waves reach those PCs. + +**Revoke means two different things depending on one setting.** With +`revokeddeniessharedkey` off, revoking drops a PC back to the shared key, which +is how rollback and credential rotation work. With it on, revoking is a real +eviction and the PC reports nothing until it enrolls again. + +**A PC that cannot reach ShopDB keeps its credential.** Unlike ADR-016's fetch +model, there is nothing to re-fetch per cycle: the secret is local and long-lived +until an admin revokes it. + +## Implementation status + +**Built, and in this tree.** `plugins/endpoints/api/agentauth.py`, +`api/enrollment.py` and `models/credential.py` are all present, +`shopdb/core/api/collector.py` reads `X-Endpoint-Credential` on the generic +collector route, `AGENT-CONTRACT.md` carries section 9, and +`tests/test_plugins/test_endpoints_credentials.py` covers the auth decisions. +The C# agent enrolls and sends the header, and both shipped PowerShell reporters +send it when a credential is present and fall back to the shared key when it is +not. + +Proven on a Windows test PC against a test server: self-enrollment, the shared +key refused for an enrolled PC, a request naming another asset refused, pins +enforced, the tray pipe working for a non-admin user, both SYSTEM reporters +sending with the per-PC credential, revoke followed by re-enrollment, rollback +to the previous agent, and enrolled PCs still working with the shared key +switched off. + +What is NOT finished is the fleet migration, which is the consequence this ADR +opens with: every PC that has not enrolled is still on the shared key by design, +and the key still lives in the places listed above. The runbook is +`plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md`. + +## References + +- `plugins/endpoints/api/agentauth.py` - credential and shared-key authentication +- `plugins/endpoints/api/enrollment.py` - enroll, approve, revoke, migration waves +- `plugins/endpoints/models/credential.py` - credential, enroll request, wave +- `plugins/endpoints/AGENT-CONTRACT.md` section 9 - the wire contract +- `plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md` - the rollout runbook +- ADR-016 - the superseded targeting model diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..e32a9db4 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,69 @@ + + +# Architecture Decision Records + +An ADR captures one decision: the context, the decision, the consequences and +the alternatives. ADRs are immutable once accepted. A superseded ADR stays in +this folder, marked at the top, pointing forward. + +**Read the index, not the folder.** Find the one line that matches your +question, open that ADR, and skip the rest. Nothing here is a prerequisite for +anything else. + +## Which decision governs your question + +| ADR | The question it answers | Holds? | +|---|---|---| +| [001](ADR-001-asset-as-platform-contract.md) | What may a plugin depend on? What is the versioned platform surface? | In force | +| [002](ADR-002-plugin-versioning.md) | How is that surface versioned, and what does a plugin's `core_version` mean? | In force | +| [003](ADR-003-plugin-distribution.md) | How does a plugin reach a site - bundled, or from its own repo? | In force. ADR-013 added the catalog, artifact and signing on top | +| [004](ADR-004-deployment-topology.md) | One instance per site, or one multi-tenant instance for all sites? | In force, except its "Migration strategy" section, replaced by ADR-008 | +| [005](ADR-005-equipment-vs-measuringtools.md) | Are metrology instruments a separate asset class from machinery? | **Superseded by [018](ADR-018-metrology-instruments-are-machines.md).** Answer reversed | +| [006](ADR-006-collector-contract.md) | How does a plugin accept data pushed in from outside? | In force | +| [007](ADR-007-product-versioning-and-releases.md) | What version is the PRODUCT, as distinct from the plugin contract? | In force | +| [008](ADR-008-plugin-migration-ownership.md) | Whose Alembic chain owns a plugin's tables? | In force | +| [009](ADR-009-frontend-plugin-gating.md) | What happens when someone opens a URL belonging to a disabled plugin? | In force | +| [010](ADR-010-frontend-plugin-hooks.md) | How does a plugin add UI without editing core Vue files? | In force | +| [011](ADR-011-machines-rename.md) | Why is the code called `machines` and what happened to `machinetypes`? | In force | +| [012](ADR-012-geenforce-manifest-ownership.md) | Does ShopDB own GE-Enforce manifests, and does it own the engine? | **Record only on this line.** The plugin it decided is not bundled here; ADR-013 and the API-token resource scopes trace back to it | +| [013](ADR-013-plugin-catalog-and-lean-builds.md) | How does a site ship only the plugins it chose, and how is plugin code trusted? | In force | +| [014](ADR-014-schema-lean-per-site.md) | Does a lean site still create tables for plugins it never installed? | In force | +| [015](ADR-015-site-specific-configuration.md) | Where may one site's hostnames, shares and defaults live? | In force | +| [016](ADR-016-credential-delivery.md) | How does a bay get a controller credential without a cleartext file on a share? | **Superseded by [019](ADR-019-per-pc-endpoint-credentials.md), and never built.** Do not build against it | +| [017](ADR-017-buildings-and-levels.md) | How does the floor map hold more than one floor and more than one building? | In force | +| [018](ADR-018-metrology-instruments-are-machines.md) | Where do CMMs and vision systems live now that `measuringtools` is retired? | In force, and this line is the one it describes: there is no measuring-tool asset class | +| [019](ADR-019-per-pc-endpoint-credentials.md) | How does the server know WHICH PC is calling, and what replaced the fleet-wide key? | In force | + +Related decisions that are not ADRs, kept as records of why something was built: +[docs/proposals](../proposals/README.md). + +## Status vocabulary + +The `Status:` line at the top of each ADR uses these, and nothing else: + +- **PROPOSED** - drafted, awaiting decision. +- **ACCEPTED** - in effect. +- **SUPERSEDED by ADR-NNN** - replaced. The link forward is part of the status, + and the superseded ADR carries a note at the top saying not to build against + it and what changed. +- **DEPRECATED** - no longer in effect, with no replacement. + +## Authoring + +1. Copy the most recent ADR as a template and increment the number. +2. Start the file with a `docmeta` comment, before the H1. ADRs are + `purpose: explanation`. +3. Add a row to the table above: the QUESTION it answers, not its title. +4. Add it to [docs/INDEX.md](../INDEX.md) too - under "Decisions" while it is + current, under "Archive" once it is superseded. It has to be a table ROW + with its real line count, not a mention in a paragraph: + `tests/test_docs_index.py` fails on an ADR no row routes to, and on a row + whose line count is stale. +5. Never edit an accepted ADR's decision. Supersede it with a new one, and mark + the old one at the top - ADR-005 and ADR-016 are the worked examples. diff --git a/docs/api-inventory.json b/docs/api-inventory.json new file mode 100644 index 00000000..2eac25b1 --- /dev/null +++ b/docs/api-inventory.json @@ -0,0 +1,13107 @@ +[ + { + "surface": "core-apitokens", + "endpoints": [ + { + "method": "GET", + "path": "/api/apitokens", + "auth": "jwt", + "params": "not documented", + "purpose": "List the caller's own tokens. Admins may pass ?all=true for everyone's.", + "example": "curl http://localhost:5001/api/apitokens -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "all", + "type": "string", + "required": false, + "default": "false" + } + ], + "responseexample": { + "data": [ + { + "createddate": "2026-01-01T00:00:00.000000Z", + "displayprefix": "", + "expiresat": null, + "isactive": true, + "isexpired": false, + "lastusedat": null, + "name": "member token", + "resourcescopes": null, + "scopes": null, + "tokenid": 2, + "tokenprefix": "", + "userid": 2, + "username": "" + }, + { + "createddate": "2026-01-01T00:00:00.000000Z", + "displayprefix": "", + "expiresat": null, + "isactive": true, + "isexpired": false, + "lastusedat": null, + "name": "admin token", + "resourcescopes": null, + "scopes": null, + "tokenid": 1, + "tokenprefix": "", + "userid": 1, + "username": "" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/apitokens", + "auth": "permission:apitokens.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a token for the caller. Returns the full secret ONCE.", + "example": "curl -X POST http://localhost:5001/api/apitokens -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "name": "direct token", + "scopes": [ + "applications.create" + ] + }, + "responseexample": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "displayprefix": "", + "expiresat": null, + "isactive": true, + "isexpired": false, + "lastusedat": null, + "name": "direct token", + "resourcescopes": null, + "scopes": [ + "applications.create" + ], + "secret": "", + "tokenid": 1, + "tokenprefix": "", + "userid": 1, + "warning": "Save this token now. It will not be shown again. Store it somewhere safe." + }, + "message": "Token created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/apitokens/", + "auth": "permission:apitokens.create", + "params": "path: tokenid (int)", + "purpose": "Revoke (deactivate) a token. Own token, or any if admin.", + "example": "curl -X DELETE http://localhost:5001/api/apitokens/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "message": "Token revoked", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/apitokens/", + "auth": "permission:apitokens.create", + "params": "path: tokenid (int); body: JSON (fields not yet documented)", + "purpose": "Rename, rescope, or deactivate a token. Own token, or any if admin.", + "example": "curl -X PUT http://localhost:5001/api/apitokens/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "scopes": [ + "applications.edit" + ] + }, + "responseexample": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "displayprefix": "", + "expiresat": null, + "isactive": true, + "isexpired": false, + "lastusedat": null, + "name": "test token", + "resourcescopes": null, + "scopes": [ + "applications.edit" + ], + "tokenid": 1, + "tokenprefix": "", + "userid": 1 + }, + "message": "Token updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + } + ] + }, + { + "surface": "core-applications", + "endpoints": [ + { + "method": "GET", + "path": "/api/applications", + "auth": "permission:applications.view", + "params": "not documented", + "purpose": "List all applications.", + "example": "curl http://localhost:5001/api/applications -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "appname", + "type": "string", + "required": false + }, + { + "name": "installable", + "type": "string", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "showhidden", + "type": "string", + "required": false, + "default": "false" + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/applications", + "auth": "permission:applications.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new application.", + "example": "curl -X POST http://localhost:5001/api/applications -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "appname": "No Backdate", + "createddate": "2026-01-01 00:00:00" + }, + "responseexample": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Before Revoke", + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": null, + "installersha256": null, + "installpath": null, + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "supportteamid": null, + "supportteamname": null, + "teamurl": null + }, + "message": "Application created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/applications/", + "auth": "permission:applications.delete", + "params": "path: app_id (int)", + "purpose": "Permanently delete an application, its versions, and its uploads.", + "example": "curl -X DELETE http://localhost:5001/api/applications/1 -H \"Authorization: Bearer $TOK\"", + "requestexample": { + "confirm": "OK" + }, + "responseexample": { + "data": { + "deactivatedpolicies": [ + "Required app: Doomed", + "Hand made push" + ], + "policyresult": "kept" + }, + "message": "Application deleted; deactivated 2 policy(ies) that referenced it: Required app: Doomed, Hand made push", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/applications/", + "auth": "permission:applications.view", + "params": "path: app_id (int)", + "purpose": "Get a single application with details.", + "example": "curl http://localhost:5001/api/applications/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "TeamApp", + "contacts": [ + { + "name": "Alice", + "sso": "a01" + } + ], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": null, + "installedcount": 0, + "installersha256": null, + "installpath": null, + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "knowledgebase": [], + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "supportteamid": 1, + "supportteamname": "Controls", + "teamurl": "https://sn.example/controls", + "versions": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/applications/", + "auth": "permission:applications.edit", + "params": "path: app_id (int); body: JSON (fields not yet documented)", + "purpose": "Update an application.", + "example": "curl -X PUT http://localhost:5001/api/applications/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "ismonitorable": false, + "monitorlaunchpath": "", + "monitorprocessname": "" + }, + "responseexample": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Test App", + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": null, + "installersha256": null, + "installpath": null, + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "policynote": "A configuration policy was created for this application, but it is not attached to any business policy and installs nothing until an admin adds it to one.", + "policyresult": "created", + "supportteamid": null, + "supportteamname": null, + "teamurl": null + }, + "message": "Application updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/applications//image", + "auth": "permission:applications.edit", + "params": "path: app_id (int)", + "purpose": "Remove an application's image and clear the field.", + "example": "curl -X DELETE http://localhost:5001/api/applications/1/image -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/applications//image", + "auth": "permission:applications.edit", + "params": "path: app_id (int); body: JSON (fields not yet documented)", + "purpose": "Upload (or replace) an application's image.", + "example": "curl -X POST http://localhost:5001/api/applications/1/image -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Test App", + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": "/api/applications/image/application-1.png", + "installersha256": null, + "installpath": null, + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "supportteamid": null, + "supportteamname": null, + "teamurl": null + }, + "message": "Application image uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/applications//installed", + "auth": "permission:applications.view", + "params": "path: app_id (int)", + "purpose": "List all computers that have this application installed.", + "example": "curl http://localhost:5001/api/applications/1/installed -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "DELETE", + "path": "/api/applications//package", + "auth": "permission:applications.edit", + "params": "path: app_id (int)", + "purpose": "Remove an uploaded installer and clear the path it set.", + "example": "curl -X DELETE http://localhost:5001/api/applications/1/package -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Test App", + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": null, + "installersha256": null, + "installpath": "", + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "supportteamid": null, + "supportteamname": null, + "teamurl": null + }, + "message": "Installer removed", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/applications//package", + "auth": "permission:applications.edit", + "params": "path: app_id (int); body: JSON (fields not yet documented)", + "purpose": "Upload (or replace) the installer for an application.", + "example": "curl -X POST http://localhost:5001/api/applications/1/package -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Test App", + "appversionid": null, + "bytes": 130, + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "detectedversion": "", + "documentationpath": null, + "image": null, + "installersha256": "", + "installpath": "/api/applications/package/1/setup.exe", + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "sha256": "", + "supportteamid": null, + "supportteamname": null, + "teamurl": null, + "uploadedfilename": "setup.exe" + }, + "message": "Installer uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/applications//revert", + "auth": "permission:applications.edit", + "params": "path: app_id (int); body: JSON (fields not yet documented)", + "purpose": "Strip an application back to a catalogue record.", + "example": "curl -X POST http://localhost:5001/api/applications/1/revert -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "confirm": "OK" + }, + "responseexample": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Test App", + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": "/api/applications/image/application-1.png", + "installersha256": null, + "installpath": null, + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "supportteamid": null, + "supportteamname": null, + "teamurl": null + }, + "message": "Application reverted to record", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/applications//versions", + "auth": "permission:applications.view", + "params": "path: app_id (int)", + "purpose": "List all versions for an application.", + "example": "curl http://localhost:5001/api/applications/1/versions -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/applications//versions", + "auth": "permission:applications.create", + "params": "path: app_id (int); body: JSON (fields not yet documented)", + "purpose": "Create a new version for an application.", + "example": "curl -X POST http://localhost:5001/api/applications/1/versions -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "sha256": "", + "silentargs": "/S /v/qn", + "version": "1.2.3" + }, + "responseexample": { + "data": { + "appid": 1, + "appversionid": 1, + "dateadded": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "notes": null, + "releasedate": null, + "sha256": "", + "silentargs": "/S /v/qn", + "version": "1.2.3" + }, + "message": "Version created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/applications/image/", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "path: filename (path)", + "purpose": "Serve an uploaded application image.", + "example": "curl http://localhost:5001/api/applications/image/example.png" + }, + { + "method": "GET", + "path": "/api/applications/machines/", + "auth": "permission:applications.view", + "params": "path: machine_id (int)", + "purpose": "List all applications installed on a computer.", + "example": "curl http://localhost:5001/api/applications/machines/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "appdescription": null, + "appid": 1, + "appname": "Test App", + "appversionid": null, + "computerid": 1, + "id": 1, + "installeddate": "2026-01-01T00:00:00.000000Z", + "installedversion": "1.2.3", + "isactive": true + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/applications/machines/", + "auth": "permission:applications.create", + "params": "path: machine_id (int); body: JSON (fields not yet documented)", + "purpose": "Install an application on a computer.", + "example": "curl -X POST http://localhost:5001/api/applications/machines/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/applications/machines//", + "auth": "permission:applications.delete", + "params": "path: machine_id (int), app_id (int)", + "purpose": "Uninstall an application from a computer.", + "example": "curl -X DELETE http://localhost:5001/api/applications/machines/1/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/applications/machines//", + "auth": "permission:applications.edit", + "params": "path: machine_id (int), app_id (int); body: JSON (fields not yet documented)", + "purpose": "Update installed application (e.g., change version).", + "example": "curl -X PUT http://localhost:5001/api/applications/machines/1/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/applications/package/", + "auth": "permission:applications.download", + "params": "path: filename (path)", + "purpose": "Download an uploaded installer.", + "example": "curl http://localhost:5001/api/applications/package/example.png -H \"Authorization: Bearer $TOK\"" + } + ] + }, + { + "surface": "core-assets", + "endpoints": [ + { + "method": "GET", + "path": "/api/assets", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List all assets with filtering and pagination.", + "example": "curl http://localhost:5001/api/assets -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "businessunit_id", + "type": "integer", + "required": false + }, + { + "name": "businessunitid", + "type": "integer", + "required": false + }, + { + "name": "dir", + "type": "string", + "required": false, + "default": "asc" + }, + { + "name": "include_type_data", + "type": "string", + "required": false, + "default": "false" + }, + { + "name": "location_id", + "type": "integer", + "required": false + }, + { + "name": "locationid", + "type": "integer", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "sort", + "type": "string", + "required": false, + "default": "assetnumber" + }, + { + "name": "status_id", + "type": "integer", + "required": false + }, + { + "name": "statusid", + "type": "integer", + "required": false + }, + { + "name": "type", + "type": "string", + "required": false + }, + { + "name": "type_id", + "type": "integer", + "required": false + }, + { + "name": "typeid", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [ + { + "assetid": 1, + "assetnumber": "A-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "machine", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "mill", + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "serialnumber": "", + "statusid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/assets", + "auth": "permission:assets.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new asset.", + "example": "curl -X POST http://localhost:5001/api/assets -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/assets/", + "auth": "permission:assets.delete", + "params": "path: asset_id (int)", + "purpose": "Delete (soft delete) an asset.", + "example": "curl -X DELETE http://localhost:5001/api/assets/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/assets/", + "auth": "permission:assets.view", + "params": "path: asset_id (int)", + "purpose": "Get a single asset with full details.", + "example": "curl http://localhost:5001/api/assets/1 -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "include_type_data", + "type": "string", + "required": false, + "default": "true" + } + ] + }, + { + "method": "PUT", + "path": "/api/assets/", + "auth": "permission:assets.edit", + "params": "path: asset_id (int); body: JSON (fields not yet documented)", + "purpose": "Update an asset.", + "example": "curl -X PUT http://localhost:5001/api/assets/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/assets//communications", + "auth": "permission:assets.view", + "params": "path: asset_id (int)", + "purpose": "Get all communications for an asset.", + "example": "curl http://localhost:5001/api/assets/1/communications -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/assets//relationships", + "auth": "permission:assets.view", + "params": "path: asset_id (int)", + "purpose": "Get all relationships for an asset.", + "example": "curl http://localhost:5001/api/assets/1/relationships -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "incoming": [ + { + "createddate": "2026-01-01T00:00:00.000000Z", + "inheritsposition": true, + "isactive": true, + "isdirectional": true, + "label": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "relationshipid": 1, + "relationshiptypeid": 1, + "relationshiptypename": "controls", + "sourceasset": { + "assetid": 1, + "assetnumber": "PC-1", + "assettypecolor": null, + "assettypeid": 2, + "assettypename": "computer", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "serialnumber": "", + "statusid": 1 + }, + "sourceassetid": 1, + "targetassetid": 2 + } + ], + "outgoing": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/assets/lookup/", + "auth": "permission:assets.view", + "params": "path: assetnumber (string)", + "purpose": "Look up an asset by its asset number.", + "example": "curl http://localhost:5001/api/assets/lookup/x -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/assets/map", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Get all assets with map positions for unified floor map display.", + "example": "curl http://localhost:5001/api/assets/map -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "assettype", + "type": "string", + "required": false + }, + { + "name": "businessunitid", + "type": "integer", + "required": false + }, + { + "name": "locationid", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "statusid", + "type": "integer", + "required": false + }, + { + "name": "subtype", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": { + "assets": [ + { + "assetid": 1, + "assetnumber": "2007", + "assettype": "machine", + "assettypeid": 1, + "businessunit": null, + "businessunitid": null, + "displayname": "", + "dualpathpartner": null, + "levelid": null, + "location": null, + "locationid": null, + "mapx": 100, + "mapy": 100, + "name": null, + "primaryip": null, + "serialnumber": "", + "status": null, + "statuscolor": null, + "statusid": 1, + "typedata": { + "assetid": 1, + "controllermodelid": null, + "controllervendorid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "islocationonly": false, + "lastmaintenancedate": null, + "machineid": 1, + "machinetypeid": null, + "maintenanceintervaldays": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "nextmaintenancedate": null, + "requiresmanualconfig": false, + "vendorid": null + } + }, + { + "assetid": 2, + "assetnumber": "2008", + "assettype": "machine", + "assettypeid": 1, + "businessunit": null, + "businessunitid": null, + "displayname": "", + "dualpathpartner": null, + "levelid": null, + "location": null, + "locationid": null, + "mapx": 110, + "mapy": 110, + "name": null, + "primaryip": null, + "serialnumber": "", + "status": null, + "statuscolor": null, + "statusid": 1, + "typedata": { + "assetid": 2, + "controllermodelid": null, + "controllervendorid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "islocationonly": false, + "lastmaintenancedate": null, + "machineid": 2, + "machinetypeid": null, + "maintenanceintervaldays": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "nextmaintenancedate": null, + "requiresmanualconfig": false, + "vendorid": null + } + } + ], + "filters": { + "assettypes": [ + { + "assettype": "machine", + "assettypeid": 1, + "color": null, + "icon": null + } + ], + "businessunits": [], + "locations": [], + "statuses": [], + "subtypes": { + "Computer": [], + "Machine": [], + "Network Device": [], + "Printer": [] + } + }, + "total": 2 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/assets/relationships", + "auth": "permission:assets.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a relationship between two assets.", + "example": "curl -X POST http://localhost:5001/api/assets/relationships -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "relationshiptypeid": 1, + "sourceassetid": 1, + "targetassetid": 2 + }, + "responseexample": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "inheritsposition": true, + "isactive": true, + "label": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "propagated": [ + { + "createddate": "2026-01-01T00:00:00.000000Z", + "inheritsposition": true, + "isactive": true, + "label": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "relationshipid": 3, + "relationshiptypeid": 1, + "sourceassetid": 1, + "targetassetid": 3 + } + ], + "propagatedcount": 1, + "relationshipid": 2, + "relationshiptypeid": 1, + "sourceassetid": 1, + "targetassetid": 2 + }, + "message": "Relationship created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/assets/relationships/", + "auth": "permission:assets.delete", + "params": "path: rel_id (int)", + "purpose": "Delete an asset relationship.", + "example": "curl -X DELETE http://localhost:5001/api/assets/relationships/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "message": "Relationship deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/assets/relationshiptypes", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List all asset relationship types.", + "example": "curl http://localhost:5001/api/assets/relationshiptypes -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "color": null, + "description": null, + "isdirectional": false, + "propagatesthrough": [], + "relationshiptype": "WiFi", + "relationshiptypeid": 1 + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/assets/relationshiptypes", + "auth": "permission:assets.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new asset relationship type.", + "example": "curl -X POST http://localhost:5001/api/assets/relationshiptypes -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "isdirectional": false, + "relationshiptype": "Dualpath" + }, + "responseexample": { + "data": { + "color": null, + "description": null, + "isdirectional": true, + "propagatesthrough": [], + "relationshiptype": "Backup For", + "relationshiptypeid": 1 + }, + "message": "Relationship type created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/assets/relationshiptypes/", + "auth": "permission:assets.delete", + "params": "path: type_id (int)", + "purpose": "Delete a relationship type. Refused if any relationship still uses it.", + "example": "curl -X DELETE http://localhost:5001/api/assets/relationshiptypes/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/assets/relationshiptypes/", + "auth": "permission:assets.edit", + "params": "path: type_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a relationship type.", + "example": "curl -X PUT http://localhost:5001/api/assets/relationshiptypes/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "isdirectional": false + }, + "responseexample": { + "data": { + "color": null, + "description": null, + "isdirectional": false, + "propagatesthrough": [], + "relationshiptype": "USB", + "relationshiptypeid": 1 + }, + "message": "Relationship type updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/assets/statuses", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List all asset statuses.", + "example": "curl http://localhost:5001/api/assets/statuses -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/assets/statuses", + "auth": "permission:assets.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new asset status.", + "example": "curl -X POST http://localhost:5001/api/assets/statuses -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/assets/statuses/", + "auth": "permission:assets.delete", + "params": "path: status_id (int)", + "purpose": "Delete an asset status. Refused if any asset still uses it.", + "example": "curl -X DELETE http://localhost:5001/api/assets/statuses/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/assets/statuses/", + "auth": "permission:assets.view", + "params": "path: status_id (int)", + "purpose": "Get a single asset status.", + "example": "curl http://localhost:5001/api/assets/statuses/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/assets/statuses/", + "auth": "permission:assets.edit", + "params": "path: status_id (int); body: JSON (fields not yet documented)", + "purpose": "Update an asset status.", + "example": "curl -X PUT http://localhost:5001/api/assets/statuses/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/assets/types", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List all asset types.", + "example": "curl http://localhost:5001/api/assets/types -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/assets/types", + "auth": "permission:assets.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new asset type.", + "example": "curl -X POST http://localhost:5001/api/assets/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/assets/types/", + "auth": "permission:assets.view", + "params": "path: type_id (int)", + "purpose": "Get a single asset type.", + "example": "curl http://localhost:5001/api/assets/types/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/assets/types/", + "auth": "permission:assets.edit", + "params": "path: type_id (int); body: JSON (fields not yet documented)", + "purpose": "Update an asset type's display fields (color/icon/description). The name, plugin, and table are structural and not editable here.", + "example": "curl -X PUT http://localhost:5001/api/assets/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "core-auditlogs", + "endpoints": [ + { + "method": "GET", + "path": "/api/auditlogs", + "auth": "permission:audit.view", + "params": "not documented", + "purpose": "List audit logs with filtering and pagination.", + "example": "curl http://localhost:5001/api/auditlogs -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "action", + "type": "string", + "required": false + }, + { + "name": "entitytype", + "type": "string", + "required": false + }, + { + "name": "from_date", + "type": "string", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "perpage", + "type": "integer", + "required": false, + "default": 50 + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "to_date", + "type": "string", + "required": false + }, + { + "name": "userid", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 50, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/auditlogs/entity//", + "auth": "permission:audit.view", + "params": "path: entitytype (string), entityid (int)", + "purpose": "Get audit history for a specific entity.", + "example": "curl http://localhost:5001/api/auditlogs/entity/x/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/auditlogs/purge", + "auth": "permission:audit.purge", + "params": "body: JSON (fields not yet documented)", + "purpose": "Delete entries older than the retention window.", + "example": "curl -X POST http://localhost:5001/api/auditlogs/purge -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + }, + { + "method": "GET", + "path": "/api/auditlogs/retention", + "auth": "permission:audit.view", + "params": "not documented", + "purpose": "Size of the log and what the configured retention would remove.", + "example": "curl http://localhost:5001/api/auditlogs/retention -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "expired": 0, + "newest": "2026-01-01T00:00:00.000000", + "oldest": "2026-01-01T00:00:00.000000", + "retentiondays": 30, + "total": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/auditlogs/stats", + "auth": "permission:audit.view", + "params": "not documented", + "purpose": "Get audit log statistics.", + "example": "curl http://localhost:5001/api/auditlogs/stats -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "actions": {}, + "activeUsers": [], + "entities": {}, + "recentCount": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "core-auth", + "endpoints": [ + { + "method": "POST", + "path": "/api/auth/change-password", + "auth": "jwt", + "params": "body: JSON (fields not yet documented)", + "purpose": "Change the authenticated user's own password.", + "example": "curl -X POST http://localhost:5001/api/auth/change-password -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "current_password": "", + "new_password": "" + }, + "responseexample": { + "data": { + "mustchangepassword": "" + }, + "message": "Password changed", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/auth/login", + "auth": "none (the login surface itself; rate limited, 5-strike lockout)", + "params": "body: JSON (fields not yet documented)", + "purpose": "Authenticate user and return JWT tokens.", + "example": "curl -X POST http://localhost:5001/api/auth/login -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "password": "", + "username": "" + }, + "responseexample": { + "data": { + "access_token": "", + "expires_in": 3600, + "refresh_token": "", + "token_type": "", + "user": { + "email": "", + "firstname": "", + "lastname": "", + "mustchangepassword": "", + "permissions": [ + "assets.view", + "assets.create", + "assets.edit", + "" + ], + "roles": [ + "admin" + ], + "userid": 1, + "username": "" + } + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/auth/logout", + "auth": "jwt", + "params": "body: JSON (fields not yet documented)", + "purpose": "Logout user (for frontend token cleanup).", + "example": "curl -X POST http://localhost:5001/api/auth/logout -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + }, + { + "method": "GET", + "path": "/api/auth/me", + "auth": "jwt", + "params": "not documented", + "purpose": "Get current authenticated user info.", + "example": "curl http://localhost:5001/api/auth/me -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "email": "", + "firstname": "", + "lastname": "", + "mustchangepassword": "", + "permissions": [ + "assets.view", + "assets.create", + "assets.edit", + "" + ], + "roles": [ + "admin" + ], + "userid": 1, + "username": "" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/auth/refresh", + "auth": "jwt (refresh token)", + "params": "body: JSON (fields not yet documented)", + "purpose": "Refresh access token using refresh token.", + "example": "curl -X POST http://localhost:5001/api/auth/refresh -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + } + ] + }, + { + "surface": "core-businessunits", + "endpoints": [ + { + "method": "GET", + "path": "/api/businessunits", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "not documented", + "purpose": "List all business units.", + "example": "curl http://localhost:5001/api/businessunits -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "businessunit", + "type": "string", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/businessunits", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new business unit.", + "example": "curl -X POST http://localhost:5001/api/businessunits -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/businessunits/", + "auth": "jwt + role:admin", + "params": "path: bu_id (int)", + "purpose": "Delete (deactivate) a business unit.", + "example": "curl -X DELETE http://localhost:5001/api/businessunits/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/businessunits/", + "auth": "permission:assets.view", + "params": "path: bu_id (int)", + "purpose": "Get a single business unit.", + "example": "curl http://localhost:5001/api/businessunits/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/businessunits/", + "auth": "jwt + role:admin", + "params": "path: bu_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a business unit.", + "example": "curl -X PUT http://localhost:5001/api/businessunits/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "core-collector", + "endpoints": [ + { + "method": "POST", + "path": "/api/collector/", + "auth": "api-key (per-plugin collector token)", + "params": "path: pluginname (string); body: JSON (fields not yet documented)", + "purpose": "Generic collector ingest for any plugin (ADR-006).", + "example": "curl -X POST http://localhost:5001/api/collector/x -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "hostname": "", + "queues": [ + { + "drivername": "HP Universal Printing PS", + "isdefault": true, + "portaddress": "", + "portname": "IP_10.20.0.11", + "queuename": "PRINTER-A" + }, + { + "drivername": "HP Universal Printing PS", + "isdefault": false, + "portaddress": "", + "portname": "IP_10.20.0.12", + "queuename": "PRINTER-B" + }, + { + "drivername": "HP Universal Printing PS", + "isdefault": false, + "portaddress": "", + "portname": "IP_10.20.0.99", + "queuename": "Reception Copier" + } + ] + }, + "responseexample": { + "data": { + "accessprotocols": [], + "action": "updated", + "assetid": 2, + "identityvalue": "LEGACYPC", + "machinelinks": [], + "measuringtoollinkcount": 0, + "measuringtoollinks": [], + "partmarkers": [ + { + "assetid": 3, + "assetnumber": "LEGACYPC-PARTMARKER", + "operationassetid": null + } + ], + "printerlinkcount": 0, + "printerlinks": [], + "status": "ok", + "warnings": [ + "mapped ComputerType not found: Shopfloor PC", + "machine number '0615' is this PC's own asset number; marker not filed under an operation. Rename the PC asset to its hostname, or create the operation asset." + ] + }, + "message": "computers collector updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/collector/_schemas", + "auth": "jwt", + "params": "not documented", + "purpose": "List collector schemas for all enabled plugins (JWT-protected).", + "example": "curl http://localhost:5001/api/collector/_schemas -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/collector/apps", + "auth": "api-key (collector token, legacy shared key)", + "params": "body: JSON (fields not yet documented)", + "purpose": "Update installed applications for a PC (matched by hostname).", + "example": "curl -X POST http://localhost:5001/api/collector/apps -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "POST", + "path": "/api/collector/bulk", + "auth": "api-key (collector token, legacy shared key)", + "params": "body: JSON (fields not yet documented)", + "purpose": "Bulk update multiple PCs at once.", + "example": "curl -X POST http://localhost:5001/api/collector/bulk -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "pcs": [ + { + "hostname": "" + } + ] + }, + "responseexample": { + "data": { + "errors": [], + "notfound": [ + "WJLEG01" + ], + "timestamp": "2026-01-01T00:00:00.000000", + "updated": 0 + }, + "message": "0 PC(s) updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/collector/heartbeat", + "auth": "api-key (collector token, legacy shared key)", + "params": "body: JSON (fields not yet documented)", + "purpose": "Record PC online status / heartbeat (single or batch).", + "example": "curl -X POST http://localhost:5001/api/collector/heartbeat -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "hostname": "" + }, + "responseexample": { + "data": { + "notfound": [ + "WJLEG01" + ], + "timestamp": "2026-01-01T00:00:00.000000", + "updated": 0 + }, + "message": "0 PC(s) heartbeat recorded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/collector/pc", + "auth": "api-key (collector token, legacy shared key)", + "params": "body: JSON (fields not yet documented)", + "purpose": "Update one PC from the collection script (matched by hostname).", + "example": "curl -X POST http://localhost:5001/api/collector/pc -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/collector/status", + "auth": "api-key (per-plugin collector token)", + "params": "not documented", + "purpose": "Check collector API status.", + "example": "curl http://localhost:5001/api/collector/status -H \"X-API-Key: $KEY\"", + "responseexample": { + "data": { + "endpoints": [ + "POST /api/collector/", + "GET /api/collector/_schemas", + "POST /api/collector/pc", + "" + ], + "status": "ok", + "timestamp": "2026-01-01T00:00:00.000000" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "core-customfields", + "endpoints": [ + { + "method": "GET", + "path": "/api/customfields", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List custom-field definitions. Filter by ?assettypeid=. ?active=false includes inactive ones.", + "example": "curl http://localhost:5001/api/customfields -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "assettypeid", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/customfields", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: customfields.create_field has no docstring.", + "example": "curl -X POST http://localhost:5001/api/customfields -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/customfields/", + "auth": "jwt + role:admin", + "params": "path: fieldid (int)", + "purpose": "UNDOCUMENTED: customfields.delete_field has no docstring.", + "example": "curl -X DELETE http://localhost:5001/api/customfields/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/customfields/", + "auth": "jwt + role:admin", + "params": "path: fieldid (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: customfields.update_field has no docstring.", + "example": "curl -X PUT http://localhost:5001/api/customfields/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/customfields/asset/", + "auth": "permission:assets.view", + "params": "path: assetid (int)", + "purpose": "Active custom fields for an asset's type, merged with its values.", + "example": "curl http://localhost:5001/api/customfields/asset/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/customfields/asset/", + "auth": "jwt + role:admin", + "params": "path: assetid (int); body: JSON (fields not yet documented)", + "purpose": "Upsert values for an asset. Body: {values: {fieldid: value, ...}}.", + "example": "curl -X PUT http://localhost:5001/api/customfields/asset/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "core-dashboard", + "endpoints": [ + { + "method": "GET", + "path": "/api/dashboard", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Get dashboard summary data (asset-based).", + "example": "curl http://localhost:5001/api/dashboard -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "activeassets": 0, + "bystatus": { + "Unknown": 2 + }, + "counts": { + "machines": 2, + "networkdevices": 0, + "pcs": 0, + "printers": 0, + "total": 2 + }, + "countsactiveonly": true, + "dualpathcollapsed": false, + "inactiveexcluded": 0, + "inrepair": 0, + "recent": [ + { + "assetid": 2, + "assetnumber": "2008", + "assettype": "machine", + "createddate": "2026-01-01T00:00:00.000000Z" + }, + { + "assetid": 1, + "assetnumber": "2007", + "assettype": "machine", + "createddate": "2026-01-01T00:00:00.000000Z" + } + ], + "statuses": { + "Unknown": 2 + }, + "totalassets": 2, + "totalmachines": 2, + "totalnetwork": 0, + "totalpc": 0, + "totalprinter": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/dashboard/capabilities", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "not documented", + "purpose": "What THIS caller can reach, for chrome that would otherwise only 401.", + "example": "curl http://localhost:5001/api/dashboard/capabilities -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "cansearch": false + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/dashboard/health", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "not documented", + "purpose": "Health check endpoint (no auth required).", + "example": "curl http://localhost:5001/api/dashboard/health", + "responseexample": { + "data": { + "database": "healthy", + "status": "ok" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/dashboard/navigation", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "not documented", + "purpose": "Get navigation items from all loaded plugins.", + "example": "curl http://localhost:5001/api/dashboard/navigation -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "icon": "layout-dashboard", + "name": "Dashboard", + "permission": "assets.view", + "position": 0, + "route": "/" + }, + { + "icon": "map", + "name": "Map", + "permission": "assets.view", + "position": 4, + "route": "/map" + }, + { + "icon": "bell", + "name": "Notifications", + "permission": "notifications.view", + "plugin": "notifications", + "position": 5, + "route": "/notifications" + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/dashboard/stats", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Get detailed statistics by asset type.", + "example": "curl http://localhost:5001/api/dashboard/stats -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "bytype": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/dashboard/summary", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Get dashboard summary data (asset-based).", + "example": "curl http://localhost:5001/api/dashboard/summary -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "activeassets": 0, + "bystatus": {}, + "counts": { + "machines": 0, + "networkdevices": 0, + "pcs": 0, + "printers": 0, + "total": 0 + }, + "countsactiveonly": true, + "dualpathcollapsed": true, + "inactiveexcluded": 0, + "inrepair": 0, + "recent": [], + "statuses": {}, + "totalassets": 0, + "totalmachines": 0, + "totalnetwork": 0, + "totalpc": 0, + "totalprinter": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/dashboard/widgets", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Aggregate dashboard widget definitions from all enabled plugins.", + "example": "curl http://localhost:5001/api/dashboard/widgets -H \"Authorization: Bearer $TOK\"" + } + ] + }, + { + "surface": "core-dashboarddefaults", + "endpoints": [ + { + "method": "GET", + "path": "/api/dashboarddefaults", + "auth": "permission:settings.view", + "params": "not documented", + "purpose": "List all visitor-IP -> business-unit mappings.", + "example": "curl http://localhost:5001/api/dashboarddefaults -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/dashboarddefaults", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a visitor-IP -> business-unit mapping.", + "example": "curl -X POST http://localhost:5001/api/dashboarddefaults -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "businessunitid": 1, + "description": "Materials lobby kiosk", + "ipaddress": "" + }, + "responseexample": { + "data": { + "businessunit": "Materials", + "businessunitid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "dashboarddefaultid": 1, + "description": "Materials lobby kiosk", + "displaypath": "/shopfloor", + "displayrole": "Dashboard", + "fqdn": "", + "ipaddress": "", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z" + }, + "message": "Mapping created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/dashboarddefaults/", + "auth": "jwt + role:admin", + "params": "path: default_id (int)", + "purpose": "Delete (deactivate) a mapping.", + "example": "curl -X DELETE http://localhost:5001/api/dashboarddefaults/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "message": "Mapping deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/dashboarddefaults/", + "auth": "jwt + role:admin", + "params": "path: default_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a mapping.", + "example": "curl -X PUT http://localhost:5001/api/dashboarddefaults/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "description": "moved" + }, + "responseexample": { + "data": { + "businessunit": "Materials", + "businessunitid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "dashboarddefaultid": 1, + "description": "moved", + "displaypath": "/shopfloor", + "displayrole": "Dashboard", + "fqdn": "", + "ipaddress": "", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z" + }, + "message": "Mapping updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/dashboarddefaults/display-role", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "not documented", + "purpose": "Resolve what a single 'display' PC should show, from its own IP.", + "example": "curl http://localhost:5001/api/dashboarddefaults/display-role", + "queryparams": [ + { + "name": "fqdn", + "type": "string", + "required": false + }, + { + "name": "ipaddress", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "businessunit": null, + "businessunitid": null, + "fqdn": "", + "ipaddress": "", + "path": "/parts-kiosk", + "role": "3DPrintRoom" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/dashboarddefaults/visitor-location", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "not documented", + "purpose": "Resolve the business unit for the calling display by its IP.", + "example": "curl http://localhost:5001/api/dashboarddefaults/visitor-location", + "queryparams": [ + { + "name": "fqdn", + "type": "string", + "required": false + }, + { + "name": "ipaddress", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "businessunit": "Materials", + "businessunitid": 1, + "fqdn": "", + "ipaddress": "" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "core-docs", + "endpoints": [ + { + "method": "GET", + "path": "/api/docs/", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "not documented", + "purpose": "Redoc page. url_for keeps the asset URLs correct under any mount.", + "example": "curl http://localhost:5001/api/docs/" + }, + { + "method": "GET", + "path": "/api/docs/llms.txt", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "not documented", + "purpose": "Concise LLM-oriented API guide.", + "example": "curl http://localhost:5001/api/docs/llms.txt" + }, + { + "method": "GET", + "path": "/api/docs/openapi.json", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "not documented", + "purpose": "The generated OpenAPI 3.1 spec (machine + LLM readable).", + "example": "curl http://localhost:5001/api/docs/openapi.json" + }, + { + "method": "GET", + "path": "/api/docs/redoc.standalone.js", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "not documented", + "purpose": "Vendored Redoc bundle (offline).", + "example": "curl http://localhost:5001/api/docs/redoc.standalone.js" + } + ] + }, + { + "surface": "core-employees", + "endpoints": [ + { + "method": "GET", + "path": "/api/employees/lookup", + "auth": "permission:employees.view", + "params": "not documented", + "purpose": "Look up multiple employees by SSO list.", + "example": "curl http://localhost:5001/api/employees/lookup -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "sso", + "type": "string", + "required": false, + "default": "" + } + ] + }, + { + "method": "GET", + "path": "/api/employees/lookup/", + "auth": "permission:employees.view", + "params": "path: sso (string)", + "purpose": "Look up a single employee by SSO.", + "example": "curl http://localhost:5001/api/employees/lookup/x -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "First_Name": "Test", + "Last_Name": "Person", + "Picture": null, + "Role": null, + "SSO": 210000001, + "Team": null, + "photofilename": "photo-210000001.png", + "photourl": "/api/employees/photo/photo-210000001.png" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/employees/search", + "auth": "permission:employees.view", + "params": "not documented", + "purpose": "Search employees by name.", + "example": "curl http://localhost:5001/api/employees/search -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "limit", + "type": "integer", + "required": false, + "default": 10 + }, + { + "name": "q", + "type": "string", + "required": false, + "default": "" + } + ], + "responseexample": { + "data": [ + { + "First_Name": "Dana", + "Last_Name": "Reyes", + "Picture": null, + "Role": null, + "SSO": 990005, + "Team": null, + "photofilename": null, + "photourl": null + }, + { + "First_Name": "Sam", + "Last_Name": "Reyes", + "Picture": null, + "Role": null, + "SSO": 990006, + "Team": null, + "photofilename": null, + "photourl": null + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "core-locations", + "endpoints": [ + { + "method": "GET", + "path": "/api/locations", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List all locations.", + "example": "curl http://localhost:5001/api/locations -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "locationname", + "type": "string", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/locations", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new location.", + "example": "curl -X POST http://localhost:5001/api/locations -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/locations/", + "auth": "jwt + role:admin", + "params": "path: location_id (int)", + "purpose": "Delete (deactivate) a location.", + "example": "curl -X DELETE http://localhost:5001/api/locations/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/locations/", + "auth": "permission:assets.view", + "params": "path: location_id (int)", + "purpose": "Get a single location.", + "example": "curl http://localhost:5001/api/locations/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/locations/", + "auth": "jwt + role:admin", + "params": "path: location_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a location.", + "example": "curl -X PUT http://localhost:5001/api/locations/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/locations/types", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List location types. ?active=false includes inactive ones.", + "example": "curl http://localhost:5001/api/locations/types -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + } + ], + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/locations/types", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: locations.create_location_type has no docstring.", + "example": "curl -X POST http://localhost:5001/api/locations/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/locations/types/", + "auth": "jwt + role:admin", + "params": "path: type_id (int)", + "purpose": "UNDOCUMENTED: locations.delete_location_type has no docstring.", + "example": "curl -X DELETE http://localhost:5001/api/locations/types/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/locations/types/", + "auth": "jwt + role:admin", + "params": "path: type_id (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: locations.update_location_type has no docstring.", + "example": "curl -X PUT http://localhost:5001/api/locations/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "core-maplevels", + "endpoints": [ + { + "method": "GET", + "path": "/api/maplevels", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "not documented", + "purpose": "Every active level, grouped by building, in display order.", + "example": "curl http://localhost:5001/api/maplevels -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "buildings": [ + { + "buildingid": 1, + "buildingname": "Public Works", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "levels": [ + { + "assetcount": 0, + "blueprintdark": null, + "blueprintlight": "floorplan.svg", + "buildingid": 1, + "buildingname": "Public Works", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "isdefault": false, + "levelid": 1, + "levelname": "Ground", + "mapheight": 2550, + "mapwidth": 3300, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "sortorder": 1 + } + ], + "modifieddate": "2026-01-01T00:00:00.000000Z", + "sortorder": 1 + } + ], + "defaultlevelid": 1, + "totalplaced": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/maplevels", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: maplevels.create_level has no docstring.", + "example": "curl -X POST http://localhost:5001/api/maplevels -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/maplevels/", + "auth": "jwt + role:admin", + "params": "path: levelid (int)", + "purpose": "Deactivate a level, refusing while assets are still placed on it.", + "example": "curl -X DELETE http://localhost:5001/api/maplevels/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/maplevels/", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "path: levelid (int)", + "purpose": "UNDOCUMENTED: maplevels.get_level has no docstring.", + "example": "curl http://localhost:5001/api/maplevels/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "blueprintdark": null, + "blueprintlight": "floorplan.svg", + "buildingid": 1, + "buildingname": "Public Works", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "isdefault": false, + "levelid": 1, + "levelname": "Ground", + "mapheight": 2550, + "mapwidth": 3300, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "sortorder": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PATCH|PUT", + "path": "/api/maplevels/", + "auth": "jwt + role:admin", + "params": "path: levelid (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: maplevels.update_level has no docstring.", + "example": "curl -X PATCH http://localhost:5001/api/maplevels/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "POST", + "path": "/api/maplevels//blueprint", + "auth": "jwt + role:admin", + "params": "path: levelid (int); body: JSON (fields not yet documented)", + "purpose": "Upload this level's blueprint for one theme.", + "example": "curl -X POST http://localhost:5001/api/maplevels/1/blueprint -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/maplevels//blueprint/", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "path: levelid (int), filename (path)", + "purpose": "Serve a level's blueprint image. Public, for the same reason as above.", + "example": "curl http://localhost:5001/api/maplevels/1/blueprint/example.png -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/maplevels/buildings", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: maplevels.create_building has no docstring.", + "example": "curl -X POST http://localhost:5001/api/maplevels/buildings -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "PATCH|PUT", + "path": "/api/maplevels/buildings/", + "auth": "jwt + role:admin", + "params": "path: buildingid (int); body: JSON (fields not yet documented)", + "purpose": "Rename or reorder a building. Levels move with it; nothing repositions.", + "example": "curl -X PATCH http://localhost:5001/api/maplevels/buildings/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "core-mappositions", + "endpoints": [ + { + "method": "POST", + "path": "/api/mappositions/positions", + "auth": "permission:assets.edit", + "params": "body: JSON (fields not yet documented)", + "purpose": "Set positions for many assets at once.", + "example": "curl -X POST http://localhost:5001/api/mappositions/positions -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "positions": [ + { + "assetid": 1, + "levelid": 2, + "mapx": 42, + "mapy": 43 + } + ] + }, + "responseexample": { + "data": { + "snapshotid": 1, + "updated": 1 + }, + "message": "1 position(s) saved", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/mappositions/snapshots", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Snapshots, newest first. Metadata only - the positions are large.", + "example": "curl http://localhost:5001/api/mappositions/snapshots -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/mappositions/snapshots//restore", + "auth": "permission:assets.edit", + "params": "path: snapshotid (int); body: JSON (fields not yet documented)", + "purpose": "Put the positions in this snapshot back.", + "example": "curl -X POST http://localhost:5001/api/mappositions/snapshots/1/restore -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "restored": 3, + "skippedassetids": [] + }, + "message": "3 marker(s) restored", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/mappositions/transform", + "auth": "permission:assets.edit", + "params": "body: JSON (fields not yet documented)", + "purpose": "Move every placed marker on a level by a transform read off landmarks.", + "example": "curl -X POST http://localhost:5001/api/mappositions/transform -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "dryrun": false, + "landmarks": [ + { + "fromx": 0, + "fromy": 0, + "tox": 0, + "toy": 1450 + }, + { + "fromx": 1000, + "fromy": 1000, + "tox": 1000, + "toy": 2450 + } + ], + "levelid": 1 + }, + "responseexample": { + "data": { + "assetcount": 3, + "dryrun": false, + "levelid": 1, + "moves": [ + { + "assetid": 1, + "assetnumber": "M000", + "fromx": 100, + "fromy": 100, + "name": null, + "outofbounds": false, + "tox": 100, + "toy": 1550 + }, + { + "assetid": 2, + "assetnumber": "M001", + "fromx": 1500, + "fromy": 1200, + "name": null, + "outofbounds": true, + "tox": 1500, + "toy": 2650 + }, + { + "assetid": 3, + "assetnumber": "M002", + "fromx": 3200, + "fromy": 2500, + "name": null, + "outofbounds": true, + "tox": 3200, + "toy": 3950 + } + ], + "outofboundscount": 2, + "snapshotid": 1, + "tolevelid": 1, + "transform": { + "offsetx": 0.0, + "offsety": 1450.0, + "scalex": 1.0, + "scaley": 1.0 + } + }, + "message": "3 marker(s) moved; snapshot 1 can restore them", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/mappositions/verify", + "auth": "permission:assets.edit", + "params": "body: JSON (fields not yet documented)", + "purpose": "Mark markers as reviewed against the current drawing without moving them.", + "example": "curl -X POST http://localhost:5001/api/mappositions/verify -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "assetids": [ + 1 + ], + "unverify": true + }, + "responseexample": { + "data": { + "updated": 1 + }, + "message": "1 marker(s) unverified", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + } + ] + }, + { + "surface": "core-models", + "endpoints": [ + { + "method": "GET", + "path": "/api/models", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List all vendor catalog models.", + "example": "curl http://localhost:5001/api/models -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "category", + "type": "string", + "required": false + }, + { + "name": "modelnumber", + "type": "string", + "required": false + }, + { + "name": "modeltype", + "type": "integer", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "vendor", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/models", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new model.", + "example": "curl -X POST http://localhost:5001/api/models -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/models/", + "auth": "jwt + role:admin", + "params": "path: model_id (int)", + "purpose": "Delete (deactivate) a model.", + "example": "curl -X DELETE http://localhost:5001/api/models/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/models/", + "auth": "permission:assets.view", + "params": "path: model_id (int)", + "purpose": "Get a single model.", + "example": "curl http://localhost:5001/api/models/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/models/", + "auth": "jwt + role:admin", + "params": "path: model_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a model.", + "example": "curl -X PUT http://localhost:5001/api/models/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/models//image", + "auth": "jwt + role:admin", + "params": "path: model_id (int)", + "purpose": "Clear a model image and delete the uploaded file if we own it.", + "example": "curl -X DELETE http://localhost:5001/api/models/1/image -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "documentationurl": null, + "imageurl": null, + "isactive": true, + "modelnumber": "TESTMODEL-1", + "modelnumberid": 1, + "modeltypeid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "vendorid": null + }, + "message": "Model image removed", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/models//image", + "auth": "jwt + role:admin", + "params": "path: model_id (int); body: JSON (fields not yet documented)", + "purpose": "Upload (or replace) the photo for a model.", + "example": "curl -X POST http://localhost:5001/api/models/1/image -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "documentationurl": null, + "imageurl": "/api/models/image/model-1.jpg", + "isactive": true, + "modelnumber": "TESTMODEL-1", + "modelnumberid": 1, + "modeltypeid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "vendorid": null + }, + "message": "Model image uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/models/image/", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "path: filename (path)", + "purpose": "Serve an uploaded model image (public - asset detail pages read it).", + "example": "curl http://localhost:5001/api/models/image/example.png" + } + ] + }, + { + "surface": "core-modeltypes", + "endpoints": [ + { + "method": "GET", + "path": "/api/modeltypes", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List all model types with optional filtering.", + "example": "curl http://localhost:5001/api/modeltypes -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "category", + "type": "string", + "required": false + }, + { + "name": "modeltype", + "type": "string", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/modeltypes", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new model type.", + "example": "curl -X POST http://localhost:5001/api/modeltypes -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/modeltypes/", + "auth": "jwt + role:admin", + "params": "path: type_id (int)", + "purpose": "Delete (deactivate) a model type.", + "example": "curl -X DELETE http://localhost:5001/api/modeltypes/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/modeltypes/", + "auth": "permission:assets.view", + "params": "path: type_id (int)", + "purpose": "Get a single model type.", + "example": "curl http://localhost:5001/api/modeltypes/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/modeltypes/", + "auth": "jwt + role:admin", + "params": "path: type_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a model type.", + "example": "curl -X PUT http://localhost:5001/api/modeltypes/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "core-operatingsystems", + "endpoints": [ + { + "method": "GET", + "path": "/api/operatingsystems", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List all operating systems.", + "example": "curl http://localhost:5001/api/operatingsystems -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "osname", + "type": "string", + "required": false + }, + { + "name": "osversion", + "type": "string", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/operatingsystems", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new operating system.", + "example": "curl -X POST http://localhost:5001/api/operatingsystems -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/operatingsystems/", + "auth": "jwt + role:admin", + "params": "path: os_id (int)", + "purpose": "Delete (deactivate) an operating system.", + "example": "curl -X DELETE http://localhost:5001/api/operatingsystems/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/operatingsystems/", + "auth": "permission:assets.view", + "params": "path: os_id (int)", + "purpose": "Get a single operating system.", + "example": "curl http://localhost:5001/api/operatingsystems/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/operatingsystems/", + "auth": "jwt + role:admin", + "params": "path: os_id (int); body: JSON (fields not yet documented)", + "purpose": "Update an operating system.", + "example": "curl -X PUT http://localhost:5001/api/operatingsystems/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "core-plugins", + "endpoints": [ + { + "method": "GET", + "path": "/api/plugins", + "auth": "permission:settings.view", + "params": "not documented", + "purpose": "List all discovered plugins (enabled or not) with their metadata and the framework contract version.", + "example": "curl http://localhost:5001/api/plugins -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/plugins/", + "auth": "jwt + role:admin", + "params": "path: name (string); body: JSON (fields not yet documented)", + "purpose": "Enable or disable a plugin. Takes effect on the next app restart for route/navigation changes.", + "example": "curl -X PUT http://localhost:5001/api/plugins/x -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/plugins/enabled", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "not documented", + "purpose": "Return just the names of enabled plugins as a flat array.", + "example": "curl http://localhost:5001/api/plugins/enabled -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + "backups", + "computers", + "employees", + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/plugins/settings-routes", + "auth": "permission:settings.view", + "params": "not documented", + "purpose": "Return settings routes contributed by all enabled plugins.", + "example": "curl http://localhost:5001/api/plugins/settings-routes -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "routes": [ + { + "component": "drawn.vue", + "hub": "model-catalog", + "name": "drawn-route", + "path": "drawn", + "tab": "Drawn" + }, + { + "component": ".plugins-staged/computers/views/OperatingSystemsList.vue", + "hub": "model-catalog", + "name": "model-catalog-os", + "path": "operatingsystems", + "tab": "Operating Systems" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "core-pluginui", + "endpoints": [ + { + "method": "GET", + "path": "/api/pluginui/asset-panels", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Return the asset-detail panels matching one asset's type (get_asset_panels).", + "example": "curl http://localhost:5001/api/pluginui/asset-panels -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "assetid", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [ + { + "assettypes": [ + "computer" + ], + "empty": "No agent report yet.", + "endpoint": "/api/endpoints/asset/{assetid}/status", + "group": "details", + "id": "endpoints-status", + "plugin": "endpoints", + "position": 10, + "render": "keyvalue", + "title": "Endpoint status" + }, + { + "assettypes": [ + "computer" + ], + "collapsible": true, + "commandendpoint": "/api/endpoints/{assetid}/commands", + "empty": "No monitored services reported.", + "endpoint": "/api/endpoints/asset/{assetid}/services", + "facets": { + "key": "statelabel", + "order": [ + "Running", + "Stopped", + "Paused", + "" + ] + }, + "filter": { + "equals": "service", + "key": "kind" + }, + "group": "serviceprocess", + "id": "endpoints-tracked-services", + "map": { + "action": "action", + "badge": { + "color": "statecolor", + "label": "statelabel" + }, + "title": "name" + }, + "permission": "endpoints.command", + "plugin": "endpoints", + "position": 12, + "render": "list", + "status": { + "key": "statecolor", + "levels": { + "green": "ok", + "red": "critical", + "yellow": "warning" + } + }, + "title": "Tracked Services" + }, + { + "assettypes": [ + "computer" + ], + "collapsible": true, + "commandendpoint": "/api/endpoints/{assetid}/commands", + "empty": "No monitored processes reported.", + "endpoint": "/api/endpoints/asset/{assetid}/services", + "facets": { + "key": "statelabel", + "order": [ + "Running", + "Stopped", + "Paused", + "" + ] + }, + "filter": { + "equals": "process", + "key": "kind" + }, + "group": "serviceprocess", + "id": "endpoints-tracked-processes", + "map": { + "action": "action", + "badge": { + "color": "statecolor", + "label": "statelabel" + }, + "title": "name" + }, + "permission": "endpoints.command", + "plugin": "endpoints", + "position": 13, + "render": "list", + "status": { + "key": "statecolor", + "levels": { + "green": "ok", + "red": "critical", + "yellow": "warning" + } + }, + "title": "Tracked Processes" + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/pluginui/asset-presentation", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Merge enabled plugins' asset-type presentation entries (hook).", + "example": "curl http://localhost:5001/api/pluginui/asset-presentation -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "assettype": "stubtype", + "plugin": "backups", + "route": "/stubtype/by-asset/{assetid}" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/pluginui/asset-type-descriptors", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Merge enabled plugins' asset-type subtype descriptors.", + "example": "curl http://localhost:5001/api/pluginui/asset-type-descriptors -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "assettype": "computer", + "edit_permission": "computers.manage", + "optional_tabs": [], + "plugin": "computers", + "subtype_color_field": "color", + "subtype_description_field": "description", + "subtype_endpoint": "/computers/types", + "subtype_id_field": "computertypeid", + "subtype_label_field": "computertype" + }, + { + "assettype": "machine", + "edit_permission": "machines.manage", + "optional_tabs": [], + "plugin": "machines", + "subtype_color_field": "color", + "subtype_description_field": "description", + "subtype_endpoint": "/machines/types", + "subtype_id_field": "machinetypeid", + "subtype_label_field": "machinetype" + }, + { + "assettype": "network_device", + "edit_permission": "network.manage", + "optional_tabs": [], + "plugin": "network", + "subtype_color_field": "color", + "subtype_description_field": "description", + "subtype_endpoint": "/network/types", + "subtype_id_field": "networkdevicetypeid", + "subtype_label_field": "networkdevicetype" + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/pluginui/core-type-descriptors", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Return type descriptors for core-owned non-asset types.", + "example": "curl http://localhost:5001/api/pluginui/core-type-descriptors -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "category": "location", + "edit_permission": "admin", + "group": "other", + "optional_tabs": [], + "subtype_color_field": "color", + "subtype_description_field": "description", + "subtype_endpoint": "/locations/types", + "subtype_id_field": "locationtypeid", + "subtype_label_field": "locationtype", + "type_name": "Location Type", + "type_name_plural": "Location Types" + }, + { + "category": "notification", + "custom_component": "NotificationTypesTab", + "edit_permission": "admin", + "group": "other", + "optional_tabs": [], + "subtype_color_field": "typecolor", + "subtype_description_field": "typedescription", + "subtype_endpoint": "/notifications/types", + "subtype_id_field": "notificationtypeid", + "subtype_label_field": "typename", + "type_name": "Notification Type", + "type_name_plural": "Notification Types" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/pluginui/map-overlays", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Merge enabled plugins' map overlay declarations (get_map_overlays hook).", + "example": "curl http://localhost:5001/api/pluginui/map-overlays -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "endpoint": "/api/whatever", + "id": "due-soon", + "legend": true, + "plugin": "backups", + "title": "Due soon" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/pluginui/settings-cards", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Merge enabled plugins' settings-catalog cards (get_settings_cards hook).", + "example": "curl http://localhost:5001/api/pluginui/settings-cards -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "description": "Collection interval, share root for part-marker snapshots, staleness threshold and revision retention", + "group": "Machines", + "icon": "package", + "plugin": "backups", + "position": 21, + "title": "Backups", + "to": "/settings/backups" + }, + { + "description": "Download the collector script stamped with this site's URL and ranges", + "group": "Computers", + "icon": "download", + "plugin": "computers", + "position": 26, + "title": "Asset reporter", + "to": "/settings/collector" + }, + { + "description": "The shared agent key PCs use until they enroll, the site agent installer builder, and what the agent reports.", + "group": "Endpoints", + "icon": "key", + "plugin": "endpoints", + "position": 27, + "title": "Endpoints", + "to": "/settings/endpoints/agentkey" + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "core-reports", + "endpoints": [ + { + "method": "GET", + "path": "/api/reports", + "auth": "permission:reports.view", + "params": "not documented", + "purpose": "List all available reports.", + "example": "curl http://localhost:5001/api/reports -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "reports": [ + { + "category": "inventory", + "description": "Machine count grouped by machine type", + "endpoint": "/api/reports/machines-by-type", + "id": "machines-by-type", + "name": "Machines by Type" + }, + { + "category": "inventory", + "description": "Asset count grouped by status", + "endpoint": "/api/reports/assets-by-status", + "id": "assets-by-status", + "name": "Assets by Status" + }, + { + "category": "usage", + "description": "Most clicked knowledge base articles", + "endpoint": "/api/reports/kb-popularity", + "id": "kb-popularity", + "name": "KB Popularity" + }, + "" + ], + "total": 13 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/reports/asset-inventory", + "auth": "permission:reports.view", + "params": "not documented", + "purpose": "Report: Complete asset inventory summary.", + "example": "curl http://localhost:5001/api/reports/asset-inventory -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "businessunitid", + "type": "integer", + "required": false + }, + { + "name": "format", + "type": "string", + "required": false + }, + { + "name": "locationid", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": { + "data": { + "bylocation": [], + "bystatus": [], + "bytype": [] + }, + "generated": "2026-01-01T00:00:00.000000", + "report": "asset_inventory", + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/reports/assets-by-status", + "auth": "permission:reports.view", + "params": "not documented", + "purpose": "Report: Asset count grouped by status.", + "example": "curl http://localhost:5001/api/reports/assets-by-status -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "assettypeid", + "type": "integer", + "required": false + }, + { + "name": "businessunitid", + "type": "integer", + "required": false + }, + { + "name": "format", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "data": [], + "generated": "2026-01-01T00:00:00.000000", + "report": "assets_by_status", + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/reports/email", + "auth": "permission:reports.export", + "params": "body: JSON (fields not yet documented)", + "purpose": "Email a report's data as an HTML table (on-demand delivery).", + "example": "curl -X POST http://localhost:5001/api/reports/email -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "columns": [ + { + "key": "vendor", + "label": "Vendor" + } + ], + "rows": [ + { + "vendor": "Dell" + } + ], + "subject": "Warranty Report", + "to": "" + }, + "responseexample": { + "data": { + "reason": "notconfigured", + "sent": false + }, + "message": "Email is not configured (SMTP disabled or host unset).", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/reports/kb-popularity", + "auth": "permission:reports.view", + "params": "not documented", + "purpose": "Report: Most clicked knowledge base articles.", + "example": "curl http://localhost:5001/api/reports/kb-popularity -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "format", + "type": "string", + "required": false + }, + { + "name": "limit", + "type": "integer", + "required": false, + "default": 20 + } + ], + "responseexample": { + "data": { + "data": [], + "generated": "2026-01-01T00:00:00.000000", + "report": "kb_popularity", + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/reports/machines-by-type", + "auth": "permission:reports.view", + "params": "not documented", + "purpose": "Report: Machine count grouped by machine type.", + "example": "curl http://localhost:5001/api/reports/machines-by-type -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "businessunitid", + "type": "integer", + "required": false + }, + { + "name": "format", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "data": [], + "generated": "2026-01-01T00:00:00.000000", + "report": "machines_by_type", + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/reports/pc-relationships", + "auth": "permission:reports.view", + "params": "not documented", + "purpose": "Report: PCs with relationships to shop floor machines.", + "example": "curl http://localhost:5001/api/reports/pc-relationships -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "format", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "data": [ + { + "hostname": "", + "ip": "", + "machine_number": "4242", + "model": "", + "vendor": "" + } + ], + "generated": "2026-01-01T00:00:00.000000", + "report": "pc_relationships", + "total": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/reports/software-compliance", + "auth": "permission:reports.view", + "params": "not documented", + "purpose": "Report: Required applications vs installed (per PC).", + "example": "curl http://localhost:5001/api/reports/software-compliance -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "appid", + "type": "string", + "required": false + }, + { + "name": "format", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "data": [], + "generated": "2026-01-01T00:00:00.000000", + "message": "No required applications defined", + "report": "software_compliance" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "core-scripts", + "endpoints": [ + { + "method": "GET", + "path": "/api/scripts", + "auth": "permission:endpoints.runscript", + "params": "not documented", + "purpose": "List scripts (active by default).", + "example": "curl http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "search", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/scripts", + "auth": "permission:endpoints.runscript", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a script catalog entry (body uploaded separately).", + "example": "curl -X POST http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "scriptname": "autologon", + "workingdirectory": "C:\\Program Files\\Sysinternals" + }, + "responseexample": { + "data": { + "captureoutput": false, + "createddate": "2026-01-01T00:00:00.000000Z", + "errormode": "default", + "filename": null, + "hasbody": false, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "runcontext": "system", + "scriptdescription": null, + "scriptid": 1, + "scriptname": "autologon", + "sha256": null, + "workingdirectory": "C:\\Program Files\\Sysinternals" + }, + "message": "Script created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/scripts/", + "auth": "permission:endpoints.runscript", + "params": "path: script_id (int)", + "purpose": "UNDOCUMENTED: scripts.delete_script has no docstring.", + "example": "curl -X DELETE http://localhost:5001/api/scripts/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/scripts/", + "auth": "permission:endpoints.runscript", + "params": "path: script_id (int)", + "purpose": "UNDOCUMENTED: scripts.get_script has no docstring.", + "example": "curl http://localhost:5001/api/scripts/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/scripts/", + "auth": "permission:endpoints.runscript", + "params": "path: script_id (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: scripts.update_script has no docstring.", + "example": "curl -X PUT http://localhost:5001/api/scripts/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "scriptdescription": "now documented" + }, + "responseexample": { + "data": { + "captureoutput": true, + "createddate": "2026-01-01T00:00:00.000000Z", + "errormode": "default", + "filename": null, + "hasbody": false, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "runcontext": "system", + "scriptdescription": "now documented", + "scriptid": 1, + "scriptname": "keep-my-flag", + "sha256": null, + "workingdirectory": null + }, + "message": "Script updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/scripts//body", + "auth": "permission:endpoints.runscript", + "params": "path: script_id (int); body: JSON (fields not yet documented)", + "purpose": "Upload (or replace) the .ps1 body for a script.", + "example": "curl -X POST http://localhost:5001/api/scripts/1/body -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "bytes": 23, + "captureoutput": false, + "createddate": "2026-01-01T00:00:00.000000Z", + "errormode": "default", + "filename": "script-1.ps1", + "hasbody": true, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "runcontext": "system", + "scriptdescription": null, + "scriptid": 1, + "scriptname": "digest-check", + "sha256": "", + "uploadedfilename": "digest-check.ps1", + "workingdirectory": null + }, + "message": "Script body uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/scripts/body/", + "auth": "permission:endpoints.runscript", + "params": "path: filename (path)", + "purpose": "Operator-facing script body download (JWT/RBAC).", + "example": "curl http://localhost:5001/api/scripts/body/example.png -H \"Authorization: Bearer $TOK\"" + } + ] + }, + { + "surface": "core-search", + "endpoints": [ + { + "method": "GET", + "path": "/api/search", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "Global search across multiple entity types.", + "example": "curl http://localhost:5001/api/search -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "q", + "type": "string", + "required": false, + "default": "" + } + ], + "responseexample": { + "data": { + "counts": { + "knowledgebase": 2 + }, + "degraded": [ + "Employee" + ], + "query": "zzkeyword", + "results": [ + { + "id": 1, + "linkurl": "u", + "relevance": 25.0, + "subtitle": "Livesearchapp", + "title": "Zzrunbook for the live one", + "type": "knowledgebase", + "url": "/knowledgebase/1" + }, + { + "id": 3, + "linkurl": "u", + "relevance": 25.0, + "subtitle": null, + "title": "Zzrunbook with no topic", + "type": "knowledgebase", + "url": "/knowledgebase/3" + } + ], + "total": 2, + "total_all": 2 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/search/suggest", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "A better spelling for a query that found nothing, or null.", + "example": "curl http://localhost:5001/api/search/suggest -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "q", + "type": "string", + "required": false, + "default": "" + } + ], + "responseexample": { + "data": { + "query": "keyecne", + "suggestion": "keyence" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "core-settings", + "endpoints": [ + { + "method": "GET", + "path": "/api/settings", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "not documented", + "purpose": "List all settings, optionally filtered by category.", + "example": "curl http://localhost:5001/api/settings -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "category", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [ + { + "category": "auth", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "SAML Assertion Consumer Service URL", + "key": "saml_acs_url", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "settingid": 111, + "value": "", + "valuetype": "string" + }, + { + "category": "auth", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "SAML group name that grants admin role", + "key": "saml_admin_group", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "settingid": 114, + "value": "", + "valuetype": "string" + }, + { + "category": "auth", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "Allow local username/password login when SAML is enabled", + "key": "saml_allow_local_login", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "settingid": 112, + "value": true, + "valuetype": "boolean" + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/settings", + "auth": "permission:settings.edit", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new setting (admin only).", + "example": "curl -X POST http://localhost:5001/api/settings -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/settings/", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "path: key (string)", + "purpose": "Get a single setting by key.", + "example": "curl http://localhost:5001/api/settings/x -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "category": "plugin.endpoints", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "Accept a one-line install bootstrap token in the ?t= query string as well as the X-Endpoint-Bootstrap-Token header. On (the default) keeps the pasted install commands and the Download .ps1 link workin...", + "key": "plugin.endpoints.bootstrapquerytoken", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "settingid": 1, + "value": false, + "valuetype": "boolean" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/settings/", + "auth": "permission:settings.edit", + "params": "path: key (string); body: JSON (fields not yet documented)", + "purpose": "Update a setting value.", + "example": "curl -X PUT http://localhost:5001/api/settings/x -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "value": "" + }, + "responseexample": { + "data": { + "category": "plugin.endpoints", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "Accept a one-line install bootstrap token in the ?t= query string as well as the X-Endpoint-Bootstrap-Token header. On (the default) keeps the pasted install commands and the Download .ps1 link workin...", + "key": "plugin.endpoints.bootstrapquerytoken", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "settingid": 1, + "value": false, + "valuetype": "boolean" + }, + "message": "Setting updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/settings/asset-import/import", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Validate uploaded CSVs and, when commit=true, load them.", + "example": "curl -X POST http://localhost:5001/api/settings/asset-import/import -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/settings/asset-import/script", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "Download Collect-ShopDB.ps1 with this site's CSV path embedded.", + "example": "curl http://localhost:5001/api/settings/asset-import/script -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/settings/asset-import/templates", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "List the importable CSV tables in the order they must be uploaded.", + "example": "curl http://localhost:5001/api/settings/asset-import/templates -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "tables": [ + { + "filename": "assetstatuses.csv", + "table": "assetstatuses" + }, + { + "filename": "assettypes.csv", + "table": "assettypes" + }, + { + "filename": "locationtypes.csv", + "table": "locationtypes" + }, + "" + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/settings/asset-import/templates/", + "auth": "jwt + role:admin", + "params": "path: tablename (string)", + "purpose": "Download a generated CSV template for a single importable table.", + "example": "curl http://localhost:5001/api/settings/asset-import/templates/x -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/settings/asset-import/winc/batches", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "List applied/undone WINC import batches, newest first (for the undo UI).", + "example": "curl http://localhost:5001/api/settings/asset-import/winc/batches -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "batches": [ + { + "batchid": "", + "createdat": "2026-01-01 00:00:00.000000", + "createdby": "", + "kind": "winc-machine-adoption", + "rowcount": 1, + "status": "undone", + "undoneat": "2026-01-01 00:00:00.000000" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/settings/asset-import/winc/commit", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Apply a WINC plan reversibly. Requires acknowledgment for needs-create.", + "example": "curl -X POST http://localhost:5001/api/settings/asset-import/winc/commit -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "batchid": "", + "blocking": false, + "committed": true, + "kind": "winc-machine-adoption", + "needscreate": { + "businessunit": { + "Large Parts": 1 + } + }, + "planhash": "", + "rowcount": 1, + "summary": { + "ambiguous_match": 0, + "businessunit_blank": 0, + "businessunit_needs-create": 1, + "businessunit_resolved": 0, + "businessunit_unresolved": 0, + "export_rows": 2, + "in_scope": 1, + "location_blank": 0, + "location_needs-create": 0, + "location_resolved": 1, + "location_unresolved": 0, + "non_workstation_rows": 1, + "skipped_no_existing_machine": 0 + }, + "uncreatable": {}, + "unresolved": {} + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/settings/asset-import/winc/preview", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Read-only WINC analysis: report what a commit would do. Writes nothing.", + "example": "curl -X POST http://localhost:5001/api/settings/asset-import/winc/preview -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "blocking": true, + "committed": false, + "kind": "winc-machine-adoption", + "needscreate": { + "businessunit": { + "Large Parts": 1 + }, + "location": { + "T6": 1 + } + }, + "planhash": "", + "rowcount": 1, + "summary": { + "ambiguous_match": 0, + "businessunit_blank": 0, + "businessunit_needs-create": 1, + "businessunit_resolved": 0, + "businessunit_unresolved": 0, + "export_rows": 1, + "in_scope": 1, + "location_blank": 0, + "location_needs-create": 1, + "location_resolved": 0, + "location_unresolved": 0, + "non_workstation_rows": 0, + "skipped_no_existing_machine": 0 + }, + "uncreatable": { + "location": { + "T6": 1 + } + }, + "unresolved": {} + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/settings/asset-import/winc/undo", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Reverse an applied WINC import batch by id. Conservative and reversible.", + "example": "curl -X POST http://localhost:5001/api/settings/asset-import/winc/undo -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "batchid": "" + }, + "responseexample": { + "data": { + "batchid": "", + "conflicts": [ + { + "column": null, + "operation": "insert", + "reason": "still referenced by dashboarddefaults.businessunitid", + "table": "businessunits", + "targetid": 1 + } + ], + "reverted": 3 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/settings/branding-logo", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Upload a branding logo image and point the matching setting at it.", + "example": "curl -X POST http://localhost:5001/api/settings/branding-logo -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "key": "site_favicon", + "value": "/api/settings/branding/logo-favicon.ico" + }, + "message": "Logo uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/settings/branding/", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "path: filename (path)", + "purpose": "Serve an uploaded branding logo (public - kiosks/print pages read it).", + "example": "curl http://localhost:5001/api/settings/branding/example.png" + }, + { + "method": "POST", + "path": "/api/settings/map-blueprint", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Upload a floor-map blueprint image and point the setting at it.", + "example": "curl -X POST http://localhost:5001/api/settings/map-blueprint -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/settings/map-blueprint/", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "path: filename (path)", + "purpose": "Serve an uploaded blueprint image (public - kiosks read it).", + "example": "curl http://localhost:5001/api/settings/map-blueprint/example.png" + }, + { + "method": "POST", + "path": "/api/settings/seed", + "auth": "permission:settings.edit", + "params": "body: JSON (fields not yet documented)", + "purpose": "Seed default settings if they don't exist.", + "example": "curl -X POST http://localhost:5001/api/settings/seed -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": {}, + "responseexample": { + "data": { + "created": 112 + }, + "message": "112 default settings created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/settings/test-email", + "auth": "permission:settings.edit", + "params": "body: JSON (fields not yet documented)", + "purpose": "Send a test email to verify SMTP configuration.", + "example": "curl -X POST http://localhost:5001/api/settings/test-email -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "to": "" + }, + "responseexample": { + "data": { + "error": "[Errno -2] Name or service not known", + "sent": false + }, + "message": "Test email failed: [Errno -2] Name or service not known", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + } + ] + }, + { + "surface": "core-setup", + "endpoints": [ + { + "method": "POST", + "path": "/api/setup/complete", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Flag the first-run setup as finished.", + "example": "curl -X POST http://localhost:5001/api/setup/complete -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + }, + { + "method": "POST", + "path": "/api/setup/create-admin", + "auth": "none (first run only: refused once an admin exists)", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create the first admin. Only works while there are zero users, setup has not been marked complete, and the caller proves operator control (setup token or a loopback request).", + "example": "curl -X POST http://localhost:5001/api/setup/create-admin -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/setup/needs-admin", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "not documented", + "purpose": "True when no users exist yet - the app's very first run. Public so the login screen can offer to create the first admin.", + "example": "curl http://localhost:5001/api/setup/needs-admin", + "responseexample": { + "data": { + "needsadmin": false + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/setup/seed-reference", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Seed core reference data + permissions + default settings. Idempotent - reuses the same routines as the `flask seed ...` CLI commands.", + "example": "curl -X POST http://localhost:5001/api/setup/seed-reference -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + }, + { + "method": "POST", + "path": "/api/setup/seed-starter", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Add common vendors that are not already present. Idempotent.", + "example": "curl -X POST http://localhost:5001/api/setup/seed-starter -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + } + ] + }, + { + "surface": "core-shopdbbackups", + "endpoints": [ + { + "method": "GET", + "path": "/api/shopdbbackups", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "List every retained backup set, newest first, with a compatibility flag.", + "example": "curl http://localhost:5001/api/shopdbbackups -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [], + "meta": { + "count": 0, + "lastgoodat": null, + "requestid": "00000000", + "retention": { + "keepdaily": 5, + "keepmonthly": 6, + "keepweekly": 4 + }, + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/shopdbbackups", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Kick off an on-demand backup run.", + "example": "curl -X POST http://localhost:5001/api/shopdbbackups -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + }, + { + "method": "GET", + "path": "/api/shopdbbackups/", + "auth": "jwt + role:admin", + "params": "path: snapshotid (string)", + "purpose": "Return one set's full manifest plus a resolved compatibility verdict.", + "example": "curl http://localhost:5001/api/shopdbbackups/x -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/shopdbbackups//restore", + "auth": "jwt + role:admin", + "params": "path: snapshotid (string); body: JSON (fields not yet documented)", + "purpose": "Queue a restore of a set, gated on schema compatibility.", + "example": "curl -X POST http://localhost:5001/api/shopdbbackups/x/restore -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + }, + { + "method": "POST", + "path": "/api/shopdbbackups/finalize-restore", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Run the post-restore schema upgrade in the app context.", + "example": "curl -X POST http://localhost:5001/api/shopdbbackups/finalize-restore -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "queued": true, + "snapshotid": "wj-2026-09-06T120000Z" + }, + "message": "Schema upgrade queued.", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/shopdbbackups/history", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "Return the recorded history of backup and restore runs, newest first.", + "example": "curl http://localhost:5001/api/shopdbbackups/history -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "backups": [], + "restores": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/shopdbbackups/settings", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "Return destination + retention config for the settings pane.", + "example": "curl http://localhost:5001/api/shopdbbackups/settings -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "backupsroot": "/backups", + "destinationkind": "local", + "destinationkinds": [ + "local", + "smb", + "aws" + ], + "fields": {}, + "isarmed": true, + "retention": { + "keepdaily": 5, + "keepmonthly": 6, + "keepweekly": 4 + } + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/shopdbbackups/settings", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Persist the backup destination kind and its credential/config fields.", + "example": "curl -X PUT http://localhost:5001/api/shopdbbackups/settings -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": {}, + "responseexample": { + "data": { + "backupsroot": "/backups", + "destinationkind": "local", + "destinationkinds": [ + "local", + "smb", + "aws" + ], + "fields": {}, + "isarmed": true, + "retention": { + "keepdaily": 5, + "keepmonthly": 6, + "keepweekly": 4 + } + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/shopdbbackups/status", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "Return the live status of the most recent backup and restore runs.", + "example": "curl http://localhost:5001/api/shopdbbackups/status -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "backup": null, + "restore": null + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "core-supportteams", + "endpoints": [ + { + "method": "GET", + "path": "/api/supportteams", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List support teams (with contacts). ?active and ?teamname filters.", + "example": "curl http://localhost:5001/api/supportteams -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "teamname", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [ + { + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "supportteamid": 1, + "teamname": "Retired", + "teamurl": null, + "webhookurl": null + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/supportteams", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new support team.", + "example": "curl -X POST http://localhost:5001/api/supportteams -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "createddate": "2026-01-01 00:00:00", + "modifieddate": "2026-01-01T00:00:00", + "teamname": "Legacy" + }, + "responseexample": { + "data": { + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "supportteamid": 1, + "teamname": "Controls", + "teamurl": "https://servicenow.example/group/controls", + "webhookurl": null + }, + "message": "Support team created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/supportteams/", + "auth": "jwt + role:admin", + "params": "path: team_id (int)", + "purpose": "Delete a support team; 409 while any application references it.", + "example": "curl -X DELETE http://localhost:5001/api/supportteams/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "message": "Support team deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/supportteams/", + "auth": "permission:assets.view", + "params": "path: team_id (int)", + "purpose": "Get a single support team with its contacts.", + "example": "curl http://localhost:5001/api/supportteams/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "contacts": [ + { + "contactid": 2, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "First", + "sortorder": 1, + "sso": "111", + "supportteamid": 1 + }, + { + "contactid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Second", + "sortorder": 2, + "sso": "222", + "supportteamid": 1 + } + ], + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "supportteamid": 1, + "teamname": "Controls", + "teamurl": null, + "webhookurl": null + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/supportteams/", + "auth": "jwt + role:admin", + "params": "path: team_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a support team.", + "example": "curl -X PUT http://localhost:5001/api/supportteams/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "teamname": "Controls Renamed", + "teamurl": "https://x.example" + }, + "responseexample": { + "data": { + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "supportteamid": 1, + "teamname": "Controls Renamed", + "teamurl": "https://x.example", + "webhookurl": null + }, + "message": "Support team updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/supportteams//contacts", + "auth": "jwt + role:admin", + "params": "path: team_id (int); body: JSON (fields not yet documented)", + "purpose": "Add a contact to a support team.", + "example": "curl -X POST http://localhost:5001/api/supportteams/1/contacts -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "createddate": "2026-01-01 00:00:00", + "modifieddate": "2026-01-01T00:00:00", + "name": "LegacyOwner" + }, + "responseexample": { + "data": { + "contactid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Second", + "sortorder": 2, + "sso": "222", + "supportteamid": 1 + }, + "message": "Contact created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/supportteams//contacts/", + "auth": "jwt + role:admin", + "params": "path: team_id (int), contact_id (int)", + "purpose": "Delete a support-team contact.", + "example": "curl -X DELETE http://localhost:5001/api/supportteams/1/contacts/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "message": "Contact deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/supportteams//contacts/", + "auth": "jwt + role:admin", + "params": "path: team_id (int), contact_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a support-team contact.", + "example": "curl -X PUT http://localhost:5001/api/supportteams/1/contacts/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "name": "First Updated" + }, + "responseexample": { + "data": { + "contactid": 2, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "First Updated", + "sortorder": 1, + "sso": "111", + "supportteamid": 1 + }, + "message": "Contact updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + } + ] + }, + { + "surface": "core-users", + "endpoints": [ + { + "method": "GET", + "path": "/api/users", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "List all users.", + "example": "curl http://localhost:5001/api/users -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "createddate": "2026-01-01T00:00:00.000000Z", + "directpermissions": [], + "email": "", + "failedlogins": 0, + "firstname": "", + "isactive": true, + "islocked": false, + "lastlogindate": "2026-01-01T00:00:00Z", + "lastname": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "mustchangepassword": "", + "permissions": [ + "apitokens.create", + "applications.create", + "applications.delete", + "" + ], + "roles": [ + { + "color": null, + "roleid": 1, + "rolename": "admin" + } + ], + "userid": 1, + "username": "" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/users", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new user.", + "example": "curl -X POST http://localhost:5001/api/users -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "directpermissions": [ + "endpoints.runscript" + ], + "email": "", + "password": "", + "roles": [], + "sendwelcome": false, + "username": "" + }, + "responseexample": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "directpermissions": [ + "endpoints.restart" + ], + "email": "", + "failedlogins": 0, + "firstname": "", + "isactive": true, + "islocked": false, + "lastlogindate": null, + "lastname": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "mustchangepassword": "", + "permissions": [ + "applications.view", + "assets.view", + "backups.download", + "" + ], + "roles": [ + { + "color": "#0DCAF0", + "roleid": 3, + "rolename": "technician" + } + ], + "userid": 2, + "username": "", + "warning": "User created but the welcome email could not be sent." + }, + "message": "User created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/users/", + "auth": "jwt + role:admin", + "params": "path: userid (int)", + "purpose": "Delete a user.", + "example": "curl -X DELETE http://localhost:5001/api/users/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "message": "User deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/users/", + "auth": "jwt", + "params": "path: userid (int)", + "purpose": "Get a single user.", + "example": "curl http://localhost:5001/api/users/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "directpermissions": [], + "email": "", + "failedlogins": 0, + "firstname": "", + "isactive": true, + "islocked": false, + "lastlogindate": "2026-01-01T00:00:00Z", + "lastname": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "mustchangepassword": "", + "permissions": [], + "roles": [], + "userid": 1, + "username": "" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/users/", + "auth": "jwt", + "params": "path: userid (int); body: JSON (fields not yet documented)", + "purpose": "Update a user.", + "example": "curl -X PUT http://localhost:5001/api/users/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "password": "" + }, + "responseexample": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "directpermissions": [], + "email": "", + "failedlogins": 0, + "firstname": "", + "isactive": true, + "islocked": false, + "lastlogindate": null, + "lastname": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "mustchangepassword": "", + "permissions": [], + "roles": [], + "userid": 2, + "username": "" + }, + "message": "User updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/users/permissions", + "auth": "jwt", + "params": "not documented", + "purpose": "List assignable permissions grouped by category.", + "example": "curl http://localhost:5001/api/users/permissions -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/users/roles", + "auth": "jwt", + "params": "not documented", + "purpose": "List all roles with their permissions.", + "example": "curl http://localhost:5001/api/users/roles -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "color": null, + "description": "Administrator", + "grantable": null, + "isadmin": true, + "isguest": false, + "permissions": [], + "roleid": 1, + "rolename": "admin", + "usercount": 1 + }, + { + "color": "#6C757D", + "description": "Anyone who is not signed in. Holds nothing by default; grant it a permission to open that up to people with no account.", + "grantable": [ + "applications.view", + "assets.view", + "computers.view", + "" + ], + "isadmin": false, + "isguest": true, + "permissions": [], + "roleid": 2, + "rolename": "guest", + "usercount": 0 + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/users/roles", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new role.", + "example": "curl -X POST http://localhost:5001/api/users/roles -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "color": "#ff8800", + "description": "colorful", + "permissions": [], + "rolename": "painters" + }, + "responseexample": { + "data": { + "color": "#ff8800", + "description": "colorful", + "permissions": [], + "roleid": 2, + "rolename": "painters" + }, + "message": "Role created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/users/roles/", + "auth": "jwt + role:admin", + "params": "path: roleid (int)", + "purpose": "Delete a role.", + "example": "curl -X DELETE http://localhost:5001/api/users/roles/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/users/roles/", + "auth": "jwt + role:admin", + "params": "path: roleid (int); body: JSON (fields not yet documented)", + "purpose": "Update a role.", + "example": "curl -X PUT http://localhost:5001/api/users/roles/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "permissions": [ + "reports.view" + ] + }, + "responseexample": { + "data": { + "color": "#6C757D", + "description": "Anyone who is not signed in. Holds nothing by default; grant it a permission to open that up to people with no account.", + "permissions": [ + "reports.view" + ], + "roleid": 2, + "rolename": "guest" + }, + "message": "Role updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + } + ] + }, + { + "surface": "core-vendors", + "endpoints": [ + { + "method": "GET", + "path": "/api/vendors", + "auth": "permission:assets.view", + "params": "not documented", + "purpose": "List all vendors.", + "example": "curl http://localhost:5001/api/vendors -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "vendor", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [ + { + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "supportemail": "", + "supportphone": "", + "vendor": "Acme", + "vendorid": 1, + "website": null + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/vendors", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new vendor.", + "example": "curl -X POST http://localhost:5001/api/vendors -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "createddate": "2026-01-01 00:00:00", + "vendor": "Legacy Vendor" + }, + "responseexample": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "supportemail": "", + "supportphone": "", + "vendor": "Unscoped Vendor", + "vendorid": 1, + "website": null + }, + "message": "Vendor created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/vendors/", + "auth": "jwt + role:admin", + "params": "path: vendor_id (int)", + "purpose": "Delete (deactivate) a vendor.", + "example": "curl -X DELETE http://localhost:5001/api/vendors/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/vendors/", + "auth": "permission:assets.view", + "params": "path: vendor_id (int)", + "purpose": "Get a single vendor.", + "example": "curl http://localhost:5001/api/vendors/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/vendors/", + "auth": "jwt + role:admin", + "params": "path: vendor_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a vendor.", + "example": "curl -X PUT http://localhost:5001/api/vendors/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "plugin-backups", + "endpoints": [ + { + "method": "GET", + "path": "/api/backups/asset/", + "auth": "permission:backups.view", + "params": "path: assetid (int)", + "purpose": "Revision history for one asset, newest first.", + "example": "curl http://localhost:5001/api/backups/asset/1 -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "kind", + "type": "string", + "required": false + } + ] + }, + { + "method": "GET", + "path": "/api/backups/asset//info", + "auth": "permission:backups.view", + "params": "path: assetid (int)", + "purpose": "A kind's 'at a glance' card, built from its LATEST revision.", + "example": "curl http://localhost:5001/api/backups/asset/1/info -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "kind", + "type": "string", + "required": false + } + ] + }, + { + "method": "GET", + "path": "/api/backups/dashboard/stale", + "auth": "permission:backups.view", + "params": "not documented", + "purpose": "Chains whose backup has stopped running.", + "example": "curl http://localhost:5001/api/backups/dashboard/stale -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/backups/kinds", + "auth": "permission:backups.view", + "params": "not documented", + "purpose": "Registered backup kinds, for UI that needs to enumerate them.", + "example": "curl http://localhost:5001/api/backups/kinds -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "assettypes": [ + "machine" + ], + "displayname": "", + "formats": [ + { + "ext": ".reg", + "hint": "Restore with the Load... button in the NTLARS settings dialog.", + "id": "ntlars", + "label": "NTLARS Load... (.reg)", + "mimetype": "application/octet-stream" + }, + { + "ext": ".reg", + "hint": "Use when importing outside NTLARS on a 64-bit machine.", + "id": "wow6432node", + "label": "Direct reg import (.reg, WOW6432Node)", + "mimetype": "application/octet-stream" + } + ], + "key": "ntlars", + "storagebackend": "shopdb" + }, + { + "assettypes": [ + "machine" + ], + "displayname": "", + "formats": [], + "key": "partmarker", + "storagebackend": "share" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/backups/revisions/", + "auth": "permission:backups.view", + "params": "path: backuprevisionid (int)", + "purpose": "One revision, including its parsed payload where there is one.", + "example": "curl http://localhost:5001/api/backups/revisions/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/backups/revisions//diff", + "auth": "permission:backups.view", + "params": "path: backuprevisionid (int)", + "purpose": "Diff two revisions of the same asset+kind.", + "example": "curl http://localhost:5001/api/backups/revisions/1/diff -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "against", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": { + "againstid": 1, + "backuprevisionid": 2, + "changecount": 1, + "changes": [ + { + "after": "OKUMA", + "aftertype": "REG_SZ", + "before": "", + "beforetype": "REG_SZ", + "change": "changed", + "keypath": "General", + "valuename": "Cnc" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/backups/revisions//download", + "auth": "permission:backups.download", + "params": "path: backuprevisionid (int)", + "purpose": "Render a revision back to its native file format.", + "example": "curl http://localhost:5001/api/backups/revisions/1/download -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "format", + "type": "string", + "required": false + } + ] + } + ] + }, + { + "surface": "plugin-computers", + "endpoints": [ + { + "method": "GET", + "path": "/api/computers", + "auth": "permission:computers.view", + "params": "not documented", + "purpose": "List all computers with filtering and pagination.", + "example": "curl http://localhost:5001/api/computers -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "assetnumber", + "type": "string", + "required": false + }, + { + "name": "businessunit_id", + "type": "integer", + "required": false + }, + { + "name": "businessunitid", + "type": "integer", + "required": false + }, + { + "name": "dir", + "type": "string", + "required": false, + "default": "asc" + }, + { + "name": "location_id", + "type": "integer", + "required": false + }, + { + "name": "locationid", + "type": "integer", + "required": false + }, + { + "name": "os_id", + "type": "integer", + "required": false + }, + { + "name": "osid", + "type": "integer", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "shopfloor", + "type": "string", + "required": false + }, + { + "name": "sort", + "type": "string", + "required": false, + "default": "hostname" + }, + { + "name": "type_id", + "type": "integer", + "required": false + }, + { + "name": "typeid", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [ + { + "accessmethods": [], + "assetid": 1, + "assetnumber": "PC-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "computer", + "businessunitid": null, + "computer": { + "accessprotocolnames": [], + "assetid": 1, + "azuredevicecategory": null, + "azuredeviceid": null, + "azureobjectid": null, + "computerid": 1, + "computertypeid": 1, + "computertypename": "Standard", + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "isactive": true, + "lastboottime": null, + "lastreporteddate": null, + "loggedinuser": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "osid": null, + "vendorid": null + }, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/computers", + "auth": "permission:computers.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create new computer (creates both Asset and Computer records).", + "example": "curl -X POST http://localhost:5001/api/computers -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "assetnumber": "PC-IMPORT-1", + "createddate": "2026-01-01 00:00:00", + "hostname": "", + "modifieddate": "2026-01-01T00:00:00" + }, + "responseexample": { + "data": { + "accessmethods": [], + "assetid": 1, + "assetnumber": "AUDIT-PC-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "computer", + "businessunitid": null, + "computer": { + "accessprotocolnames": [], + "assetid": 1, + "azuredevicecategory": null, + "azuredeviceid": null, + "azureobjectid": null, + "computerid": 1, + "computertypeid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "isactive": true, + "lastboottime": null, + "lastreporteddate": null, + "loggedinuser": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "osid": null, + "vendorid": null + }, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statuscolor": null, + "statusid": 1, + "statusname": "In Use" + }, + "message": "Computer created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/computers/", + "auth": "permission:computers.delete", + "params": "path: computer_id (int)", + "purpose": "Delete (soft delete) computer.", + "example": "curl -X DELETE http://localhost:5001/api/computers/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/computers/", + "auth": "permission:computers.view", + "params": "path: computer_id (int)", + "purpose": "Get a single computer with full details.", + "example": "curl http://localhost:5001/api/computers/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "accessmethods": [], + "assetid": 1, + "assetnumber": "PC-2001", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "computer", + "businessunitid": null, + "communications": [], + "computer": { + "accessprotocolnames": [], + "assetid": 1, + "azuredevicecategory": null, + "azuredeviceid": null, + "azureobjectid": null, + "computerid": 1, + "computertypeid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "imageurl": "/api/models/images/model-1.png", + "isactive": true, + "lastboottime": null, + "lastreporteddate": null, + "loggedinuser": null, + "modelname": "IMG-MODEL-1", + "modelnumberid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "osid": null, + "vendorid": null + }, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/computers/", + "auth": "permission:computers.edit", + "params": "path: computer_id (int); body: JSON (fields not yet documented)", + "purpose": "Update computer (both Asset and Computer records).", + "example": "curl -X PUT http://localhost:5001/api/computers/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "gaugelabreference": "GL-2", + "maintenancereference": "MNT-2" + }, + "responseexample": { + "data": { + "accessmethods": [ + { + "id": 1, + "link": "rdp://auditpc1", + "name": "RDP", + "port": null, + "portoverride": null, + "protocolid": 1, + "scheme": "rdp" + } + ], + "assetid": 1, + "assetnumber": "AUDIT-PC-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "computer", + "businessunitid": null, + "computer": { + "accessprotocolnames": [ + "RDP" + ], + "assetid": 1, + "azuredevicecategory": null, + "azuredeviceid": null, + "azureobjectid": null, + "computerid": 1, + "computertypeid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "isactive": true, + "lastboottime": null, + "lastreporteddate": null, + "loggedinuser": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "osid": null, + "vendorid": null + }, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statuscolor": null, + "statusid": 1, + "statusname": "In Use" + }, + "message": "Computer updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/computers//apps", + "auth": "permission:computers.view", + "params": "path: computer_id (int)", + "purpose": "Get all installed applications for a computer.", + "example": "curl http://localhost:5001/api/computers/1/apps -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/computers//apps", + "auth": "permission:computers.create", + "params": "path: computer_id (int); body: JSON (fields not yet documented)", + "purpose": "Add an installed application to a computer.", + "example": "curl -X POST http://localhost:5001/api/computers/1/apps -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/computers//apps/", + "auth": "permission:computers.delete", + "params": "path: computer_id (int), app_id (int)", + "purpose": "Remove an installed application from a computer.", + "example": "curl -X DELETE http://localhost:5001/api/computers/1/apps/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/computers//report", + "auth": "permission:computers.create", + "params": "path: computer_id (int); body: JSON (fields not yet documented)", + "purpose": "Report computer status (for agent-based reporting).", + "example": "curl -X POST http://localhost:5001/api/computers/1/report -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/computers/asset//policypanel", + "auth": "permission:computers.view", + "params": "path: asset_id (int)", + "purpose": "Keyvalue panel: which policy governs this endpoint, and what it allows.", + "example": "curl http://localhost:5001/api/computers/asset/1/policypanel -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/computers/by-asset/", + "auth": "permission:computers.view", + "params": "path: asset_id (int)", + "purpose": "Get computer data by asset ID.", + "example": "curl http://localhost:5001/api/computers/by-asset/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/computers/by-hostname/", + "auth": "permission:computers.view", + "params": "path: hostname (string)", + "purpose": "Get computer by hostname.", + "example": "curl http://localhost:5001/api/computers/by-hostname/x -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/computers/client-script", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "The collector reporter, stamped with THIS site's values.", + "example": "curl http://localhost:5001/api/computers/client-script -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/computers/dashboard/quiet", + "auth": "permission:computers.view", + "params": "not documented", + "purpose": "PCs that WERE reporting, have stopped, and are still meant to be in use.", + "example": "curl http://localhost:5001/api/computers/dashboard/quiet -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "computerid": 2, + "hostname": "", + "lastreported": "2026-01-01T00:00:00.000000Z", + "quietdays": 9 + }, + { + "computerid": 1, + "hostname": "", + "lastreported": "2026-01-01T00:00:00.000000Z", + "quietdays": 2 + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/computers/dashboard/sharedmachines", + "auth": "permission:computers.view", + "params": "not documented", + "purpose": "Machine numbers claimed by more than one PC, with nothing filed under them.", + "example": "curl http://localhost:5001/api/computers/dashboard/sharedmachines -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "assetid": 1, + "machinenumber": "2026", + "pccount": 2, + "pcs": "PCONE, PCTWO" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/computers/dashboard/summary", + "auth": "permission:computers.view", + "params": "not documented", + "purpose": "Get computer dashboard summary data.", + "example": "curl http://localhost:5001/api/computers/dashboard/summary -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "byos": [], + "bytype": [], + "nonshopfloor": 0, + "shopfloor": 0, + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/computers/display-kiosks", + "auth": "permission:computers.view", + "params": "not documented", + "purpose": "Reporting display kiosks, for the Dashboard Defaults picker.", + "example": "curl http://localhost:5001/api/computers/display-kiosks -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "computerid": 1, + "fqdn": "", + "hostname": "", + "serialnumber": "" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/computers/policies/actions", + "auth": "permission:computers.view", + "params": "not documented", + "purpose": "The allowlisted action names + labels a policy may reference.", + "example": "curl http://localhost:5001/api/computers/policies/actions -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "action": "service_start", + "description": "Start a Windows service by name on the endpoint.", + "kind": "command", + "label": "Start service", + "mayauthor": true, + "requiredpermission": null + }, + { + "action": "service_stop", + "description": "Stop a running Windows service by name on the endpoint.", + "kind": "command", + "label": "Stop service", + "mayauthor": true, + "requiredpermission": null + }, + { + "action": "service_status", + "description": "Report whether a named Windows service is running.", + "kind": "command", + "label": "Check service", + "mayauthor": true, + "requiredpermission": null + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/computers/policies/business", + "auth": "permission:computers.view", + "params": "not documented", + "purpose": "UNDOCUMENTED: computers.list_business_policies has no docstring.", + "example": "curl http://localhost:5001/api/computers/policies/business -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/computers/policies/business", + "auth": "permission:computers.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: computers.create_business_policy has no docstring.", + "example": "curl -X POST http://localhost:5001/api/computers/policies/business -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/computers/policies/business/", + "auth": "permission:computers.delete", + "params": "path: policy_id (int)", + "purpose": "UNDOCUMENTED: computers.delete_business_policy has no docstring.", + "example": "curl -X DELETE http://localhost:5001/api/computers/policies/business/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/computers/policies/business/", + "auth": "permission:computers.view", + "params": "path: policy_id (int)", + "purpose": "UNDOCUMENTED: computers.get_business_policy has no docstring.", + "example": "curl http://localhost:5001/api/computers/policies/business/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/computers/policies/business/", + "auth": "permission:computers.edit", + "params": "path: policy_id (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: computers.update_business_policy has no docstring.", + "example": "curl -X PUT http://localhost:5001/api/computers/policies/business/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/computers/policies/configuration", + "auth": "permission:computers.view", + "params": "not documented", + "purpose": "UNDOCUMENTED: computers.list_configuration_policies has no docstring.", + "example": "curl http://localhost:5001/api/computers/policies/configuration -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/computers/policies/configuration", + "auth": "permission:computers.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: computers.create_configuration_policy has no docstring.", + "example": "curl -X POST http://localhost:5001/api/computers/policies/configuration -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "actions": [ + { + "action": "run_script" + } + ], + "name": "Legitimate" + }, + "responseexample": { + "data": { + "actions": [ + { + "action": "run_script", + "configurationpolicyactionid": 1, + "configurationpolicyid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "desiredstate": null, + "intervalminutes": null, + "isactive": true, + "mode": "gate", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "params": null, + "sortorder": 0 + } + ], + "configurationpolicyid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Legitimate", + "scopejson": null, + "source": "local" + }, + "message": "Configuration policy created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/computers/policies/configuration/", + "auth": "permission:computers.delete", + "params": "path: policy_id (int)", + "purpose": "UNDOCUMENTED: computers.delete_configuration_policy has no docstring.", + "example": "curl -X DELETE http://localhost:5001/api/computers/policies/configuration/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/computers/policies/configuration/", + "auth": "permission:computers.view", + "params": "path: policy_id (int)", + "purpose": "UNDOCUMENTED: computers.get_configuration_policy has no docstring.", + "example": "curl http://localhost:5001/api/computers/policies/configuration/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/computers/policies/configuration/", + "auth": "permission:computers.edit", + "params": "path: policy_id (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: computers.update_configuration_policy has no docstring.", + "example": "curl -X PUT http://localhost:5001/api/computers/policies/configuration/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "description": "clearer wording", + "name": "New Name" + }, + "responseexample": { + "data": { + "actions": [ + { + "action": "run_script", + "configurationpolicyactionid": 1, + "configurationpolicyid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "desiredstate": null, + "intervalminutes": null, + "isactive": true, + "mode": "gate", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "params": null, + "sortorder": 0 + } + ], + "configurationpolicyid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "clearer wording", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "New Name", + "scopejson": null, + "source": "local" + }, + "message": "Configuration policy updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/computers/policies/effective/", + "auth": "permission:computers.view", + "params": "path: asset_id (int)", + "purpose": "Preview the resolved effective policy for one endpoint (debug/UI).", + "example": "curl http://localhost:5001/api/computers/policies/effective/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/computers/protocols", + "auth": "permission:computers.view", + "params": "not documented", + "purpose": "List access protocols. ?active=false includes disabled ones.", + "example": "curl http://localhost:5001/api/computers/protocols -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + } + ], + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/computers/protocols", + "auth": "permission:computers.edit", + "params": "body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: computers.create_protocol has no docstring.", + "example": "curl -X POST http://localhost:5001/api/computers/protocols -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/computers/protocols/", + "auth": "permission:computers.edit", + "params": "path: protocol_id (int)", + "purpose": "UNDOCUMENTED: computers.delete_protocol has no docstring.", + "example": "curl -X DELETE http://localhost:5001/api/computers/protocols/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PATCH|PUT", + "path": "/api/computers/protocols/", + "auth": "permission:computers.edit", + "params": "path: protocol_id (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: computers.update_protocol has no docstring.", + "example": "curl -X PATCH http://localhost:5001/api/computers/protocols/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/computers/types", + "auth": "permission:computers.view", + "params": "not documented", + "purpose": "List all computer types.", + "example": "curl http://localhost:5001/api/computers/types -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/computers/types", + "auth": "permission:computers.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new computer type.", + "example": "curl -X POST http://localhost:5001/api/computers/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/computers/types/", + "auth": "permission:computers.delete", + "params": "path: type_id (int)", + "purpose": "Delete a computer type. Refused if any PC still uses it.", + "example": "curl -X DELETE http://localhost:5001/api/computers/types/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/computers/types/", + "auth": "permission:computers.view", + "params": "path: type_id (int)", + "purpose": "Get a single computer type.", + "example": "curl http://localhost:5001/api/computers/types/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/computers/types/", + "auth": "permission:computers.edit", + "params": "path: type_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a computer type.", + "example": "curl -X PUT http://localhost:5001/api/computers/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "plugin-endpoints", + "endpoints": [ + { + "method": "GET", + "path": "/api/endpoints", + "auth": "permission:endpoints.fleet", + "params": "not documented", + "purpose": "List reported endpoint states.", + "example": "curl http://localhost:5001/api/endpoints -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "businessunitid", + "type": "string", + "required": false + }, + { + "name": "computertypeid", + "type": "string", + "required": false + }, + { + "name": "credentialstate", + "type": "string", + "required": false + }, + { + "name": "locationid", + "type": "string", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "sort", + "type": "string", + "required": false + }, + { + "name": "status", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [ + { + "agentversion": "1.20.0", + "alias": "REVOKED", + "assetid": 6, + "assetnumber": "REVOKED", + "computerid": 6, + "computertype": null, + "computertypeid": null, + "cpupercent": null, + "createdat": "2026-01-01T00:00:00.000000Z", + "credentialstate": "", + "domain": null, + "endpointstateid": 6, + "health": { + "label": "ok", + "level": "ok" + }, + "hostname": "", + "idleseconds": null, + "lastboottime": null, + "lastreportedat": "2026-01-01T00:00:00.000000Z", + "loggedinuser": null, + "machines": [], + "memorypercent": null, + "mtconnectcheckedat": null, + "mtconnectrunning": null, + "pendingreboot": null, + "status": "reporting", + "trayversion": null, + "uptimedisplay": null, + "uptimeseconds": null + }, + { + "agentversion": "1.20.0", + "alias": "SHARED", + "assetid": 5, + "assetnumber": "SHARED", + "computerid": 5, + "computertype": null, + "computertypeid": null, + "cpupercent": null, + "createdat": "2026-01-01T00:00:00.000000Z", + "credentialstate": "", + "domain": null, + "endpointstateid": 5, + "health": { + "label": "ok", + "level": "ok" + }, + "hostname": "", + "idleseconds": null, + "lastboottime": null, + "lastreportedat": "2026-01-01T00:00:00.000000Z", + "loggedinuser": null, + "machines": [], + "memorypercent": null, + "mtconnectcheckedat": null, + "mtconnectrunning": null, + "pendingreboot": null, + "status": "reporting", + "trayversion": null, + "uptimedisplay": null, + "uptimeseconds": null + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 2, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/", + "auth": "permission:endpoints.fleet", + "params": "not documented", + "purpose": "List reported endpoint states.", + "example": "curl http://localhost:5001/api/endpoints/ -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "businessunitid", + "type": "string", + "required": false + }, + { + "name": "computertypeid", + "type": "string", + "required": false + }, + { + "name": "credentialstate", + "type": "string", + "required": false + }, + { + "name": "locationid", + "type": "string", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "sort", + "type": "string", + "required": false + }, + { + "name": "status", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [ + { + "agentversion": null, + "alias": "Bay 3 Controller", + "assetid": 1, + "assetnumber": "fleetbox", + "computerid": 1, + "computertype": null, + "computertypeid": null, + "cpupercent": null, + "createdat": "2026-01-01T00:00:00.000000Z", + "credentialstate": "", + "domain": null, + "endpointstateid": 1, + "health": { + "label": "no agent", + "level": "unknown" + }, + "hostname": "", + "idleseconds": null, + "lastboottime": null, + "lastreportedat": null, + "loggedinuser": null, + "machines": [], + "memorypercent": null, + "mtconnectcheckedat": null, + "mtconnectrunning": null, + "pendingreboot": null, + "status": "never", + "trayversion": null, + "uptimedisplay": null, + "uptimeseconds": null + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Get the current state for one endpoint by asset id.", + "example": "curl http://localhost:5001/api/endpoints/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "actionminimums": { + "collect_log": "1.16.0" + }, + "agentcurrent": "1.6.3", + "agentversion": "1.20.0", + "assetid": 2, + "cpupercent": null, + "createdat": "2026-01-01T00:00:00.000000Z", + "credential": { + "credentialprefix": "", + "enrolledvia": "auto", + "issuedat": "2026-01-01T00:00:00.000000Z", + "lastusedat": "2026-01-01T00:00:00.000000Z", + "reenrollblocked": true, + "reenrollblockedreason": "rollback", + "revokedat": "2026-01-01T00:00:00.000000Z", + "state": "reenrollblocked", + "statelabel": "Re-enroll blocked" + }, + "domain": null, + "endpointstateid": 2, + "hostname": "", + "idleseconds": null, + "lastboottime": null, + "lastreportedat": "2026-01-01T00:00:00.000000Z", + "loggedinuser": null, + "memorypercent": null, + "mtconnectcheckedat": null, + "mtconnectrunning": null, + "pendingreboot": null, + "status": "reporting", + "trayversion": null, + "uptimedisplay": null, + "uptimeseconds": null + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints//agentupgrade", + "auth": "permission:endpoints.agentupdate", + "params": "path: asset_id (int); body: JSON (fields not yet documented)", + "purpose": "Push the published agent build to one endpoint.", + "example": "curl -X POST http://localhost:5001/api/endpoints/1/agentupgrade -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "action": "run_script", + "target": "script:1" + }, + "responseexample": { + "data": { + "action": "run_script", + "assetid": 1, + "claimedat": null, + "completedat": null, + "createdat": "2026-01-01T00:00:00.000000Z", + "endpointcommandid": 1, + "requestedby": "", + "resultmessage": null, + "status": "pending", + "target": "script:1", + "targetkind": "script" + }, + "message": "Command queued", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints//commands", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Recent commands for one endpoint, newest first (operator view).", + "example": "curl http://localhost:5001/api/endpoints/1/commands -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/endpoints//commands", + "auth": "permission:endpoints.command", + "params": "path: asset_id (int); body: JSON (fields not yet documented)", + "purpose": "Queue an allowlisted command for one endpoint.", + "example": "curl -X POST http://localhost:5001/api/endpoints/1/commands -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "action": "service_status", + "overridereason": "line down, need status", + "policyoverride": true, + "target": "spooler" + }, + "responseexample": { + "data": { + "action": "collect_log", + "assetid": 1, + "claimedat": null, + "completedat": null, + "createdat": "2026-01-01T00:00:00.000000Z", + "endpointcommandid": 1, + "requestedby": "", + "resultmessage": null, + "status": "pending", + "target": "C:\\logs\\app.log", + "targetkind": "logpath" + }, + "message": "Command queued", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints//events", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "List curated Windows events for one endpoint, newest first.", + "example": "curl http://localhost:5001/api/endpoints/1/events -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "eventid", + "type": "string", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + } + ] + }, + { + "method": "GET", + "path": "/api/endpoints//holdableactions", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Desired-mode actions on this endpoint, with current hold state.", + "example": "curl http://localhost:5001/api/endpoints/1/holdableactions -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "action": "service_start", + "held": false + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "DELETE", + "path": "/api/endpoints//lock", + "auth": "permission:endpoints.command", + "params": "path: asset_id (int)", + "purpose": "Lift the quarantine on this endpoint.", + "example": "curl -X DELETE http://localhost:5001/api/endpoints/1/lock -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints//lock", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "The endpoint lock standing on this endpoint, or null.", + "example": "curl http://localhost:5001/api/endpoints/1/lock -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "assetid": 1, + "endpointlockid": 1, + "lockedat": "2026-01-01T00:00:00.000000Z", + "lockedbyuserid": 1, + "lockedbyusername": "", + "reason": "visible to all", + "releasedat": null, + "releasedbyusername": "" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints//lock", + "auth": "permission:endpoints.command", + "params": "path: asset_id (int); body: JSON (fields not yet documented)", + "purpose": "Quarantine this endpoint against non-admin commands.", + "example": "curl -X POST http://localhost:5001/api/endpoints/1/lock -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "reason": "locked but still reconciled" + }, + "responseexample": { + "data": { + "assetid": 1, + "endpointlockid": 1, + "lockedat": "2026-01-01T00:00:00.000000Z", + "lockedbyuserid": 1, + "lockedbyusername": "", + "reason": "locked but still reconciled", + "releasedat": null, + "releasedbyusername": "" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints//logcollections", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Collected-log history for one endpoint, newest first.", + "example": "curl http://localhost:5001/api/endpoints/1/logcollections -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints//policyholds", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Active break-glass holds for one endpoint.", + "example": "curl http://localhost:5001/api/endpoints/1/policyholds -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "action": "service_stop", + "createdbyusername": "", + "expiresat": "2026-01-01T00:00:00.000000", + "reason": "vendor on site" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints//policyholds", + "auth": "permission:endpoints.command", + "params": "path: asset_id (int); body: JSON (fields not yet documented)", + "purpose": "Deliberately suspend desired-state reconciliation for one or more actions.", + "example": "curl -X POST http://localhost:5001/api/endpoints/1/policyholds -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "actions": [ + "service_start", + "service_stop" + ], + "minutes": 60, + "reason": "stripping the enclosure" + }, + "responseexample": { + "data": [ + { + "action": "service_stop", + "createdbyusername": "", + "expiresat": "2026-01-01T00:00:00.000000", + "reason": "stripping the enclosure" + }, + { + "action": "service_start", + "createdbyusername": "", + "expiresat": "2026-01-01T00:00:00.000000", + "reason": "stripping the enclosure" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/endpoints//policyholds/", + "auth": "permission:endpoints.command", + "params": "path: asset_id (int), action (string)", + "purpose": "End a hold early, resuming reconciliation of one action.", + "example": "curl -X DELETE http://localhost:5001/api/endpoints/1/policyholds/x -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints//scriptruns", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "The last few run_script commands for one endpoint, newest first.", + "example": "curl http://localhost:5001/api/endpoints/1/scriptruns -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "limit", + "type": "integer", + "required": false, + "default": 5 + } + ], + "responseexample": { + "data": [ + { + "action": "run_script", + "assetid": 1, + "claimedat": null, + "completedat": null, + "createdat": "2026-01-01T00:00:00Z", + "endpointcommandid": 6, + "requestedby": "", + "resultdetail": null, + "resultmessage": null, + "scriptname": "test-open-notepad", + "status": "succeeded", + "target": "script:1", + "targetkind": null + }, + { + "action": "run_script", + "assetid": 1, + "claimedat": null, + "completedat": null, + "createdat": "2026-01-01T00:00:00Z", + "endpointcommandid": 5, + "requestedby": "", + "resultdetail": null, + "resultmessage": null, + "scriptname": "test-open-notepad", + "status": "succeeded", + "target": "script:1", + "targetkind": null + }, + { + "action": "run_script", + "assetid": 1, + "claimedat": null, + "completedat": null, + "createdat": "2026-01-01T00:00:00Z", + "endpointcommandid": 4, + "requestedby": "", + "resultdetail": null, + "resultmessage": null, + "scriptname": "test-open-notepad", + "status": "succeeded", + "target": "script:1", + "targetkind": null + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints/adoptiontool/package", + "auth": "permission:endpoints.agentpackage", + "params": "body: JSON (fields not yet documented)", + "purpose": "Bundle the adoption tool as a downloadable ZIP.", + "example": "curl -X POST http://localhost:5001/api/endpoints/adoptiontool/package -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/endpoints/agentkey", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "Revoke the endpoint agent key: stop all managed-token endpoint traffic.", + "example": "curl -X DELETE http://localhost:5001/api/endpoints/agentkey -H \"Authorization: Bearer $TOK\"", + "requestexample": {}, + "responseexample": { + "data": { + "envkeyfallback": true, + "key": null, + "provisioned": false, + "revoked": 0 + }, + "message": "Endpoint agent key revoked", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/agentkey", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "Report the endpoint agent key state (no secret).", + "example": "curl http://localhost:5001/api/endpoints/agentkey -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/endpoints/agentkey/regenerate", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Mint a fresh endpoint agent key, revoking any previous one.", + "example": "curl -X POST http://localhost:5001/api/endpoints/agentkey/regenerate -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": {}, + "responseexample": { + "data": { + "envkeyfallback": false, + "key": { + "createddate": "2026-01-01T00:00:00.000000Z", + "displayprefix": "", + "expiresat": null, + "isactive": true, + "isexpired": false, + "lastusedat": null, + "name": "Endpoint agent key", + "resourcescopes": null, + "scopes": [ + "collector.ingest.endpoints" + ], + "tokenid": 1, + "tokenprefix": "", + "userid": 1 + }, + "provisioned": true, + "secret": "", + "warning": "Save this key now. It will not be shown again. Configure it as the agent X-API-Key." + }, + "message": "Endpoint agent key generated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/alerts", + "auth": "api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)", + "params": "not documented", + "purpose": "Fleet alerts for one endpoint by hostname (collector-token auth).", + "example": "curl http://localhost:5001/api/endpoints/alerts -H \"X-API-Key: $KEY\"", + "queryparams": [ + { + "name": "hostname", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [ + { + "message": "Scheduled maintenance at 3:30 PM", + "notificationId": 1, + "notificationid": 1, + "timeout": 0, + "title": "Scheduled maintenance at 3:30 PM", + "type": "Fleet Alert", + "typeColor": "#ffc107", + "typecolor": "#ffc107" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints/alerts//acknowledge", + "auth": "api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)", + "params": "path: notification_id (int); body: JSON (fields not yet documented)", + "purpose": "Agent acknowledges a fleet alert (collector-token auth).", + "example": "curl -X POST http://localhost:5001/api/endpoints/alerts/1/acknowledge -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "assetid": 1, + "hostname": "" + }, + "responseexample": { + "data": { + "acknowledged": true + }, + "message": "Alert already acknowledged", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints/apppackage/", + "auth": "api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)", + "params": "path: appversionid (int)", + "purpose": "Agent-facing installer download (collector-token auth).", + "example": "curl http://localhost:5001/api/endpoints/apppackage/1 -H \"X-API-Key: $KEY\"" + }, + { + "method": "GET", + "path": "/api/endpoints/apppackage/app/", + "auth": "api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)", + "params": "path: app_id (int)", + "purpose": "Agent-facing installer download by APP (collector-token auth).", + "example": "curl http://localhost:5001/api/endpoints/apppackage/app/1 -H \"X-API-Key: $KEY\"" + }, + { + "method": "GET", + "path": "/api/endpoints/apppackage/app//", + "auth": "api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)", + "params": "path: app_id (int), filename (path)", + "purpose": "Agent-facing installer download by APP (collector-token auth).", + "example": "curl http://localhost:5001/api/endpoints/apppackage/app/1/example.png -H \"X-API-Key: $KEY\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//activity", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "List panel: one chronological feed of what happened to this endpoint.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/activity -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//applications", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Table panel: the installed-software (SBOM) snapshot for this endpoint.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/applications -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//diskhealth", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Physical drive health rows.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/diskhealth -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//hardware", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Keyvalue panel: processor, memory and system chassis inventory.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/hardware -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//interfaces", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Table panel: every hardware NIC the agent last reported.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/interfaces -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//iphistory", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Table panel: every address this asset has held, newest change first.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/iphistory -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//listeners", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Table panel: TCP sockets in LISTEN, with the owning process.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/listeners -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//mappeddrives", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "List panel: mapped network drives with a health badge each.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/mappeddrives -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//osinfo", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Keyvalue panel: operating system, firmware and domain membership.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/osinfo -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//recentevents", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "List panel: the endpoint's most recent curated events, newest first.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/recentevents -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//services", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "List panel: monitored services/processes with a run-state badge per row.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/services -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "action": { + "running": false, + "start": "process_start", + "status": "process_status", + "stop": "process_stop", + "target": "dncMain.exe", + "targetkind": "process" + }, + "drift": true, + "kind": "process", + "name": "eDNC", + "statecolor": "gray", + "statelabel": "Unknown" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/asset//status", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Keyvalue panel: the endpoint's current status snapshot.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/status -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "agentbehind": false, + "agentcurrent": "1.6.3", + "agentversion": "1.20.0", + "credential": { + "credentialprefix": "", + "enrolledvia": "auto", + "issuedat": "2026-01-01T00:00:00.000000Z", + "lastusedat": "2026-01-01T00:00:00.000000Z", + "reenrollblocked": true, + "reenrollblockedreason": "rollback", + "revokedat": "2026-01-01T00:00:00.000000Z", + "state": "reenrollblocked", + "statelabel": "Re-enroll blocked" + }, + "fields": [ + { + "label": "Agent", + "mono": true, + "value": "1.20.0" + }, + { + "format": "datetime", + "label": "Last reported", + "mono": false, + "value": "2026-01-01T00:00:00.000000Z" + }, + { + "label": "Credential", + "mono": false, + "value": "Re-enroll blocked" + }, + "" + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/asset//storage", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Tabs panel: volume capacity and physical drive health as two sections.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/storage -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//telemetrymeta", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Lightweight freshness probe for the panels.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/telemetrymeta -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//usercontext", + "auth": "permission:endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Keyvalue panel: session-scoped data from the interactive user probe.", + "example": "curl http://localhost:5001/api/endpoints/asset/1/usercontext -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/endpoints/collect", + "auth": "api-key (endpoints collector token)", + "params": "body: JSON (fields not yet documented)", + "purpose": "Agent telemetry ingest. Same handler as POST /api/collector/endpoints.", + "example": "curl -X POST http://localhost:5001/api/endpoints/collect -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "agentversion": "1.20.0", + "hostname": "" + }, + "responseexample": { + "data": { + "action": "created", + "assetid": 1, + "identityvalue": "BAY-01", + "status": "ok", + "warnings": [] + }, + "message": "endpoints collector created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints/commands//result", + "auth": "api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)", + "params": "path: command_id (int); body: JSON (fields not yet documented)", + "purpose": "Agent reports a claimed command's outcome (collector-token auth).", + "example": "curl -X POST http://localhost:5001/api/endpoints/commands/1/result -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'", + "queryparams": [ + { + "name": "hostname", + "type": "string", + "required": false + } + ], + "requestexample": { + "succeeded": true + }, + "responseexample": { + "data": { + "action": "mtconnect_status", + "assetid": 1, + "claimedat": null, + "completedat": "2026-01-01T00:00:00.000000Z", + "createdat": "2026-01-01T00:00:00.000000Z", + "endpointcommandid": 1, + "requestedby": "", + "resultmessage": null, + "status": "succeeded", + "target": null, + "targetkind": null + }, + "message": "Result recorded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints/commands/next", + "auth": "api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)", + "params": "not documented", + "purpose": "Agent long-poll: claim the next pending command for a hostname.", + "example": "curl http://localhost:5001/api/endpoints/commands/next -H \"X-API-Key: $KEY\"", + "queryparams": [ + { + "name": "hostname", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "action": "mtconnect_status", + "endpointcommandid": 1, + "target": null, + "targetkind": null + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/dashboard/enrollmentpending", + "auth": "permission:endpoints.fleet", + "params": "not documented", + "purpose": "One queue row while enroll requests wait for an admin, else nothing.", + "example": "curl http://localhost:5001/api/endpoints/dashboard/enrollmentpending -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "pendingcount": 2, + "summary": "2 PCs waiting for enrollment approval" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/dashboard/quiet", + "auth": "permission:endpoints.fleet", + "params": "not documented", + "purpose": "Endpoints that reported before, then went quiet past the window.", + "example": "curl http://localhost:5001/api/endpoints/dashboard/quiet -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/dashboard/reenrollblocked", + "auth": "permission:endpoints.fleet", + "params": "not documented", + "purpose": "PCs kept from re-enrolling for more than 7 days.", + "example": "curl http://localhost:5001/api/endpoints/dashboard/reenrollblocked -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [ + { + "assetid": 2, + "blockeddays": 9, + "computerid": 2, + "hostname": "", + "reason": "rollback" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/dashboard/stability", + "auth": "permission:endpoints.fleet", + "params": "not documented", + "purpose": "Endpoints with recent crash/shutdown/corruption events.", + "example": "curl http://localhost:5001/api/endpoints/dashboard/stability -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/dashboard/summary", + "auth": "permission:endpoints.fleet", + "params": "not documented", + "purpose": "High-level fleet counts for the endpoints dashboard.", + "example": "curl http://localhost:5001/api/endpoints/dashboard/summary -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/endpoints/enroll", + "auth": "none (per-IP rate limited and capped; a valid fleet key only decides auto-enroll)", + "params": "body: JSON (fields not yet documented)", + "purpose": "A PC asks for its own credential.", + "example": "curl -X POST http://localhost:5001/api/endpoints/enroll -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "agentversion": "1.20.0", + "deviceid": null, + "enrollnonce": "mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm", + "hostname": "", + "serialnumber": "" + }, + "responseexample": { + "data": { + "assetid": 1, + "credential": "", + "status": "enrolled" + }, + "message": "Endpoint enrolled", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints/enrollment", + "auth": "permission:endpoints.fleet", + "params": "not documented", + "purpose": "Credential state of every computer, plus pending and recent requests.", + "example": "curl http://localhost:5001/api/endpoints/enrollment -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "state", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "computers": [ + { + "assetid": 1, + "credentialprefix": "", + "enrolledvia": "auto", + "hostname": "", + "issuedat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "reenrollblocked": false, + "reenrollblockedreason": null, + "serialnumber": "", + "state": "enrolled" + }, + { + "assetid": 2, + "credentialprefix": "", + "enrolledvia": null, + "hostname": "", + "issuedat": null, + "lastusedat": null, + "reenrollblocked": false, + "reenrollblockedreason": null, + "serialnumber": "", + "state": "notenrolled" + }, + { + "assetid": 3, + "credentialprefix": "", + "enrolledvia": null, + "hostname": "", + "issuedat": null, + "lastusedat": null, + "reenrollblocked": false, + "reenrollblockedreason": null, + "serialnumber": "", + "state": "pending" + } + ], + "counts": { + "enrolled": 1, + "notenrolled": 1, + "pending": 1, + "pendingcomputers": 1 + }, + "requests": [ + { + "agentversion": "1.20.0", + "decidedat": null, + "decidedby": null, + "deviceid": null, + "hostname": "", + "lastseenat": "2026-01-01T00:00:00.000000Z", + "matchedassetid": 3, + "reason": "no serial on record", + "requestedat": "2026-01-01T00:00:00.000000Z", + "requestid": "00000000", + "serialnumber": "", + "sourceip": "127.0.0.1", + "status": "pending" + }, + { + "agentversion": "1.20.0", + "decidedat": null, + "decidedby": null, + "deviceid": null, + "hostname": "", + "lastseenat": "2026-01-01T00:00:00.000000Z", + "matchedassetid": 1, + "reason": null, + "requestedat": "2026-01-01T00:00:00.000000Z", + "requestid": "00000000", + "serialnumber": "", + "sourceip": "127.0.0.1", + "status": "issued" + } + ], + "settings": { + "autoenroll": true, + "maxpendingenrollments": 500, + "revokeddeniessharedkey": false, + "sharedkeyallowed": true + } + }, + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 3, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints/enrollment/assets//allow-reenroll", + "auth": "jwt + role:admin", + "params": "path: assetid (int); body: JSON (fields not yet documented)", + "purpose": "Lift a re-enrollment block. The PC enrolls on its next attempt.", + "example": "curl -X POST http://localhost:5001/api/endpoints/enrollment/assets/1/allow-reenroll -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "assetid": 1, + "credentialid": 1, + "credentialprefix": "", + "enrolledvia": "auto", + "issuedat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "reenrollblocked": false, + "reenrollblockedreason": null, + "revokedat": "2026-01-01T00:00:00.000000Z", + "revokedby": "testadmin" + }, + "message": "Re-enrollment allowed", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints/enrollment/assets//revoke", + "auth": "jwt + role:admin", + "params": "path: assetid (int); body: JSON (fields not yet documented)", + "purpose": "Un-enroll a PC. Its agent gets CREDENTIAL_INVALID and enrolls again.", + "example": "curl -X POST http://localhost:5001/api/endpoints/enrollment/assets/1/revoke -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "blockreenroll": true, + "reason": "rollback" + }, + "responseexample": { + "data": { + "assetid": 1, + "credentialid": 1, + "credentialprefix": "", + "enrolledvia": "auto", + "issuedat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "reenrollblocked": true, + "reenrollblockedreason": "rollback", + "revokedat": "2026-01-01T00:00:00.000000Z", + "revokedby": "testadmin" + }, + "message": "Endpoint credential revoked", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints/enrollment/migration", + "auth": "permission:endpoints.fleet", + "params": "not documented", + "purpose": "Per-computer readiness to move onto per-PC credentials, plus waves.", + "example": "curl http://localhost:5001/api/endpoints/enrollment/migration -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "state", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "computers": [ + { + "agentversion": "1.19.0", + "assetid": 2, + "blockers": [], + "hostname": "", + "lastreportedat": "2026-01-01T00:00:00.000000Z", + "state": "ready" + }, + { + "agentversion": "1.19.0", + "assetid": 3, + "blockers": [], + "hostname": "", + "lastreportedat": "2026-01-01T00:00:00.000000Z", + "state": "ready" + }, + { + "agentversion": "1.20.0", + "assetid": 1, + "blockers": [ + "awaiting enrollment" + ], + "hostname": "", + "lastreportedat": "2026-01-01T00:00:00.000000Z", + "state": "blocked" + } + ], + "counts": { + "blocked": 1, + "enrolled": 0, + "notreporting": 0, + "pending": 0, + "ready": 2, + "upgrading": 0 + }, + "target": "1.20.0", + "waves": [ + { + "createdat": "2026-01-01T00:00:00Z", + "createdby": "", + "done": false, + "enrolled": 0, + "failed": 1, + "queued": 3, + "size": 3, + "waveid": 1 + }, + { + "createdat": "2026-01-01T00:00:00Z", + "createdby": "", + "done": false, + "enrolled": 0, + "failed": 0, + "queued": 2, + "size": 2, + "waveid": 2 + } + ] + }, + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 3, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints/enrollment/migration/wave", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Push the agent upgrade to a batch of READY PCs.", + "example": "curl -X POST http://localhost:5001/api/endpoints/enrollment/migration/wave -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "action": "run_script", + "target": "script:1" + }, + "responseexample": { + "data": { + "queued": [ + 1 + ], + "retried": [], + "skipped": [ + { + "assetid": 2, + "reason": "offline" + }, + { + "assetid": 3, + "reason": "notreporting" + }, + { + "assetid": 999999, + "reason": "not a computer" + } + ], + "waveid": 1 + }, + "message": "1 PC(s) queued", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/endpoints/enrollment/requests//approve", + "auth": "jwt + role:admin", + "params": "path: requestid (int); body: JSON (fields not yet documented)", + "purpose": "Bind a pending request to the asset the admin picked.", + "example": "curl -X POST http://localhost:5001/api/endpoints/enrollment/requests/1/approve -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "assetid": 1, + "sethostname": "" + }, + "responseexample": { + "data": { + "agentversion": "1.20.0", + "decidedat": "2026-01-01T00:00:00.000000Z", + "decidedby": "testadmin", + "deviceid": null, + "hostname": "", + "hostnameresult": "", + "lastseenat": "2026-01-01T00:00:00.000000Z", + "matchedassetid": 1, + "reason": null, + "requestedat": "2026-01-01T00:00:00.000000Z", + "requestid": "00000000", + "serialnumber": "", + "sourceip": "127.0.0.1", + "status": "approved" + }, + "message": "Enroll request approved; that computer was renamed to the hostname the PC reports", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/endpoints/enrollment/requests//reject", + "auth": "jwt + role:admin", + "params": "path: requestid (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: endpoints.reject_enroll_request has no docstring.", + "example": "curl -X POST http://localhost:5001/api/endpoints/enrollment/requests/1/reject -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "agentversion": "1.20.0", + "decidedat": "2026-01-01T00:00:00.000000Z", + "decidedby": "testadmin", + "deviceid": null, + "hostname": "", + "lastseenat": "2026-01-01T00:00:00.000000Z", + "matchedassetid": 1, + "reason": "awaiting approval", + "requestedat": "2026-01-01T00:00:00.000000Z", + "requestid": "00000000", + "serialnumber": "", + "sourceip": "127.0.0.1", + "status": "rejected" + }, + "message": "Enroll request rejected", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/endpoints/enrollment/settings", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Change the enrollment settings.", + "example": "curl -X PUT http://localhost:5001/api/endpoints/enrollment/settings -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "revokeddeniessharedkey": true + }, + "responseexample": { + "data": { + "autoenroll": true, + "maxpendingenrollments": 500, + "revokeddeniessharedkey": false, + "sharedkeyallowed": false + }, + "message": "Enrollment settings saved", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints/logcollections/", + "auth": "permission:endpoints.updateapp", + "params": "path: collection_id (int)", + "purpose": "One collected log including its content, for the scrollable pane.", + "example": "curl http://localhost:5001/api/endpoints/logcollections/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/logcollections//download", + "auth": "permission:endpoints.updateapp", + "params": "path: collection_id (int)", + "purpose": "Serve a collected log as a file attachment.", + "example": "curl http://localhost:5001/api/endpoints/logcollections/1/download -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/monitors", + "auth": "permission:endpoints.view", + "params": "not documented", + "purpose": "List monitor definitions (shipped and local).", + "example": "curl http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/endpoints/monitors", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a user-defined (local) monitor definition.", + "example": "curl -X POST http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/endpoints/monitors/", + "auth": "jwt + role:admin", + "params": "path: monitor_id (int)", + "purpose": "Delete a local monitor definition. Shipped rows are protected.", + "example": "curl -X DELETE http://localhost:5001/api/endpoints/monitors/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/endpoints/monitors/", + "auth": "jwt + role:admin", + "params": "path: monitor_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a local monitor definition. Shipped rows are read-only.", + "example": "curl -X PUT http://localhost:5001/api/endpoints/monitors/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "launchpath": "D:\\Dnc\\bin\\DncMain.exe" + }, + "responseexample": { + "data": { + "createdat": "2026-01-01T00:00:00.000000Z", + "datacollectorpath": null, + "displayname": "", + "endpointmonitorid": 1, + "isactive": false, + "kind": "process", + "launchpath": "C:\\Program Files\\Dnc\\bin\\DncMain.exe", + "logcollectorpath": null, + "processname": "UDC", + "servicename": null, + "source": "shipped" + }, + "message": "Monitor updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints/monitors/resolve", + "auth": "permission:endpoints.view", + "params": "not documented", + "purpose": "Resolve the monitor set for a PC type (agent runtime fetch).", + "example": "curl http://localhost:5001/api/endpoints/monitors/resolve -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "pctype", + "type": "string", + "required": false + } + ] + }, + { + "method": "GET", + "path": "/api/endpoints/monitors/resolved", + "auth": "api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)", + "params": "not documented", + "purpose": "Agent watch list: the monitors this endpoint should report on.", + "example": "curl http://localhost:5001/api/endpoints/monitors/resolved -H \"X-API-Key: $KEY\"", + "queryparams": [ + { + "name": "hostname", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "processes": [ + { + "app": "Edge", + "launchpath": "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + "name": "msedge.exe" + } + ], + "services": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints/operatorreports", + "auth": "api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)", + "params": "body: JSON (fields not yet documented)", + "purpose": "Agent uploads one operator issue report (collector-token auth).", + "example": "curl -X POST http://localhost:5001/api/endpoints/operatorreports -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "assetid": 1, + "createdat": "2026-01-01T00:00:00.000000Z", + "details": "the CMM app will not start", + "hasimage": true, + "imagebytesize": 70, + "imagemimetype": "image/png", + "operatorreportid": 1, + "reason": "Other (free text)", + "reportedhostname": "" + }, + "message": "Operator report received", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints/operatorreports//image", + "auth": "permission:endpoints.operatorreports", + "params": "path: operatorreportid (int)", + "purpose": "Serve one operator report's screenshot.", + "example": "curl http://localhost:5001/api/endpoints/operatorreports/1/image -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/endpoints/panel//operatorreports", + "auth": "permission:endpoints.operatorreports", + "params": "path: asset_id (int)", + "purpose": "Operator Reports panel: this PC's recent operator issue reports.", + "example": "curl http://localhost:5001/api/endpoints/panel/1/operatorreports -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "reports": [ + { + "assetid": 1, + "createdat": "2026-01-01T00:00:00.000000Z", + "details": "second", + "hasimage": false, + "imagebytesize": null, + "imagemimetype": null, + "operatorreportid": 2, + "reason": "Other (free text)", + "reportedhostname": "" + }, + { + "assetid": 1, + "createdat": "2026-01-01T00:00:00.000000Z", + "details": "first", + "hasimage": false, + "imagebytesize": null, + "imagemimetype": null, + "operatorreportid": 1, + "reason": "Other (free text)", + "reportedhostname": "" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/scriptbody/", + "auth": "api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)", + "params": "path: script_id (int)", + "purpose": "Agent-facing script body download (collector-token auth).", + "example": "curl http://localhost:5001/api/endpoints/scriptbody/1 -H \"X-API-Key: $KEY\"" + }, + { + "method": "GET", + "path": "/api/endpoints/settings/server-cert", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "Get details about the current server certificate.", + "example": "curl http://localhost:5001/api/endpoints/settings/server-cert -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "expires_in_days": 29, + "is_self_signed": true, + "issuer": "CN = pin.example.test", + "not_after": "Oct 18 18:57:47 2026 GMT", + "not_before": "Sep 17 18:57:47 2026 GMT", + "san_list": [], + "sha1thumbprint": "", + "spkisha256": "", + "subject_cn": "pin.example.test", + "thumbprint": "99:F1:E1:1D:5A:66:EE:44:8C:CA:5C:D8:BF:EA:C3:F3:8F:82:40:D3:5E:C9:4D:F0:AC:0B:3C:AD:80:D7:C1:DF" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/settings/server-cert/download", + "auth": "permission:endpoints.fleet", + "params": "not documented", + "purpose": "The PUBLIC server certificate as a DER .cer file, never the key.", + "example": "curl http://localhost:5001/api/endpoints/settings/server-cert/download -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/endpoints/settings/server-cert/generate", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Generate a new self-signed server certificate.", + "example": "curl -X POST http://localhost:5001/api/endpoints/settings/server-cert/generate -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "cn": "shopdb.example.test", + "days": 30, + "san": "" + }, + "responseexample": { + "data": { + "message": "Certificate generated successfully. Nginx is restarting." + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints/settings/server-cert/live", + "auth": "permission:endpoints.fleet", + "params": "not documented", + "purpose": "The certificate PCs actually receive from this site's public address.", + "example": "curl http://localhost:5001/api/endpoints/settings/server-cert/live -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "configured": true, + "error": "Could not read the certificate from shopdb.example.test:443 (Connection refused)", + "matchesfile": false, + "presentedsha1thumbprint": null, + "presentedspkisha256": null, + "url": "https://shopdb.example.test" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints/settings/server-cert/upload", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Upload a new server certificate and private key.", + "example": "curl -X POST http://localhost:5001/api/endpoints/settings/server-cert/upload -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "message": "Certificate uploaded successfully. Nginx is restarting." + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints/siteagent", + "auth": "permission:endpoints.agentpackage", + "params": "not documented", + "purpose": "Report whether the site agent build has a prebuilt binary staged.", + "example": "curl http://localhost:5001/api/endpoints/siteagent -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "agentcurrent": null, + "agentlegacy": null, + "agentstaged": false, + "agentversions": [], + "certavailable": true, + "serverpin": "", + "sharedkeyallowed": false, + "traycurrent": null, + "traylegacy": null, + "traystaged": false, + "trayversions": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/endpoints/siteagent/binaries/", + "auth": "permission:endpoints.agentpackage", + "params": "path: binkey (string)", + "purpose": "List staged versions for a binary (agent or tray), newest first.", + "example": "curl http://localhost:5001/api/endpoints/siteagent/binaries/x -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/endpoints/siteagent/binaries/", + "auth": "permission:endpoints.agentpackage", + "params": "path: binkey (string); body: JSON (fields not yet documented)", + "purpose": "Stage an uploaded binary as a new version.", + "example": "curl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/x -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "binary": "agent", + "current": "", + "registration": { + "agentversion": "", + "appid": 1, + "appname": "ShopDB Endpoint Agent", + "contents": { + "agent": { + "filename": "shopdbagent.exe", + "sha256": "", + "version": "" + } + }, + "created": false, + "installpath": "/api/applications/package/1/shopdb-endpoint-agent.zip", + "ok": true, + "sha256": "", + "size": 135 + }, + "versions": [ + { + "iscurrent": true, + "size": 130, + "uploadedat": "2026-01-01T00:00:00.000000Z", + "version": "" + }, + { + "iscurrent": false, + "size": 130, + "uploadedat": "2026-01-01T00:00:00.000000Z", + "version": "" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/endpoints/siteagent/binaries//adopt", + "auth": "permission:endpoints.agentpackage", + "params": "path: binkey (string); body: JSON (fields not yet documented)", + "purpose": "Adopt the unmanaged legacy flat file into the version store.", + "example": "curl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/x/adopt -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "PUT", + "path": "/api/endpoints/siteagent/binaries//current", + "auth": "permission:endpoints.agentpackage", + "params": "path: binkey (string); body: JSON (fields not yet documented)", + "purpose": "Point 'current' at an already-staged version (roll back or forward).", + "example": "curl -X PUT http://localhost:5001/api/endpoints/siteagent/binaries/x/current -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "version": "" + }, + "responseexample": { + "data": { + "binary": "agent", + "current": "", + "registration": { + "agentversion": "", + "appid": 1, + "appname": "ShopDB Endpoint Agent", + "contents": { + "agent": { + "filename": "shopdbagent.exe", + "sha256": "", + "version": "" + } + }, + "created": false, + "installpath": "/api/applications/package/1/shopdb-endpoint-agent.zip", + "ok": true, + "sha256": "", + "size": 135 + }, + "versions": [ + { + "iscurrent": false, + "size": 258, + "uploadedat": "2026-01-01T00:00:00.000000Z", + "version": "" + }, + { + "iscurrent": true, + "size": 258, + "uploadedat": "2026-01-01T00:00:00.000000Z", + "version": "" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/endpoints/siteagent/binaries//detect", + "auth": "permission:endpoints.agentpackage", + "params": "path: binkey (string); body: JSON (fields not yet documented)", + "purpose": "Read the PE file version of an uploaded EXE without staging it.", + "example": "curl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/x/detect -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/endpoints/siteagent/bootstrap.ps1", + "auth": "api-key (single-use bootstrap token in the X-Endpoint-Bootstrap-Token header, or ?t= while that is enabled; per-IP rate limited, 404 when not usable)", + "params": "not documented", + "purpose": "The PowerShell the one-line command downloads, runs and deletes.", + "example": "curl http://localhost:5001/api/endpoints/siteagent/bootstrap.ps1" + }, + { + "method": "GET", + "path": "/api/endpoints/siteagent/bootstrap/package", + "auth": "api-key (single-use bootstrap token in the X-Endpoint-Bootstrap-Token header, or ?t= while that is enabled; per-IP rate limited, 404 when not usable)", + "params": "not documented", + "purpose": "The keyless agent package the bootstrap script installs.", + "example": "curl http://localhost:5001/api/endpoints/siteagent/bootstrap/package" + }, + { + "method": "GET", + "path": "/api/endpoints/siteagent/bootstrap/tokens", + "auth": "jwt + role:admin", + "params": "not documented", + "purpose": "Every bootstrap token, newest first. Never a secret.", + "example": "curl http://localhost:5001/api/endpoints/siteagent/bootstrap/tokens -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "tokens": [ + { + "createdat": "2026-01-01T00:00:00.000000Z", + "createdby": "", + "expiresat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "lastusedip": null, + "maxuses": 25, + "remaining": 25, + "revokedat": null, + "state": "active", + "tokenid": 1, + "tokenprefix": "", + "usecount": 0 + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints/siteagent/bootstrap/tokens", + "auth": "jwt + role:admin", + "params": "body: JSON (fields not yet documented)", + "purpose": "Mint one short-lived bootstrap token. The secret is returned ONCE.", + "example": "curl -X POST http://localhost:5001/api/endpoints/siteagent/bootstrap/tokens -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "hours": 6, + "maxuses": 3 + }, + "responseexample": { + "data": { + "command": "powershell -ExecutionPolicy Bypass -c \"try { New-Item -ItemType Directory -Force -Path 'C:\\Program Files\\ShopDB\\bootstrap' | Out-Null; icacls 'C:\\Program Files\\ShopDB\\bootstrap' /inheritance:r /grant ...", + "scripturl": "https://shopdb.test/shopdb/api/endpoints/siteagent/bootstrap.ps1?t=&via=download", + "secret": "", + "token": { + "createdat": "2026-01-01T00:00:00.000000Z", + "createdby": "", + "expiresat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "lastusedip": null, + "maxuses": 500, + "remaining": 500, + "revokedat": null, + "state": "active", + "tokenid": 1, + "tokenprefix": "", + "usecount": 0 + } + }, + "message": "Bootstrap token created. The command is shown once.", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/endpoints/siteagent/bootstrap/tokens//revoke", + "auth": "jwt + role:admin", + "params": "path: tokenid (int); body: JSON (fields not yet documented)", + "purpose": "Kill a bootstrap token now. Already-revoked is a no-op, not an error.", + "example": "curl -X POST http://localhost:5001/api/endpoints/siteagent/bootstrap/tokens/1/revoke -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "token": { + "createdat": "2026-01-01T00:00:00.000000Z", + "createdby": "", + "expiresat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "lastusedip": null, + "maxuses": 25, + "remaining": 25, + "revokedat": "2026-01-01T00:00:00.000000Z", + "state": "revoked", + "tokenid": 1, + "tokenprefix": "", + "usecount": 0 + } + }, + "message": "Bootstrap token revoked", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/endpoints/siteagent/package", + "auth": "permission:endpoints.agentpackage", + "params": "body: JSON (fields not yet documented)", + "purpose": "Bundle the site agent as a downloadable ZIP.", + "example": "curl -X POST http://localhost:5001/api/endpoints/siteagent/package -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "baseurl": "https://shopdb.test", + "bundlecert": true, + "cadenceseconds": 900, + "collectorkey": "", + "nextserverpin": "" + }, + "readsbody": true + } + ] + }, + { + "surface": "plugin-guides", + "endpoints": [ + { + "method": "GET", + "path": "/api/guides", + "auth": "permission:guides.view", + "params": "not documented", + "purpose": "Published guides, plus the caller's own drafts if they can author.", + "example": "curl http://localhost:5001/api/guides -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "q", + "type": "string", + "required": false + }, + { + "name": "tag", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [ + { + "canedit": false, + "checklistitems": 0, + "createdat": "2026-01-01T00:00:00.000000Z", + "guideid": 1, + "ispublished": true, + "publishedat": "2026-01-01T00:00:00.000000Z", + "revision": 2, + "slug": "published-procedure", + "status": "published", + "summary": null, + "tags": [], + "title": "Published procedure", + "updatedat": "2026-01-01T00:00:00.000000Z" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/guides", + "auth": "permission:guides.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: guides.create_guide has no docstring.", + "example": "curl -X POST http://localhost:5001/api/guides -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "blocks": [], + "title": "Replacing a marker controler" + }, + "responseexample": { + "data": { + "blocks": [], + "createdat": "2026-01-01T00:00:00.000000Z", + "guideid": 1, + "publishedat": null, + "revision": 1, + "slug": "replacing-a-marker-controler", + "status": "draft", + "summary": null, + "tags": [], + "title": "Replacing a marker controler", + "updatedat": "2026-01-01T00:00:00.000000Z" + }, + "message": "Guide created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/guides/", + "auth": "permission:guides.delete", + "params": "path: guideid (int)", + "purpose": "UNDOCUMENTED: guides.delete_guide has no docstring.", + "example": "curl -X DELETE http://localhost:5001/api/guides/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/guides/", + "auth": "permission:guides.view", + "params": "path: guideid (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: guides.update_guide has no docstring.", + "example": "curl -X PUT http://localhost:5001/api/guides/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "blocks": [], + "title": "Replacing a marker controller" + }, + "responseexample": { + "data": { + "blocks": [], + "createdat": "2026-01-01T00:00:00.000000Z", + "guideid": 1, + "publishedat": null, + "revision": 2, + "saved": true, + "slug": "replacing-a-marker-controler", + "status": "draft", + "summary": null, + "tags": [], + "title": "Replacing a marker controller", + "updatedat": "2026-01-01T00:00:00.000000Z" + }, + "message": "Saved", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/guides//publish", + "auth": "permission:guides.publish", + "params": "path: guideid (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: guides.publish_guide has no docstring.", + "example": "curl -X POST http://localhost:5001/api/guides/1/publish -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + }, + { + "method": "POST", + "path": "/api/guides//restore/", + "auth": "permission:guides.view", + "params": "path: guideid (int), revisionid (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: guides.restore_revision has no docstring.", + "example": "curl -X POST http://localhost:5001/api/guides/1/restore/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + }, + { + "method": "GET", + "path": "/api/guides//revisions", + "auth": "permission:guides.view", + "params": "path: guideid (int)", + "purpose": "UNDOCUMENTED: guides.list_revisions has no docstring.", + "example": "curl http://localhost:5001/api/guides/1/revisions -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/guides//unpublish", + "auth": "permission:guides.publish", + "params": "path: guideid (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: guides.unpublish_guide has no docstring.", + "example": "curl -X POST http://localhost:5001/api/guides/1/unpublish -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + }, + { + "method": "GET", + "path": "/api/guides//working", + "auth": "permission:guides.view", + "params": "path: guideid (int)", + "purpose": "The working copy, for the editor.", + "example": "curl http://localhost:5001/api/guides/1/working -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/guides/", + "auth": "permission:guides.view", + "params": "path: slug (string)", + "purpose": "What a reader gets: the published revision.", + "example": "curl http://localhost:5001/api/guides/x -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/guides/images", + "auth": "permission:guides.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Take a pasted or dropped image.", + "example": "curl -X POST http://localhost:5001/api/guides/images -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "bytesize": 40, + "createdat": "2026-01-01T00:00:00.000000Z", + "filename": "screenshot.png", + "guideid": null, + "guideimageid": 1, + "mimetype": "image/png", + "url": "/api/guides/images/1" + }, + "message": "Image uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/guides/images/", + "auth": "permission:guides.view", + "params": "path: imageid (int)", + "purpose": "The image itself, under the same rule as the guide that holds it.", + "example": "curl http://localhost:5001/api/guides/images/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/guides/revisions/", + "auth": "permission:guides.view", + "params": "path: revisionid (int)", + "purpose": "One revision by id.", + "example": "curl http://localhost:5001/api/guides/revisions/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "blocks": [ + { + "id": "b1", + "text": "secret draft wording", + "type": "paragraph" + } + ], + "createdat": "2026-01-01T00:00:00.000000Z", + "createdby": "", + "guideid": 1, + "guiderevisionid": 1, + "note": null, + "revision": 1, + "shorthash": "", + "summary": null, + "tags": [], + "title": "Published procedure" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/guides/tags", + "auth": "permission:guides.view", + "params": "not documented", + "purpose": "Every tag in use, with a count, for the filter chips.", + "example": "curl http://localhost:5001/api/guides/tags -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "plugin-knowledgebase", + "endpoints": [ + { + "method": "GET", + "path": "/api/knowledgebase", + "auth": "permission:kb.view", + "params": "not documented", + "purpose": "List all knowledge base articles.", + "example": "curl http://localhost:5001/api/knowledgebase -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "appid", + "type": "integer", + "required": false + }, + { + "name": "linkurl", + "type": "string", + "required": false + }, + { + "name": "order", + "type": "string", + "required": false, + "default": "desc" + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "shortdescription", + "type": "string", + "required": false + }, + { + "name": "sort", + "type": "string", + "required": false, + "default": "clicks" + } + ], + "responseexample": { + "data": [ + { + "appid": 1, + "application": { + "appid": 1, + "appname": "Spotfire" + }, + "clicks": 0, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "keywords": "install", + "lastupdated": "2026-01-01T00:00:00.000000Z", + "linkid": 3, + "linkurl": "https://kb.example/c", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "shortdescription": "Spotfire install guide" + }, + { + "appid": 1, + "application": { + "appid": 1, + "appname": "Spotfire" + }, + "clicks": 0, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "keywords": "export", + "lastupdated": "2026-01-01T00:00:00.000000Z", + "linkid": 2, + "linkurl": "https://kb.example/b", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "shortdescription": "Export a visualization" + }, + { + "appid": 1, + "application": { + "appid": 1, + "appname": "Spotfire" + }, + "clicks": 0, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "keywords": "login", + "lastupdated": "2026-01-01T00:00:00.000000Z", + "linkid": 1, + "linkurl": "https://kb.example/a", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "shortdescription": "Dashboard login steps" + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 3, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/knowledgebase", + "auth": "permission:kb.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new knowledge base article.", + "example": "curl -X POST http://localhost:5001/api/knowledgebase -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "keywords": "printer reset", + "linkurl": "https://kb.example/printer-reset", + "shortdescription": "How to reset a printer" + }, + "responseexample": { + "data": { + "appid": null, + "clicks": 0, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "keywords": "printer reset", + "lastupdated": "2026-01-01T00:00:00.000000Z", + "linkid": 1, + "linkurl": "https://kb.example/printer-reset", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "shortdescription": "How to reset a printer" + }, + "message": "Article created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/knowledgebase/", + "auth": "permission:kb.delete", + "params": "path: link_id (int)", + "purpose": "Delete (deactivate) a knowledge base article.", + "example": "curl -X DELETE http://localhost:5001/api/knowledgebase/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/knowledgebase/", + "auth": "permission:kb.view", + "params": "path: link_id (int)", + "purpose": "Get a single knowledge base article.", + "example": "curl http://localhost:5001/api/knowledgebase/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "appid": null, + "application": null, + "clicks": 0, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "keywords": "printer reset", + "lastupdated": "2026-01-01T00:00:00.000000Z", + "linkid": 1, + "linkurl": "https://kb.example/printer-reset", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "shortdescription": "How to reset a printer" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/knowledgebase/", + "auth": "permission:kb.edit", + "params": "path: link_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a knowledge base article.", + "example": "curl -X PUT http://localhost:5001/api/knowledgebase/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "POST", + "path": "/api/knowledgebase//click", + "auth": "permission:kb.view", + "params": "path: link_id (int); body: JSON (fields not yet documented)", + "purpose": "Increment the click counter for an article the caller is allowed to read.", + "example": "curl -X POST http://localhost:5001/api/knowledgebase/1/click -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + }, + { + "method": "GET", + "path": "/api/knowledgebase/stats", + "auth": "permission:kb.view", + "params": "not documented", + "purpose": "Get knowledge base statistics.", + "example": "curl http://localhost:5001/api/knowledgebase/stats -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "totalarticles": 0, + "totalclicks": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "plugin-machines", + "endpoints": [ + { + "method": "GET", + "path": "/api/machines", + "auth": "permission:machines.view", + "params": "not documented", + "purpose": "List all machines with filtering and pagination.", + "example": "curl http://localhost:5001/api/machines -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "assetnumber", + "type": "string", + "required": false + }, + { + "name": "businessunit_id", + "type": "integer", + "required": false + }, + { + "name": "businessunitid", + "type": "integer", + "required": false + }, + { + "name": "dir", + "type": "string", + "required": false, + "default": "asc" + }, + { + "name": "location_id", + "type": "integer", + "required": false + }, + { + "name": "locationid", + "type": "integer", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "sort", + "type": "string", + "required": false, + "default": "assetnumber" + }, + { + "name": "type_id", + "type": "integer", + "required": false + }, + { + "name": "typeid", + "type": "integer", + "required": false + }, + { + "name": "vendor_id", + "type": "integer", + "required": false + }, + { + "name": "vendorid", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [ + { + "assetid": 1, + "assetnumber": "2007", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "machine", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "dualpathpartner": { + "assetid": 2, + "assetnumber": "2008", + "machineid": 2 + }, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "machine": { + "assetid": 1, + "controllermodelid": null, + "controllervendorid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "islocationonly": false, + "lastmaintenancedate": null, + "machineid": 1, + "machinetypeid": null, + "maintenanceintervaldays": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "nextmaintenancedate": null, + "requiresmanualconfig": false, + "vendorid": null + }, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": 100, + "mapy": 100, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": 100, + "ownmapy": 100, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/machines", + "auth": "permission:machines.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create new machine (creates both Asset and Machine records).", + "example": "curl -X POST http://localhost:5001/api/machines -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "assetnumber": "MAINT-DATE-1", + "lastmaintenancedate": "2026-01-01", + "name": "Maintenance date probe", + "nextmaintenancedate": "2026-01-01" + }, + "responseexample": { + "data": { + "assetid": 1, + "assetnumber": "MAINT-DATE-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "machine", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "machine": { + "assetid": 1, + "controllermodelid": null, + "controllervendorid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "islocationonly": false, + "lastmaintenancedate": "2026-01-01", + "machineid": 1, + "machinetypeid": null, + "maintenanceintervaldays": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "nextmaintenancedate": "2026-01-01", + "requiresmanualconfig": false, + "vendorid": null + }, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Maintenance date probe", + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "message": "Machine created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/machines/", + "auth": "permission:machines.delete", + "params": "path: machine_id (int)", + "purpose": "Delete (soft delete) machine.", + "example": "curl -X DELETE http://localhost:5001/api/machines/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/machines/", + "auth": "permission:machines.view", + "params": "path: machine_id (int)", + "purpose": "Get a single machine item with full details.", + "example": "curl http://localhost:5001/api/machines/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "assetid": 1, + "assetnumber": "2007", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "machine", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "dualpathpartner": { + "assetid": 2, + "assetnumber": "2008", + "machineid": 2 + }, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "machine": { + "assetid": 1, + "controllermodelid": null, + "controllervendorid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "islocationonly": false, + "lastmaintenancedate": null, + "machineid": 1, + "machinetypeid": null, + "maintenanceintervaldays": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "nextmaintenancedate": null, + "requiresmanualconfig": false, + "vendorid": null + }, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": 100, + "mapy": 100, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": 100, + "ownmapy": 100, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/machines/", + "auth": "permission:machines.edit", + "params": "path: machine_id (int); body: JSON (fields not yet documented)", + "purpose": "Update machine (both Asset and Machine records).", + "example": "curl -X PUT http://localhost:5001/api/machines/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/machines/by-asset/", + "auth": "permission:machines.view", + "params": "path: asset_id (int)", + "purpose": "Get machine data by asset ID.", + "example": "curl http://localhost:5001/api/machines/by-asset/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/machines/dashboard/outofservice", + "auth": "permission:machines.view", + "params": "not documented", + "purpose": "Machines whose status says they are not running.", + "example": "curl http://localhost:5001/api/machines/dashboard/outofservice -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/machines/dashboard/summary", + "auth": "permission:machines.view", + "params": "not documented", + "purpose": "Get machine dashboard summary data.", + "example": "curl http://localhost:5001/api/machines/dashboard/summary -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "bystatus": [ + { + "count": 1, + "status": "In Use" + } + ], + "bytype": [], + "total": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/machines/types", + "auth": "permission:machines.view", + "params": "not documented", + "purpose": "List all machine types.", + "example": "curl http://localhost:5001/api/machines/types -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/machines/types", + "auth": "permission:machines.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new machine type.", + "example": "curl -X POST http://localhost:5001/api/machines/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/machines/types/", + "auth": "permission:machines.delete", + "params": "path: type_id (int)", + "purpose": "Delete a machine type. Refused if any asset still uses it.", + "example": "curl -X DELETE http://localhost:5001/api/machines/types/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/machines/types/", + "auth": "permission:machines.view", + "params": "path: type_id (int)", + "purpose": "Get a single machine type.", + "example": "curl http://localhost:5001/api/machines/types/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/machines/types/", + "auth": "permission:machines.edit", + "params": "path: type_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a machine type.", + "example": "curl -X PUT http://localhost:5001/api/machines/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "plugin-network", + "endpoints": [ + { + "method": "GET", + "path": "/api/network", + "auth": "permission:network.view", + "params": "not documented", + "purpose": "List all network devices with filtering and pagination.", + "example": "curl http://localhost:5001/api/network -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "assetnumber", + "type": "string", + "required": false + }, + { + "name": "businessunit_id", + "type": "integer", + "required": false + }, + { + "name": "businessunitid", + "type": "integer", + "required": false + }, + { + "name": "dir", + "type": "string", + "required": false, + "default": "asc" + }, + { + "name": "location_id", + "type": "integer", + "required": false + }, + { + "name": "locationid", + "type": "integer", + "required": false + }, + { + "name": "managed", + "type": "string", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "poe", + "type": "string", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "sort", + "type": "string", + "required": false, + "default": "hostname" + }, + { + "name": "type_id", + "type": "integer", + "required": false + }, + { + "name": "typeid", + "type": "integer", + "required": false + }, + { + "name": "vendor_id", + "type": "integer", + "required": false + }, + { + "name": "vendorid", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [ + { + "assetid": 1, + "assetnumber": "NET-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "network_device", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "ipaddress": "", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "networkdevice": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "firmwareversion": null, + "hostname": "", + "isactive": true, + "ismanaged": false, + "ispoe": false, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdeviceid": 1, + "networkdevicetypeid": 1, + "networkdevicetypename": "Switch", + "portcount": null, + "rackunit": null, + "vendorid": 1, + "vendorname": "Cisco" + }, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/network", + "auth": "permission:network.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create new network device (creates both Asset and NetworkDevice records).", + "example": "curl -X POST http://localhost:5001/api/network -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "assetnumber": "SW-CORE-01", + "hostname": "", + "ipaddress": "", + "name": "Core switch" + }, + "responseexample": { + "data": { + "assetid": 1, + "assetnumber": "APNL-Dock-Door-4", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "network_device", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "ipaddress": "", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Dock Door 4", + "networkdevice": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "firmwareversion": null, + "hostname": "", + "isactive": true, + "ismanaged": false, + "ispoe": false, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdeviceid": 1, + "networkdevicetypeid": 1, + "networkdevicetypename": "Access Panel", + "portcount": null, + "rackunit": null, + "vendorid": null + }, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "message": "Network device created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/network/", + "auth": "permission:network.delete", + "params": "path: device_id (int)", + "purpose": "Delete (soft delete) network device.", + "example": "curl -X DELETE http://localhost:5001/api/network/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/network/", + "auth": "permission:network.view", + "params": "path: device_id (int)", + "purpose": "Get a single network device with full details.", + "example": "curl http://localhost:5001/api/network/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "assetid": 1, + "assetnumber": "AST-networkdevice", + "assettypecolor": null, + "assettypeid": 3, + "assettypename": "network_device", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": "GL-2", + "ipaddress": "", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": "MNT-2", + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "networkdevice": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "firmwareversion": null, + "hostname": "", + "isactive": true, + "ismanaged": false, + "ispoe": false, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdeviceid": 1, + "networkdevicetypeid": null, + "portcount": null, + "rackunit": null, + "vendorid": null + }, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/network/", + "auth": "permission:network.edit", + "params": "path: device_id (int); body: JSON (fields not yet documented)", + "purpose": "Update network device (both Asset and NetworkDevice records).", + "example": "curl -X PUT http://localhost:5001/api/network/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "gaugelabreference": "GL-2", + "maintenancereference": "MNT-2" + }, + "responseexample": { + "data": { + "assetid": 1, + "assetnumber": "AST-networkdevice", + "assettypecolor": null, + "assettypeid": 3, + "assettypename": "network_device", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": "GL-2", + "ipaddress": "", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": "MNT-2", + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "networkdevice": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "firmwareversion": null, + "hostname": "", + "isactive": true, + "ismanaged": false, + "ispoe": false, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdeviceid": 1, + "networkdevicetypeid": null, + "portcount": null, + "rackunit": null, + "vendorid": null + }, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "message": "Network device updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/network/by-asset/", + "auth": "permission:network.view", + "params": "path: asset_id (int)", + "purpose": "Get network device data by asset ID.", + "example": "curl http://localhost:5001/api/network/by-asset/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/network/by-hostname/", + "auth": "permission:network.view", + "params": "path: hostname (string)", + "purpose": "Get network device by hostname.", + "example": "curl http://localhost:5001/api/network/by-hostname/x -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/network/dashboard/summary", + "auth": "permission:network.view", + "params": "not documented", + "purpose": "Get network device dashboard summary data.", + "example": "curl http://localhost:5001/api/network/dashboard/summary -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "bytype": [], + "byvendor": [], + "nonpoe": 0, + "poe": 0, + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/network/subnets", + "auth": "permission:network.view", + "params": "not documented", + "purpose": "List all subnets with filtering and pagination.", + "example": "curl http://localhost:5001/api/network/subnets -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "locationid", + "type": "integer", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "type", + "type": "string", + "required": false + }, + { + "name": "vlanid", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/network/subnets", + "auth": "permission:network.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new subnet.", + "example": "curl -X POST http://localhost:5001/api/network/subnets -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "cidr": "", + "name": "Attached", + "vlanid": 1 + }, + "responseexample": { + "data": { + "broadcastaddress": null, + "cidr": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "dhcpenabled": true, + "dhcprangeend": null, + "dhcprangestart": null, + "dns1": null, + "dns2": null, + "gatewayip": null, + "isactive": true, + "locationid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Attached", + "networkaddress": null, + "subnetid": 1, + "subnetmask": null, + "subnettype": null, + "vlanid": 1, + "vlanname": "In use", + "vlannumber": 77 + }, + "message": "Subnet created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/network/subnets/", + "auth": "permission:network.delete", + "params": "path: subnet_id (int)", + "purpose": "Delete (soft delete) a subnet.", + "example": "curl -X DELETE http://localhost:5001/api/network/subnets/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/network/subnets/", + "auth": "permission:network.view", + "params": "path: subnet_id (int)", + "purpose": "Get a single subnet plus the network devices whose primary IP falls in its CIDR range.", + "example": "curl http://localhost:5001/api/network/subnets/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/network/subnets/", + "auth": "permission:network.edit", + "params": "path: subnet_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a subnet.", + "example": "curl -X PUT http://localhost:5001/api/network/subnets/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/network/types", + "auth": "permission:network.view", + "params": "not documented", + "purpose": "List all network device types.", + "example": "curl http://localhost:5001/api/network/types -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/network/types", + "auth": "permission:network.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new network device type.", + "example": "curl -X POST http://localhost:5001/api/network/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "networkdevicetype": "Door Controller", + "prefix": "DOOR" + }, + "responseexample": { + "data": { + "color": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "icon": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdevicetype": "Door Controller", + "networkdevicetypeid": 1, + "prefix": "DOOR" + }, + "message": "Network device type created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/network/types/", + "auth": "permission:network.delete", + "params": "path: type_id (int)", + "purpose": "Delete a network device type. Refused if any device still uses it.", + "example": "curl -X DELETE http://localhost:5001/api/network/types/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/network/types/", + "auth": "permission:network.view", + "params": "path: type_id (int)", + "purpose": "Get a single network device type.", + "example": "curl http://localhost:5001/api/network/types/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/network/types/", + "auth": "permission:network.edit", + "params": "path: type_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a network device type.", + "example": "curl -X PUT http://localhost:5001/api/network/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "prefix": "DR" + }, + "responseexample": { + "data": { + "color": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "icon": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdevicetype": "Door Controller", + "networkdevicetypeid": 1, + "prefix": "DR" + }, + "message": "Network device type updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/network/vlans", + "auth": "permission:network.view", + "params": "not documented", + "purpose": "List all VLANs with filtering and pagination.", + "example": "curl http://localhost:5001/api/network/vlans -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "type", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/network/vlans", + "auth": "permission:network.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new VLAN.", + "example": "curl -X POST http://localhost:5001/api/network/vlans -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "name": "VLAN 4094", + "vlannumber": 4094 + }, + "responseexample": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "VLAN 4094", + "subnetcount": 0, + "vlanid": 1, + "vlannumber": 4094, + "vlantype": null + }, + "message": "VLAN created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/network/vlans/", + "auth": "permission:network.delete", + "params": "path: vlan_id (int)", + "purpose": "Delete (soft delete) a VLAN.", + "example": "curl -X DELETE http://localhost:5001/api/network/vlans/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/network/vlans/", + "auth": "permission:network.view", + "params": "path: vlan_id (int)", + "purpose": "Get a single VLAN with its subnets.", + "example": "curl http://localhost:5001/api/network/vlans/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/network/vlans/", + "auth": "permission:network.edit", + "params": "path: vlan_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a VLAN.", + "example": "curl -X PUT http://localhost:5001/api/network/vlans/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "plugin-notifications", + "endpoints": [ + { + "method": "GET", + "path": "/api/notifications", + "auth": "permission:notifications.view", + "params": "not documented", + "purpose": "List all notifications with filtering and pagination.", + "example": "curl http://localhost:5001/api/notifications -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "current", + "type": "string", + "required": false, + "default": "false" + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "ticketnumber", + "type": "string", + "required": false + }, + { + "name": "type_id", + "type": "integer", + "required": false + }, + { + "name": "typeid", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/notifications", + "auth": "permission:notifications.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new notification.", + "example": "curl -X POST http://localhost:5001/api/notifications -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "alertfleet": true, + "alertscope": { + "computertype": "CMM PC", + "hostname": [ + "", + "" + ] + }, + "notification": "Line 3 is down", + "notificationtypeid": 1 + }, + "responseexample": { + "data": { + "alertfleet": true, + "alertscope": { + "computertype": "CMM PC", + "hostname": [ + "", + "" + ] + }, + "alerttimeout": 0, + "appid": null, + "businessunitid": null, + "employeename": "", + "employeesso": "", + "enddate": null, + "endtime": null, + "isactive": true, + "iscurrent": true, + "isshopfloor": false, + "link": null, + "linkurl": null, + "message": "Line 3 is down", + "notification": "Line 3 is down", + "notificationid": 1, + "notificationtypeid": 1, + "startdate": "2026-01-01T00:00:00.000000+00:00", + "starttime": "2026-01-01T00:00:00.000000+00:00", + "ticketnumber": null, + "title": "Line 3 is down", + "typecolor": "#d93f3f", + "typename": "Fleet Alert" + }, + "message": "Notification created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/notifications/", + "auth": "permission:notifications.delete", + "params": "path: notification_id (int)", + "purpose": "Delete (soft delete) a notification.", + "example": "curl -X DELETE http://localhost:5001/api/notifications/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/notifications/", + "auth": "permission:notifications.view", + "params": "path: notification_id (int)", + "purpose": "Get a single notification.", + "example": "curl http://localhost:5001/api/notifications/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/notifications/", + "auth": "permission:notifications.edit", + "params": "path: notification_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a notification.", + "example": "curl -X PUT http://localhost:5001/api/notifications/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "alertfleet": null, + "alerttimeout": null + }, + "responseexample": { + "data": { + "alertfleet": false, + "alertscope": null, + "alerttimeout": 0, + "appid": null, + "businessunitid": null, + "employeename": "", + "employeesso": "", + "enddate": null, + "endtime": null, + "isactive": true, + "iscurrent": true, + "isshopfloor": false, + "link": null, + "linkurl": null, + "message": "Line 3 is down", + "notification": "Line 3 is down", + "notificationid": 1, + "notificationtypeid": 1, + "startdate": "2026-01-01T00:00:00.000000+00:00", + "starttime": "2026-01-01T00:00:00.000000+00:00", + "ticketnumber": null, + "title": "Line 3 is down", + "typecolor": "#d93f3f", + "typename": "Fleet Alert" + }, + "message": "Notification updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/notifications/active", + "auth": "permission:notifications.view", + "params": "not documented", + "purpose": "Get currently active notifications for display.", + "example": "curl http://localhost:5001/api/notifications/active -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "notifications": [], + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/notifications/calendar", + "auth": "permission:notifications.view", + "params": "not documented", + "purpose": "Get notifications in FullCalendar event format.", + "example": "curl http://localhost:5001/api/notifications/calendar -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "end", + "type": "string", + "required": false + }, + { + "name": "start", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/notifications/dashboard/active", + "auth": "permission:notifications.view", + "params": "not documented", + "purpose": "Notifications currently showing on the floor.", + "example": "curl http://localhost:5001/api/notifications/dashboard/active -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/notifications/dashboard/summary", + "auth": "permission:notifications.view", + "params": "not documented", + "purpose": "Get notifications dashboard summary.", + "example": "curl http://localhost:5001/api/notifications/dashboard/summary -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "active": 0, + "bytype": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/notifications/employee/", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "path: sso (string)", + "purpose": "Get recognitions for a specific employee by SSO.", + "example": "curl http://localhost:5001/api/notifications/employee/x" + }, + { + "method": "GET", + "path": "/api/notifications/shopfloor", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "not documented", + "purpose": "Get notifications for shopfloor TV dashboard.", + "example": "curl http://localhost:5001/api/notifications/shopfloor", + "queryparams": [ + { + "name": "businessunit", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "configversion": "2b7c2d2f543f", + "current": [ + { + "boardcategory": "Current Events", + "boardorder": 50, + "displaystyle": "grid", + "employeename": "", + "employeepicture": "", + "employeesso": "", + "endtime": null, + "isactive": true, + "isshopfloor": true, + "link": null, + "notification": "press down", + "notificationid": 2, + "resolved": false, + "starttime": null, + "ticketnumber": null, + "typecolor": "#17a2b8", + "typename": "Incident" + }, + { + "boardcategory": "Current Events", + "boardorder": 30, + "displaystyle": "grid", + "employeename": "", + "employeepicture": "", + "employeesso": "", + "endtime": null, + "isactive": true, + "isshopfloor": true, + "link": null, + "notification": "retooling", + "notificationid": 1, + "resolved": false, + "starttime": null, + "ticketnumber": null, + "typecolor": "#17a2b8", + "typename": "Change" + } + ], + "timestamp": "2026-01-01T00:00:00.000000", + "upcoming": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/notifications/types", + "auth": "permission:notifications.view", + "params": "not documented", + "purpose": "List all notification types.", + "example": "curl http://localhost:5001/api/notifications/types -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [ + { + "boardcategory": "Alerts", + "boardorder": 100, + "displaystyle": "grid", + "expirydays": null, + "expiryhour": null, + "expiryminute": 0, + "expirymode": "none", + "gracewindowminutes": 0, + "isactive": true, + "notificationtypeid": 1, + "showemployeephoto": "", + "splitperemployee": "", + "typecolor": "#17a2b8", + "typedescription": null, + "typename": "Awareness" + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/notifications/types", + "auth": "permission:notifications.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new notification type.", + "example": "curl -X POST http://localhost:5001/api/notifications/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "boardcategory": "Alerts", + "displaystyle": "grid", + "typename": "Awareness" + }, + "responseexample": { + "data": { + "boardcategory": "Alerts", + "boardorder": 100, + "displaystyle": "grid", + "expirydays": null, + "expiryhour": null, + "expiryminute": 0, + "expirymode": "none", + "gracewindowminutes": 0, + "isactive": true, + "notificationtypeid": 1, + "showemployeephoto": "", + "splitperemployee": "", + "typecolor": "#17a2b8", + "typedescription": null, + "typename": "Awareness" + }, + "message": "Notification type created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "PATCH|PUT", + "path": "/api/notifications/types/", + "auth": "permission:notifications.create", + "params": "path: type_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a notification type, including its auto-expiry rule.", + "example": "curl -X PATCH http://localhost:5001/api/notifications/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "boardcategory": "Alerts" + }, + "responseexample": { + "data": { + "boardcategory": "Alerts", + "boardorder": 100, + "displaystyle": "grid", + "expirydays": null, + "expiryhour": null, + "expiryminute": 0, + "expirymode": "none", + "gracewindowminutes": 0, + "isactive": true, + "notificationtypeid": 1, + "showemployeephoto": "", + "splitperemployee": "", + "typecolor": "#17a2b8", + "typedescription": null, + "typename": "Awareness" + }, + "message": "Notification type updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + } + ] + }, + { + "surface": "plugin-printers", + "endpoints": [ + { + "method": "GET", + "path": "/api/printers", + "auth": "permission:printers.view", + "params": "not documented", + "purpose": "List all printers with filtering and pagination.", + "example": "curl http://localhost:5001/api/printers -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "assetnumber", + "type": "string", + "required": false + }, + { + "name": "businessunit_id", + "type": "integer", + "required": false + }, + { + "name": "businessunitid", + "type": "integer", + "required": false + }, + { + "name": "dir", + "type": "string", + "required": false, + "default": "asc" + }, + { + "name": "location_id", + "type": "integer", + "required": false + }, + { + "name": "locationid", + "type": "integer", + "required": false + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "sort", + "type": "string", + "required": false, + "default": "hostname" + }, + { + "name": "type_id", + "type": "integer", + "required": false + }, + { + "name": "typeid", + "type": "integer", + "required": false + }, + { + "name": "vendor_id", + "type": "integer", + "required": false + }, + { + "name": "vendorid", + "type": "integer", + "required": false + } + ], + "responseexample": { + "data": [ + { + "assetid": 1, + "assetnumber": "PR-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "printer", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "ipaddress": "", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "printer": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "installpath": null, + "isactive": true, + "iscolor": false, + "iscsf": false, + "isduplex": false, + "isnetwork": true, + "modelname": "ZT411", + "modelnumberid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "pin": null, + "printerid": 1, + "printertypeid": 1, + "printertypename": "Thermal", + "sharename": null, + "vendorid": null, + "windowsname": null + }, + "serialnumber": "", + "statusid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/printers", + "auth": "permission:printers.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create new printer (creates both Asset and Printer records).", + "example": "curl -X POST http://localhost:5001/api/printers -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "assetnumber": "CSF01-Materials-HP", + "hostname": "", + "installpath": "", + "mapx": 120, + "mapy": 240 + }, + "responseexample": { + "data": { + "assetid": 1, + "assetnumber": "SpoolsInspection-Xerox", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "printer", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "printer": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "installpath": null, + "isactive": true, + "iscolor": false, + "iscsf": false, + "isduplex": false, + "isnetwork": true, + "modelname": "VersaLink C7125", + "modelnumberid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "pin": null, + "printerid": 1, + "printertypeid": null, + "sharename": null, + "vendorfrommodel": true, + "vendorid": null, + "vendorname": "Xerox", + "windowsname": null + }, + "serialnumber": "", + "statusid": 1 + }, + "message": "Printer created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/printers/", + "auth": "permission:printers.delete", + "params": "path: printer_id (int)", + "purpose": "Delete (soft delete) printer.", + "example": "curl -X DELETE http://localhost:5001/api/printers/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/printers/", + "auth": "permission:printers.view", + "params": "path: printer_id (int)", + "purpose": "Get a single printer with full details.", + "example": "curl http://localhost:5001/api/printers/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "assetid": 1, + "assetnumber": "PR-2001", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "printer", + "businessunitid": null, + "communications": [], + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "drivers": [], + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "printer": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "imageurl": "/api/models/images/model-1.png", + "installpath": null, + "isactive": true, + "iscolor": false, + "iscsf": false, + "isduplex": false, + "isnetwork": true, + "modelname": "IMG-MODEL-1", + "modelnumberid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "pin": null, + "printerid": 1, + "printertypeid": null, + "sharename": null, + "vendorid": null, + "windowsname": null + }, + "serialnumber": "", + "statusid": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/printers/", + "auth": "permission:printers.edit", + "params": "path: printer_id (int); body: JSON (fields not yet documented)", + "purpose": "Update printer (both Asset and Printer records).", + "example": "curl -X PUT http://localhost:5001/api/printers/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "gaugelabreference": "GL-2", + "maintenancereference": "MNT-2" + }, + "responseexample": { + "data": { + "assetid": 1, + "assetnumber": "AST-printer", + "assettypecolor": null, + "assettypeid": 2, + "assettypename": "printer", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": "GL-2", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": "MNT-2", + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "printer": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "installpath": null, + "isactive": true, + "iscolor": false, + "iscsf": false, + "isduplex": false, + "isnetwork": true, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "pin": null, + "printerid": 1, + "printertypeid": null, + "sharename": null, + "vendorid": null, + "windowsname": null + }, + "serialnumber": "", + "statusid": 1 + }, + "message": "Printer updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/printers//supplies", + "auth": "permission:printers.view", + "params": "path: printer_id (int)", + "purpose": "Get supply levels from Zabbix (real-time lookup).", + "example": "curl http://localhost:5001/api/printers/1/supplies -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/printers/assignments/for-asset/", + "auth": "permission:printers.view", + "params": "path: asset_id (int)", + "purpose": "What THIS asset is assigned, without inheritance.", + "example": "curl http://localhost:5001/api/printers/assignments/for-asset/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "assetid": 4, + "defaultprinterassetid": null, + "printerassetids": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/printers/assignments/for-asset/", + "auth": "permission:printers.edit", + "params": "path: asset_id (int); body: JSON (fields not yet documented)", + "purpose": "Reconcile one asset's whole printer assignment in a single call.", + "example": "curl -X PUT http://localhost:5001/api/printers/assignments/for-asset/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "defaultprinterassetid": 4, + "printerassetids": [ + 4, + 5 + ] + }, + "responseexample": { + "data": { + "assetid": 3, + "defaultprinterassetid": 4, + "printerassetids": [ + 4, + 5 + ] + }, + "message": "Printer assignment updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/printers/assignments/seed-from-observed/", + "auth": "permission:printers.edit", + "params": "path: asset_id (int); body: JSON (fields not yet documented)", + "purpose": "Write what a PC observed as the assignment of the asset in the path.", + "example": "curl -X POST http://localhost:5001/api/printers/assignments/seed-from-observed/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/printers/by-asset/", + "auth": "permission:printers.view", + "params": "path: asset_id (int)", + "purpose": "Get printer data by asset ID.", + "example": "curl http://localhost:5001/api/printers/by-asset/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/printers/dashboard/summary", + "auth": "permission:printers.view", + "params": "not documented", + "purpose": "Get printer dashboard summary data.", + "example": "curl http://localhost:5001/api/printers/dashboard/summary -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "bytype": [], + "byvendor": [], + "criticalsupplies": 0, + "lowsupplies": 0, + "online": 0, + "total": 0, + "totalprinters": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/printers/dashboard/supplies", + "auth": "permission:printers.view", + "params": "not documented", + "purpose": "Printers needing a cartridge, flattened to one row per printer.", + "example": "curl http://localhost:5001/api/printers/dashboard/supplies -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "rows": [ + { + "iscritical": true, + "levelid": null, + "location": "Shop floor", + "mapx": null, + "mapy": null, + "printerid": 2, + "printername": "Bravo", + "supplies": [ + { + "level": "critical", + "text": "Black Toner 0%", + "title": "No part number on file for this model" + } + ], + "worstremaining": 0 + }, + { + "iscritical": true, + "levelid": null, + "location": "Shop floor", + "mapx": null, + "mapy": null, + "printerid": 3, + "printername": "Charlie", + "supplies": [ + { + "level": "critical", + "text": "Black Toner 2%", + "title": "No part number on file for this model" + } + ], + "worstremaining": 2 + }, + { + "iscritical": true, + "levelid": null, + "location": "Shop floor", + "mapx": null, + "mapy": null, + "printerid": 1, + "printername": "Alpha", + "supplies": [ + { + "level": "critical", + "text": "Black Toner 4%", + "title": "No part number on file for this model" + } + ], + "worstremaining": 4 + }, + "" + ], + "unavailable": null + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/printers/drivers", + "auth": "permission:printers.view", + "params": "not documented", + "purpose": "List printer drivers. ?active=false includes inactive ones.", + "example": "curl http://localhost:5001/api/printers/drivers -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + } + ], + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/printers/drivers", + "auth": "permission:printers.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: printers_asset.create_driver has no docstring.", + "example": "curl -X POST http://localhost:5001/api/printers/drivers -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "drivername": "HP Universal Printing PCL 6", + "location": "", + "name": "HP universal", + "vendorid": 1 + }, + "responseexample": { + "data": { + "description": null, + "driverid": 1, + "drivername": "HP Universal Printing PCL 6", + "isactive": true, + "location": "", + "modelname": null, + "modelnumberid": null, + "name": "HP universal", + "vendorid": 1 + }, + "message": "Driver created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/printers/drivers/", + "auth": "permission:printers.delete", + "params": "path: driver_id (int)", + "purpose": "UNDOCUMENTED: printers_asset.delete_driver has no docstring.", + "example": "curl -X DELETE http://localhost:5001/api/printers/drivers/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/printers/drivers/", + "auth": "permission:printers.edit", + "params": "path: driver_id (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: printers_asset.update_driver has no docstring.", + "example": "curl -X PUT http://localhost:5001/api/printers/drivers/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "drivername": "Xerox Global Print Driver PCL6", + "vendorid": 2 + }, + "responseexample": { + "data": { + "description": null, + "driverid": 1, + "drivername": "Xerox Global Print Driver PCL6", + "isactive": true, + "location": "", + "modelname": null, + "modelnumberid": null, + "name": "Xerox universal", + "vendorid": 2 + }, + "message": "Driver updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/printers/for-host/", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "path: hostname (string)", + "purpose": "Printers assigned to a PC, by hostname, with what it takes to install one.", + "example": "curl http://localhost:5001/api/printers/for-host/x -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "assetid": 1, + "assetnumber": "1001", + "defaultprinterid": null, + "hostname": "", + "printers": [ + { + "assetid": 4, + "driverid": null, + "driverlocation": null, + "drivername": null, + "hostname": "", + "inheritedfromassetid": 3, + "installpath": null, + "ipaddress": "", + "isdefault": false, + "port": null, + "printerid": 1, + "queuename": "PRINTER-A", + "sharename": null, + "windowsname": "PRINTER-A" + }, + { + "assetid": 5, + "driverid": null, + "driverlocation": null, + "drivername": null, + "hostname": "", + "inheritedfromassetid": 3, + "installpath": null, + "ipaddress": "", + "isdefault": false, + "port": null, + "printerid": 2, + "queuename": "PRINTER-B", + "sharename": null, + "windowsname": "PRINTER-B" + }, + { + "assetid": 6, + "driverid": null, + "driverlocation": null, + "drivername": null, + "hostname": "", + "inheritedfromassetid": 3, + "installpath": null, + "ipaddress": "", + "isdefault": false, + "port": null, + "printerid": 3, + "queuename": "PRINTER-C", + "sharename": null, + "windowsname": "PRINTER-C" + } + ], + "source": "inherited" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/printers/install-batch", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "not documented", + "purpose": "Generate a Windows .bat that installs the selected printers.", + "example": "curl http://localhost:5001/api/printers/install-batch -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "printerids", + "type": "string", + "required": false + } + ] + }, + { + "method": "GET", + "path": "/api/printers/install-list", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "not documented", + "purpose": "Flat, unpaginated list of network printers for the printer installer.", + "example": "curl http://localhost:5001/api/printers/install-list -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "format", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [ + { + "hostname": "", + "installpath": "", + "ipaddress": "", + "iscsf": false, + "levelid": null, + "locationname": null, + "machinenumber": "CSF01-Materials-HP", + "mapx": 120, + "mapy": 240, + "modelnumber": null, + "name": "CSF01-Materials-HP", + "printerid": 1, + "sharename": null, + "vendorname": "", + "windowsname": null + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/printers/lookup", + "auth": "permission:printers.view", + "params": "not documented", + "purpose": "Find a printer by IP or FQDN. Parity with the classic printerlookup.asp.", + "example": "curl http://localhost:5001/api/printers/lookup -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "fqdn", + "type": "string", + "required": false + }, + { + "name": "ip", + "type": "string", + "required": false + } + ] + }, + { + "method": "GET", + "path": "/api/printers/lowsupplies", + "auth": "permission:printers.view", + "params": "not documented", + "purpose": "Get printers with low or critical supply levels.", + "example": "curl http://localhost:5001/api/printers/lowsupplies -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "printers": [], + "summary": { + "critical": 0, + "low": 0, + "total_checked": 0 + } + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/printers/models", + "auth": "permission:printers.view", + "params": "not documented", + "purpose": "List models with a supply count, for the supply-management picker.", + "example": "curl http://localhost:5001/api/printers/models -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + }, + { + "name": "vendor_id", + "type": "integer", + "required": false + }, + { + "name": "vendorid", + "type": "integer", + "required": false + }, + { + "name": "withsupplies", + "type": "string", + "required": false, + "default": "" + } + ], + "responseexample": { + "data": [ + { + "modelnumber": "TestModel C999", + "modelnumberid": 1, + "supplycount": 1, + "vendor": "TestVendor", + "vendorid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/printers/models//supplies", + "auth": "permission:printers.view", + "params": "path: modelnumberid (int)", + "purpose": "List all supplies mapped to a model.", + "example": "curl http://localhost:5001/api/printers/models/1/supplies -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "modelnumber": "TestModel C999", + "modelnumberid": 1, + "supplies": [ + { + "capacitytier": "standard", + "color": "black", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "marketingname": "414A Black", + "modelnumber": "TestModel C999", + "modelnumberid": 1, + "modelsupplyid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "pageyield": 2400, + "partnumber": "W2020A", + "supplytype": "toner" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/printers/models//supplies", + "auth": "permission:printers.create", + "params": "path: modelnumberid (int); body: JSON (fields not yet documented)", + "purpose": "Add a supply to a model.", + "example": "curl -X POST http://localhost:5001/api/printers/models/1/supplies -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "capacitytier": "standard", + "color": "black", + "marketingname": "414A Black", + "pageyield": 2400, + "partnumber": "W2020A", + "supplytype": "toner" + }, + "responseexample": { + "data": { + "capacitytier": "standard", + "color": "black", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "marketingname": "414A Black", + "modelnumber": "TestModel C999", + "modelnumberid": 1, + "modelsupplyid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "pageyield": 2400, + "partnumber": "W2020A", + "supplytype": "toner" + }, + "message": "Supply added", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/printers/observed/", + "auth": "permission:printers.view", + "params": "path: hostname (string)", + "purpose": "What a host last reported, each queue judged against what it is assigned.", + "example": "curl http://localhost:5001/api/printers/observed/x -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/printers/observed/for-asset/", + "auth": "permission:printers.view", + "params": "path: asset_id (int)", + "purpose": "The same comparison, reached from an asset page: one block per host.", + "example": "curl http://localhost:5001/api/printers/observed/for-asset/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "assetid": 1, + "assetnumber": "1001", + "hosts": [ + { + "assetid": 1, + "assetnumber": "1001", + "hostname": "", + "observedat": "2026-01-01T00:00:00.000000", + "queues": [ + { + "classification": "extra", + "driftfields": [], + "drivername": "HP Universal Printing PS", + "expecteddrivername": null, + "expectedportaddress": null, + "expectedqueuename": null, + "inheritedfromassetid": null, + "isassigneddefault": false, + "isdefault": true, + "isshared": false, + "portaddress": "", + "portname": "IP_10.20.0.11", + "printerassetid": 5, + "printerid": 1, + "printername": "Bay label printer", + "queuename": "PRINTER-A" + }, + { + "classification": "extra", + "driftfields": [], + "drivername": "HP Universal Printing PS", + "expecteddrivername": null, + "expectedportaddress": null, + "expectedqueuename": null, + "inheritedfromassetid": null, + "isassigneddefault": false, + "isdefault": false, + "isshared": false, + "portaddress": "", + "portname": "IP_10.20.0.12", + "printerassetid": 6, + "printerid": 2, + "printername": "Bay laser printer", + "queuename": "PRINTER-B" + }, + { + "classification": "unknown", + "driftfields": [], + "drivername": "HP Universal Printing PS", + "expecteddrivername": null, + "expectedportaddress": null, + "expectedqueuename": null, + "inheritedfromassetid": null, + "isassigneddefault": false, + "isdefault": false, + "isshared": false, + "portaddress": "", + "portname": "IP_10.20.0.99", + "printerassetid": null, + "printerid": null, + "printername": null, + "queuename": "Reception Copier" + } + ], + "seedcandidate": { + "defaultprinterassetid": 5, + "printerassetids": [ + 5, + 6 + ], + "skipped": [ + { + "drivername": "HP Universal Printing PS", + "isdefault": false, + "portaddress": "", + "portname": "IP_10.20.0.99", + "queuename": "Reception Copier" + } + ] + }, + "source": "none", + "summary": { + "drifted": 0, + "extra": 2, + "matching": 0, + "missing": 0, + "unknown": 1 + } + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/printers/pc-default", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "not documented", + "purpose": "Default printer for a PC, by machine (asset) number.", + "example": "curl http://localhost:5001/api/printers/pc-default -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "format", + "type": "string", + "required": false + }, + { + "name": "machine", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": { + "printerid": 1, + "windowsname": "HP-CSF04-Materials" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "DELETE", + "path": "/api/printers/supplies/", + "auth": "permission:printers.delete", + "params": "path: modelsupplyid (int)", + "purpose": "Delete a model supply.", + "example": "curl -X DELETE http://localhost:5001/api/printers/supplies/1 -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "message": "Supply deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PUT", + "path": "/api/printers/supplies/", + "auth": "permission:printers.edit", + "params": "path: modelsupplyid (int); body: JSON (fields not yet documented)", + "purpose": "Update a model supply.", + "example": "curl -X PUT http://localhost:5001/api/printers/supplies/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "capacitytier": "high", + "marketingname": "414X Black" + }, + "responseexample": { + "data": { + "capacitytier": "high", + "color": "black", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "marketingname": "414X Black", + "modelnumber": "TestModel C999", + "modelnumberid": 1, + "modelsupplyid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "pageyield": null, + "partnumber": "W2020A", + "supplytype": "toner" + }, + "message": "Supply updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/printers/supplies/forecast", + "auth": "permission:printers.view", + "params": "not documented", + "purpose": "Days-to-empty and replacement counts per printer.", + "example": "curl http://localhost:5001/api/printers/supplies/forecast -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "days", + "type": "integer", + "required": false, + "default": 90 + } + ], + "responseexample": { + "data": { + "available": false, + "cartridges": [], + "days": 90, + "horizondays": 14, + "orderlist": [], + "reason": "Zabbix is not configured or not reachable", + "unestimated": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/printers/supplies/meta", + "auth": "permission:printers.view", + "params": "not documented", + "purpose": "Allowed values for supply type, color, and capacity tier (for the UI).", + "example": "curl http://localhost:5001/api/printers/supplies/meta -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "capacitytiers": [ + "standard", + "high", + "extrahigh", + "" + ], + "colors": [ + "black", + "cyan", + "magenta", + "" + ], + "supplytypes": [ + "toner", + "drum", + "waste", + "" + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/printers/supplies/refresh", + "auth": "permission:printers.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Clear cached Zabbix supply data so the next read pulls fresh values.", + "example": "curl -X POST http://localhost:5001/api/printers/supplies/refresh -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": {}, + "responseexample": { + "message": "Supply cache cleared", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/printers/types", + "auth": "permission:printers.view", + "params": "not documented", + "purpose": "List all printer types.", + "example": "curl http://localhost:5001/api/printers/types -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "page", + "type": "integer", + "required": false, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "required": false + }, + { + "name": "perpage", + "type": "integer", + "required": false + }, + { + "name": "search", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/printers/types", + "auth": "permission:printers.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "Create a new printer type.", + "example": "curl -X POST http://localhost:5001/api/printers/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/printers/types/", + "auth": "permission:printers.delete", + "params": "path: type_id (int)", + "purpose": "Delete a printer type. Refused if any printer still uses it.", + "example": "curl -X DELETE http://localhost:5001/api/printers/types/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/printers/types/", + "auth": "permission:printers.view", + "params": "path: type_id (int)", + "purpose": "Get a single printer type.", + "example": "curl http://localhost:5001/api/printers/types/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/printers/types/", + "auth": "permission:printers.edit", + "params": "path: type_id (int); body: JSON (fields not yet documented)", + "purpose": "Update a printer type.", + "example": "curl -X PUT http://localhost:5001/api/printers/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + } + ] + }, + { + "surface": "plugin-slides", + "endpoints": [ + { + "method": "GET", + "path": "/api/slides/", + "auth": "permission:slides.view or slides.manage", + "params": "path: surface (string)", + "purpose": "UNDOCUMENTED: slides.list_slides has no docstring.", + "example": "curl http://localhost:5001/api/slides/x -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "PATCH", + "path": "/api/slides//", + "auth": "permission:slides.manage", + "params": "path: surface (string), slideid (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: slides.update_slide has no docstring.", + "example": "curl -X PATCH http://localhost:5001/api/slides/x/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "POST", + "path": "/api/slides//delete", + "auth": "permission:slides.manage", + "params": "path: surface (string); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: slides.delete_slides has no docstring.", + "example": "curl -X POST http://localhost:5001/api/slides/x/delete -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "POST", + "path": "/api/slides//order", + "auth": "permission:slides.manage", + "params": "path: surface (string); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: slides.reorder_slides has no docstring.", + "example": "curl -X POST http://localhost:5001/api/slides/x/order -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "POST", + "path": "/api/slides//upload", + "auth": "permission:slides.manage", + "params": "path: surface (string); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: slides.upload_slides has no docstring.", + "example": "curl -X POST http://localhost:5001/api/slides/x/upload -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "readsbody": true + }, + { + "method": "GET", + "path": "/api/slides/feed", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "not documented", + "purpose": "Flat playlist for a surface. NOT wrapped in the success_response envelope so the screensaver's existing parser needs no change.", + "example": "curl http://localhost:5001/api/slides/feed", + "queryparams": [ + { + "name": "surface", + "type": "string", + "required": false, + "default": "lobby" + } + ], + "responseexample": { + "basepath": "/api/slides/img/lobby/", + "interval": 10, + "slides": [], + "success": true, + "surface": "lobby" + } + }, + { + "method": "GET", + "path": "/api/slides/img//", + "auth": "none (reviewed: named in the public-read or mutation sweep)", + "params": "path: surface (string), filename (path)", + "purpose": "Serve a slide image with a path-traversal guard.", + "example": "curl http://localhost:5001/api/slides/img/x/example.png" + } + ] + }, + { + "surface": "plugin-tools", + "endpoints": [ + { + "method": "POST", + "path": "/api/tools/secrets", + "auth": "jwt-optional (works signed out; the handler decides what shows)", + "params": "body: JSON (fields not yet documented)", + "purpose": "Store an already-encrypted secret and return its token.", + "example": "curl -X POST http://localhost:5001/api/tools/secrets -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "ciphertext": "", + "iv": "MTIzNDU2Nzg5MDEy", + "views": 100 + }, + "responseexample": { + "data": { + "createddate": "2026-01-01T00:00:00.000000", + "expiresat": "2026-01-01T00:00:00.000000", + "token": "", + "viewcount": 0, + "viewsremaining": 100 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "GET", + "path": "/api/tools/secrets/", + "auth": "none (the unguessable token in the path is the authorisation)", + "params": "path: token (string)", + "purpose": "Whether a link is still live, WITHOUT spending it.", + "example": "curl http://localhost:5001/api/tools/secrets/x", + "responseexample": { + "data": { + "available": false + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/tools/secrets/", + "auth": "none (the unguessable token in the path is the authorisation; redeeming spends the secret)", + "params": "path: token (string); body: JSON (fields not yet documented)", + "purpose": "Hand over the ciphertext once, then spend the view.", + "example": "curl -X POST http://localhost:5001/api/tools/secrets/x -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "ciphertext": "", + "iv": "MTIzNDU2Nzg5MDEy" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + }, + { + "surface": "plugin-warranty", + "endpoints": [ + { + "method": "GET", + "path": "/api/warranty", + "auth": "permission:warranty.view", + "params": "not documented", + "purpose": "List warranties. Filters: ?status=, ?assetid=, ?active=false.", + "example": "curl http://localhost:5001/api/warranty -H \"Authorization: Bearer $TOK\"", + "queryparams": [ + { + "name": "active", + "type": "string", + "required": false, + "default": "true" + }, + { + "name": "assetid", + "type": "integer", + "required": false + }, + { + "name": "servicetag", + "type": "string", + "required": false + }, + { + "name": "status", + "type": "string", + "required": false + }, + { + "name": "vendor", + "type": "string", + "required": false + } + ], + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/warranty", + "auth": "permission:warranty.create", + "params": "body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: warranty.create_warranty has no docstring.", + "example": "curl -X POST http://localhost:5001/api/warranty -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "enddate": "2026-01-01", + "servicetag": "", + "vendor": "Acme" + }, + "responseexample": { + "data": { + "assets": [], + "enddate": "2026-01-01", + "isactive": true, + "lastcheckeddate": null, + "notes": null, + "prooffilename": null, + "proofurl": null, + "provider": "manual", + "servicelevel": null, + "servicetag": "", + "startdate": null, + "status": "active", + "statuscolor": "#4CAF50", + "vendor": "Acme", + "warrantyid": 1 + }, + "message": "Warranty created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/warranty/", + "auth": "permission:warranty.delete", + "params": "path: warrantyid (int)", + "purpose": "UNDOCUMENTED: warranty.delete_warranty has no docstring.", + "example": "curl -X DELETE http://localhost:5001/api/warranty/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/warranty/", + "auth": "permission:warranty.view", + "params": "path: warrantyid (int)", + "purpose": "UNDOCUMENTED: warranty.get_warranty has no docstring.", + "example": "curl http://localhost:5001/api/warranty/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "PUT", + "path": "/api/warranty/", + "auth": "permission:warranty.edit", + "params": "path: warrantyid (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: warranty.update_warranty has no docstring.", + "example": "curl -X PUT http://localhost:5001/api/warranty/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "requestexample": { + "enddate": "" + }, + "responseexample": { + "data": { + "assets": [], + "enddate": null, + "isactive": true, + "lastcheckeddate": null, + "notes": null, + "prooffilename": null, + "proofurl": null, + "provider": "manual", + "servicelevel": null, + "servicetag": "", + "startdate": null, + "status": "unknown", + "statuscolor": "#9E9E9E", + "vendor": "Acme", + "warrantyid": 1 + }, + "message": "Warranty updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "DELETE", + "path": "/api/warranty//proof", + "auth": "permission:warranty.edit", + "params": "path: warrantyid (int)", + "purpose": "Remove a warranty's proof document.", + "example": "curl -X DELETE http://localhost:5001/api/warranty/1/proof -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "enddate": null, + "isactive": true, + "lastcheckeddate": null, + "notes": null, + "prooffilename": null, + "proofurl": null, + "provider": "manual", + "servicelevel": null, + "servicetag": "", + "startdate": null, + "status": "unknown", + "statuscolor": "#9E9E9E", + "vendor": "Dell", + "warrantyid": 1 + }, + "message": "Proof removed", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/warranty//proof", + "auth": "permission:warranty.edit", + "params": "path: warrantyid (int); body: JSON (fields not yet documented)", + "purpose": "Upload (or replace) the proof-of-cover document for a warranty.", + "example": "curl -X POST http://localhost:5001/api/warranty/1/proof -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "responseexample": { + "data": { + "enddate": null, + "isactive": true, + "lastcheckeddate": null, + "notes": null, + "prooffilename": "Dell invoice 4471.pdf", + "proofurl": "/api/warranty/proof/warranty-1.pdf", + "provider": "manual", + "servicelevel": null, + "servicetag": "", + "startdate": null, + "status": "unknown", + "statuscolor": "#9E9E9E", + "vendor": "Dell", + "warrantyid": 1 + }, + "message": "Proof uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + }, + "readsbody": true + }, + { + "method": "POST", + "path": "/api/warranty//refresh", + "auth": "permission:warranty.edit", + "params": "path: warrantyid (int); body: JSON (fields not yet documented)", + "purpose": "UNDOCUMENTED: warranty.refresh_warranty has no docstring.", + "example": "curl -X POST http://localhost:5001/api/warranty/1/refresh -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'" + }, + { + "method": "GET", + "path": "/api/warranty/asset/", + "auth": "permission:warranty.view", + "params": "path: assetid (int)", + "purpose": "Warranties covering one asset (for the asset-detail panel).", + "example": "curl http://localhost:5001/api/warranty/asset/1 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/warranty/dashboard/expiring", + "auth": "permission:warranty.view", + "params": "not documented", + "purpose": "Warranties running out, and ones that already have.", + "example": "curl http://localhost:5001/api/warranty/dashboard/expiring -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "GET", + "path": "/api/warranty/proof/", + "auth": "permission:warranty.view", + "params": "path: filename (path)", + "purpose": "Download a proof document.", + "example": "curl http://localhost:5001/api/warranty/proof/example.png -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/warranty/report", + "auth": "permission:warranty.view", + "params": "not documented", + "purpose": "Counts + lists bucketed by derived status.", + "example": "curl http://localhost:5001/api/warranty/report -H \"Authorization: Bearer $TOK\"", + "responseexample": { + "data": { + "buckets": { + "active": [], + "expired": [], + "expiring": [], + "unknown": [] + }, + "counts": { + "active": 0, + "expired": 0, + "expiring": 0, + "unknown": 0 + } + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + }, + { + "method": "POST", + "path": "/api/warranty/sync/dell", + "auth": "permission:warranty.edit", + "params": "body: JSON (fields not yet documented)", + "purpose": "Look up every asset serial (= Dell service tag) that has no warranty yet and create warranties for the ones Dell recognizes.", + "example": "curl -X POST http://localhost:5001/api/warranty/sync/dell -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'", + "queryparams": [ + { + "name": "all", + "type": "string", + "required": false, + "default": "false" + } + ], + "requestexample": {}, + "responseexample": { + "data": { + "candidates": 0, + "created": 0, + "matched": 0, + "updated": 0 + }, + "message": "No PCs need a warranty lookup.", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + ] + } +] diff --git a/docs/deployment/DOCKER-PREBUILT-APPROACH.md b/docs/deployment/DOCKER-PREBUILT-APPROACH.md new file mode 100644 index 00000000..8c1d32c6 --- /dev/null +++ b/docs/deployment/DOCKER-PREBUILT-APPROACH.md @@ -0,0 +1,87 @@ + +# Building the image with a pre-built frontend + +> **Where this sits:** a variant of Step 2 of [DEPLOY](../DEPLOY.md), not a +> separate deployment. Everything else on that page still applies. + +The shipped `Dockerfile` is a multi-stage build: stage 1 runs `npm ci` and +`npm run build` under Node, stage 2 copies the compiled SPA into the Python +image. Behind a proxy that re-signs HTTPS, stage 1 is where it dies - npm inside +the container does not trust the corporate root CA, and the failure reads +`npm ci failed` or the unhelpful `Exit handler never called`. + +The fix is to build the frontend where npm already works - the host, which has +the corporate CA in its trust store - and give the image the finished +`frontend/dist/`. + +It is also much faster: two to three minutes instead of fifteen. A site with no +proxy problem at all may want it for that reason alone. + +## Do it + +```bash +# 1. Build the frontend on the host +cd frontend +npm ci +npm run build # writes frontend/dist/ +cd .. + +# 2. Build and start the stack from the pre-built dist +docker compose -f docker-compose.prebuilt.yml build +docker compose -f docker-compose.prebuilt.yml up -d +``` + +The rest is [DEPLOY](../DEPLOY.md) unchanged: the `migrate` service runs the +schema, the plugin bootstrap and the seeders before `api` starts, nginx +terminates TLS on `API_PORT`, and the first admin comes from `/setup` or +`flask seed admin`. + +## The files this uses + +| File | What it is | +|---|---| +| `Dockerfile.prebuilt` | single-stage: Python dependencies plus a copy of `frontend/dist/`. No Node, no npm | +| `docker-compose.prebuilt.yml` | the same stack, building from `Dockerfile.prebuilt` | +| `.dockerignore.prebuilt` | permits `frontend/dist/` to be copied, which the normal `.dockerignore` excludes | + +## What it costs + +- **npm has to exist on the build host.** That is the whole trade: the trust + problem moves from the container to a machine that has already solved it. +- **It is two steps, and forgetting the first one ships a stale UI.** The image + copies whatever is in `frontend/dist/` at build time. A frontend change that + was not rebuilt is simply not in the image, and nothing warns you. If the UI + does not show your change, rebuild `dist/` before debugging anything else. +- **`frontend/dist/` has to reach the build.** Either it is committed, or the + pipeline builds it immediately before the image. + +## The version of this that scales + +The pre-built approach is a workaround for building at the deployment site. The +end state is not building there at all: + +```yaml +# CI: build the frontend, build the image, push it +- run: cd frontend && npm ci && npm run build +- run: docker build -f Dockerfile.prebuilt -t shopdb:$GIT_SHA . +- run: docker push /shopdb:$GIT_SHA +``` + +A site then pulls a tagged image and runs it - seconds, no build step, no npm +anywhere near the facility. `docker-compose.pull.yml` is the compose file for +that shape today, and `docker-compose.airgap.yml` is the same idea for a site +that loads an image tarball off removable media +([DEPLOY-AIRGAP](../DEPLOY-AIRGAP.md)). Finishing that path is the distributed +release item on [ROADMAP](../ROADMAP.md). + +| Approach | Build time | Behind a re-signing proxy | Where it belongs | +|---|---|---|---| +| Multi-stage `Dockerfile` | 15+ min | fails unless the CA is staged | a developer box with a clean network | +| Pre-built frontend (this page) | 2-3 min | works | a site that must build locally | +| Pull a published image | seconds | works | where every site should end up | diff --git a/docs/deployment/PRODUCTION-READINESS.md b/docs/deployment/PRODUCTION-READINESS.md new file mode 100644 index 00000000..ecb84815 --- /dev/null +++ b/docs/deployment/PRODUCTION-READINESS.md @@ -0,0 +1,372 @@ + +# Production readiness assessment, 2026-09-07 + +> **This is a record, not guidance.** Read it for WHY the deployment story +> needed work, not for what to do. Two things have moved since: +> +> - **Its recommendation was reversed.** It recommends Windows/IIS for the +> reference site and Docker only for new ones. The decision since is the +> other way round: Docker is the path for every site and Windows/IIS is the +> legacy topology that will be deprecated. +> - **Its "critical path" became the roadmap.** Pre-built images in a registry, +> one-command setup, automatic plugin registration and a simpler backup mode +> are now the distributed-release item on [ROADMAP](../ROADMAP.md). Several +> of its gaps are already closed: the `migrate` service runs the schema, the +> plugin bootstrap and the seeders with no manual step, and +> `docker-compose.prebuilt.yml` removes npm from the image build. +> +> Current pages: [DEPLOY](../DEPLOY.md) to stand a site up, +> [DOCKER-PREBUILT-APPROACH](DOCKER-PREBUILT-APPROACH.md) to build it behind a +> proxy, [ROADMAP](../ROADMAP.md) for what is left. + +## Executive Summary + +[CRITICAL] **NOT PRODUCTION READY** in current state without significant operational investment. + +The Docker deployment requires extensive manual setup, multiple services, and assumes expertise with Docker Compose, restic backup systems, and Flask plugin architecture. This assessment documents current gaps and provides recommendations. + +## Current State: Deployment Complexity + +### What Works + +[OK] **Flask application code** - Stable, tested, 17 plugins functional +[OK] **Database schema** - Migrations work, data model is sound +[OK] **Frontend build** - Vue 3 SPA compiles and serves correctly +[OK] **Docker images** - Build successfully (with caveats below) +[OK] **Plugin architecture** - Clean, documented, extensible + +### Critical Gaps + +[FAIL] **One-command deployment** - Requires 7-10 manual steps +[FAIL] **Corporate proxy handling** - Zscaler cert extraction needed for npm +[FAIL] **Backup system complexity** - 3 containers + restic + scheduler +[FAIL] **TLS certificate generation** - Manual OpenSSL commands required +[FAIL] **Plugin initialization** - Must run `apply-profile` after first start +[FAIL] **Password reset** - No default admin password, requires manual SQL +[FAIL] **Build time** - 15+ minutes with corporate proxy +[FAIL] **Documentation scattered** - 3 different setup guides (Docker, Local, Backup) + +## Why Is This So Complex? + +### 1. Corporate Network Reality + +**Problem:** Behind Zscaler proxy, npm can't trust npm registry +**Impact:** Docker build fails without extracted corporate root CA +**Workaround:** PowerShell script to extract cert before building +**Production Fix:** Pre-build images in CI/CD with cert, publish to registry + +### 2. Plugin Architecture Design + +**Problem:** Plugin state in `instance/plugins.json`, not database +**Impact:** Importing prod database doesn't register plugins +**Workaround:** Manual `flask plugin apply-profile` after first start +**Production Fix:** Auto-detect installed plugins from schema on boot + +### 3. Backup System Architecture + +**Problem:** Designed for restic with 3-container orchestration +**Impact:** Requires backup-store + backup-watcher + backup containers +**Workaround:** All documented, but complex to deploy +**Production Fix:** Simpler mysqldump-only mode with direct file storage + +### 4. Multi-Stage Docker Build + +**Problem:** Frontend build needs Node 20, backend needs Python 3.14 +**Impact:** Multi-stage build is slow, complex, hard to debug +**Workaround:** Build works, but takes 15 minutes +**Production Fix:** Pre-build frontend on CI, copy dist/ into Python-only image + +## Deployment Models (Ordered by Simplicity) + +### Option 1: IIS + Windows Services (SIMPLEST) + +**Best for:** Single Windows server deployment +**Complexity:** Low +**Maintenance:** Medium + +**Setup:** +1. Install Python 3.13+ +2. Install MySQL (or use existing instance) +3. Run installer (creates Windows service for Flask) +4. Configure IIS reverse proxy +5. Install plugins via UI + +**Pros:** +- Native Windows deployment +- Familiar IIS management +- No Docker complexity +- Fast startup + +**Cons:** +- Windows-only +- Manual updates +- No containerization benefits + +**Production Ready:** [WARN] **YES** (with installer improvements) + +### Option 2: Docker Pre-Built Images (RECOMMENDED) + +**Best for:** Sites with Docker but no build capability +**Complexity:** Low-Medium +**Maintenance:** Low + +**Setup:** +1. Pull pre-built image from registry +2. Run docker-compose with .env file +3. Access UI, enable plugins + +**Pros:** +- No build step +- Fast deployment (2 minutes) +- Consistent environment +- Easy updates (pull new image) + +**Cons:** +- Requires Docker +- Need image registry +- Initial registry setup + +**Production Ready:** [OK] **YES** (requires CI/CD setup) + +### Option 3: Docker Source Build (CURRENT) + +**Best for:** Development, customization +**Complexity:** High +**Maintenance:** High + +**Setup:** +1. Extract Zscaler cert +2. Generate TLS certs +3. Build Docker images (15+ min) +4. Configure .env with 15+ variables +5. Start docker-compose +6. Run migrations +7. Install plugins +8. Reset admin password +9. Configure backup system +10. Test everything + +**Pros:** +- Full source control +- Customizable +- No external dependencies + +**Cons:** +- Extremely complex +- Long build times +- Many failure points +- Requires Docker expertise + +**Production Ready:** [CRITICAL] **NO** (too complex for operations) + +### Option 4: Kubernetes (FUTURE) + +**Best for:** Multi-site deployment, high availability +**Complexity:** Very High +**Maintenance:** Medium (with proper GitOps) + +**Status:** Not implemented +**Effort:** 2-3 weeks of work + +## What Would Make This Production Ready? + +### Critical Path (Must Have) + +1. **Pre-Built Images in Registry** + - Build images in CI/CD + - Push to container registry + - Sites just `docker pull` + - **Effort:** 1 week + +2. **One-Command Setup** + - `./deploy.sh` does everything + - No manual cert extraction + - No manual plugin installation + - **Effort:** 3 days + +3. **Automated Plugin Detection** + - Scan database schema on boot + - Auto-register plugins in JSON + - No manual `apply-profile` + - **Effort:** 2 days + +4. **Simplified Backup (Option)** + - Simple mysqldump + tar mode + - Single container, no restic + - Good enough for 80% of sites + - **Effort:** 1 week + +5. **Windows Installer Improvements** + - One-click install + - Includes Python runtime + - Creates service automatically + - **Effort:** 1 week + +### Nice to Have (Future) + +- Helm chart for Kubernetes +- Monitoring/metrics built-in +- Health check endpoints +- Automated testing in CI +- Rolling update strategy + +## Honest Recommendations + +### For the reference site + +**Recommended:** Option 1 (IIS + Windows Services) + +*Reversed since. See the note at the top of this page.* + +**Why (as argued at the time):** +- The site already had Windows infrastructure +- Simpler to operate +- Faster to deploy +- The team knew IIS + +**Steps:** +1. Build Windows installer +2. Deploy to Windows Server +3. Configure IIS reverse proxy +4. Import prod database +5. Done + +**Timeline:** 2-3 days including testing + +### For New Sites + +**Recommended:** Option 2 (Docker Pre-Built Images) + +**Prerequisites:** +- Set up CI/CD to build images +- Set up container registry (Docker Hub, Harbor, or AWS ECR) +- Create `deploy.sh` script + +**Then sites can:** +```bash +# 1. Copy .env.example to .env and edit +cp .env.example .env + +# 2. Run deployment +./deploy.sh + +# 3. Access UI +https://your-server:5001 +``` + +**Timeline for CI/CD setup:** 1-2 weeks + +### For Current Docker Source Build + +**Status:** Development/customization only, not for production + +**Use when:** +- Testing new features +- Developing plugins +- Debugging issues +- Learning the system + +**Don't use when:** +- Deploying to operations +- Training new ops staff +- Time-sensitive deployments + +## Current Documentation Issues + +### Too Many Guides + +- `LOCAL-DEV-SETUP.md` - Windows development (port 3307, run_dev.py) +- `DOCKER-SETUP.md` - Docker source build (complex, 15+ steps) +- `PRODUCTION-READINESS.md` - This file (meta/assessment) +- `scripts/backup/README.md` - Backup system (3 containers, restic) + +*Closed since: the first two were folded into `DEVELOPMENT-SETUP` and `DEPLOY`, +and `INDEX.md` is now the single entry point this section asks for.* + +**Problem:** Which one do I follow? + +**Solution:** Create decision tree: +- Developer on Windows -> LOCAL-DEV-SETUP.md +- Ops deploying to prod -> (DOESN'T EXIST YET - need simplified guide) +- Custom development -> DOCKER-SETUP.md +- Backup questions -> scripts/backup/README.md + +### Missing Guides + +- **PRODUCTION-DEPLOY.md** - Step-by-step for operations (IIS or Docker pull) +- **QUICK-START.md** - 5-minute version for evaluation +- **TROUBLESHOOTING.md** - Common issues, not scattered across files +- **UPGRADE-GUIDE.md** - How to update between versions + +## What We Did Today (Honest Assessment) + +[OK] **Merged the upstream development branch down** - 98 commits +[OK] **Fixed local dev** - `run_dev.py` works for Windows development +[OK] **Documented Docker** - Comprehensive but too complex +[OK] **Added backup docs** - System works but needs expertise +[WARN] **Docker "production ready"** - Technically works, operationally complex +[FAIL] **Simple deployment** - Still requires too many manual steps + +## Next Steps (Priority Order) + +### Immediate (This Week) + +1. **Create QUICK-START.md** + - 5-minute eval version + - Uses pre-built image (if available) OR local dev + - Gets people to working system fast + +2. **Test Windows Installer** + - Build the installer for the deployment branch + - Deploy to test Windows Server + - Document any issues + +3. **Create deployment decision tree** + - Flowchart: what's my situation -> which method to use + - Don't make users read 4 docs to figure out where to start + +### Short Term (Next Sprint) + +4. **Set up CI/CD for image builds** + - GitHub Actions or Jenkins + - Build on every push to the deployment branch + - Push to internal registry + +5. **Create `deploy.sh` script** + - One command: `./deploy.sh --env prod` + - Handles certs, plugins, migrations automatically + +6. **Simplify backup system** + - Add "simple mode" - just mysqldump + tar + - Keep restic for advanced sites + - Document tradeoffs + +### Long Term (Next Month) + +7. **Auto-plugin detection** + - Boot-time schema scan + - No manual `apply-profile` + +8. **Consolidate documentation** + - One entry point + - Clear paths for each audience + - Tested by someone who hasn't seen the system + +## Conclusion + +**Is ShopDB production-ready?** + +- **The code:** Yes, it's solid +- **Docker source build deployment:** No, too complex +- **Windows installer deployment:** Yes, with improvements +- **Docker pre-built images deployment:** Yes, but needs CI/CD setup + +**Bottom line:** The application is mature. The deployment story needs work. Focus should shift from "making Docker work" to "making deployment simple." + +**Recommendation:** Invest 2-3 weeks in deployment automation (CI/CD, pre-built images, deploy.sh) rather than continuing to document complex manual processes. diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 00000000..544bf399 --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,125 @@ +# ShopDB - guide for an AI agent + +ShopDB is a plugin-based asset-management system (PCs, printers, machines, +network devices, applications, knowledge base, guides, notifications, slides, +warranties, backups, endpoint telemetry) for manufacturing sites. Every site +runs its own instance. + +This file is the machine entry point, served at `/api/docs/llms.txt`. It covers +the API, which is what most callers need, and routes everything else. + +## Routing - read this before opening any document + +`docs/INDEX.md` is the routing table. Rows are TASKS, each row gives the +smallest set of pages that answers it, and each row says how many lines that +costs. Find the row, read those pages, skip the rest. The corpus is far larger +than any one context window and almost none of it applies to a given question. + +Every page opens with an HTML comment naming `purpose` +(tutorial/howto/reference/explanation), `audience`, `status` and a one-sentence +`summary`, plus `loadwith` for the pages it is not complete without. Filter on +that metadata before opening anything: `status: legacy` means true but +superseded (do not follow it for new work) and `status: historical` means a +finished migration or a proposal kept as a record, so neither is an answer to +"how do I do this today". + +`docs/START-HERE.md` is the same routing for a human, and shorter. + +Not an API question? Still start from the index rather than guessing at a +filename: its rows cover installing and running a site, loading its data, the +shop-floor PCs, and building on the platform. For "why is it built this way", +`docs/adr/README.md` says which decision governs the question and whether it +still holds. For writing a plugin, `docs/PLUGIN-HOOKS.md` is the contract and +everything else defers to it. + +## Answering "how do I install or fix this server" + +Docker is the topology this line ships, and the only one. `docs/DEPLOY.md` is a +new site at a connected facility, `docs/DEPLOY-AIRGAP.md` is the same stack +where nothing can be pulled, and `docs/UPGRADE.md` moves a running site to a +newer version. There is no Windows installer here. + +Before diagnosing anything, ask for `docker compose ps` and +`curl -s http://127.0.0.1:5002/api/dashboard/health`. Comparing that bypass with +the site's real URL separates "the app is broken" from "nginx or the certificate +is broken". `docs/DEPLOY.md` has the rest of the health surface, and +`docs/CONFIG.md` is where every environment variable and Setting key is defined. + +## Bulk-loading a site's data + +Two routes, and the right one depends on what the site has: + +- SPREADSHEET, no developer (the common case): `flask csv templates --out

` + generates templates FROM THE LIVE SCHEMA, then `flask csv import --dir ` + checks and `--commit` applies. Do NOT hand-write the templates - generate + them. `docs/CSV-IMPORT.md`. +- A SOURCE DATABASE to script against: the HTTP import API, + `docs/IMPORT-API.md`, with `docs/IMPORT-ADOPTION.md` for the loader around it. + +User accounts are deliberately not CSV-importable. + +## Base URL + +Each site runs its own instance (ADR-004), so there is no single production URL: +it is whatever that site serves, e.g. `https://shopdb.example.net/shopdb`. +All API paths are under `/api` (e.g. `/api/assets`). Dev: `http://localhost:5001`. + +## Auth + +Three schemes: + +- **Bearer JWT** - most endpoints. Get one by logging in, or use a managed + Personal Access Token (PAT). Send `Authorization: Bearer `. + - Login: `POST /api/auth/login` `{ "username": "...", "password": "..." }` + -> `data.access_token`. Refresh: `POST /api/auth/refresh`. + - PATs are minted in the UI (Settings > API Tokens); a *scoped* PAT is limited + to named permissions and suspends the admin bypass. +- **X-API-Key** - unattended/service endpoints (collector ingest, and any + plugin's own unattended routes). Send `X-API-Key: `. How a shop-floor PC gets its own + credential is `docs/COLLECTOR-AUTH.md`. +- **Public** - some read endpoints (e.g. printer install-list, the slide feed, + display dashboards) need no auth. + +Auth level per endpoint is in the OpenAPI `security` field: `bearerAuth`, +`apiKeyAuth`, or none. Admin-only and permission-gated routes both use bearer. + +## Response envelope + +JSON endpoints return `{ "status": "success", "data": , "meta": {...} }`. +Errors nest under `data`, NOT at the top level: +`{ "status": "error", "data": { "error": { "code": "...", "message": "...", "details": {...} } } }` +with an HTTP 4xx/5xx. Reading `message` or `code` off the root gives `undefined`. +Lists carry `meta.pagination.{page, perpage, total, pages}` - again nested, not +`meta.total`. A few feed endpoints (screensaver, some installer text formats) +return raw text/JSON without the envelope - noted per route. + +## Common recipes + +- Search everything: `GET /api/search?q=` (multi-word = AND across words). +- List assets on the map: `GET /api/assets/map`. +- List a type: `GET /api/printers`, `/api/computers`, `/api/machines`, + `/api/network` (paginated: `?page=&perpage=`). A plugin that is disabled at + that site serves none of its routes. +- Get one: `GET /api/printers/` etc. +- Create (bearer): `POST /api/printers` `{assetnumber, windowsname, vendorid, ...}`. +- Reports: `GET /api/reports` (list), `GET /api/reports/pc-relationships` (PC<->machine). +- Printer installer data: `GET /api/printers/install-list` (public; add + `?format=text` for a pipe-delimited variant); `GET /api/printers/pc-default?machine=`. +- Collector ingest (X-API-Key): `POST /api/collector/computers`, + `/api/collector/printers`, `/api/collector/endpoints`. How a PC gets its own + credential is `docs/COLLECTOR-AUTH.md`. +- Import (admin PAT, preserves timestamps with `X-Import-Mode`): `docs/IMPORT-API.md`. + +## Conventions + +- DB-mirrored params/fields use lowercase concatenated names (no underscores): + `locationid`, `vendorid`, `windowsname` - match them exactly. +- IDs in paths are integers. +- Plugin endpoints live under the plugin's prefix (`/api//...`). + +## Full reference + +- Machine spec: `GET /api/docs/openapi.json` (OpenAPI 3.1). Operation and + plugin counts live in `docs/PROJECT-MAP.md`, which is generated. +- Interactive: `GET /api/docs` (Redoc). +- Human reference: the same spec at `GET /api/docs`, and `docs/INDEX.md` for everything else. diff --git a/docs/openapi.json b/docs/openapi.json index b6bc374d..f5537a7c 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "ShopDB Flask API", "version": "0.12.0", - "description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither." + "description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither. A permission-gated route is published as bearer-required, which is what a default install does - a site that grants the permission to the `guest` role makes that same route answer a caller with no token." }, "servers": [ { @@ -115,806 +115,149 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Managed service token (e.g. collector.ingest, geenforce.fetch)." + "description": "A managed service or device token in a header. X-API-Key for collector and managed tokens (collector.ingest, and any service permission a plugin declares); X-Endpoint-Bootstrap-Token for agent bootstrap; X-Kiosk-Token for a kiosk screen. The operation description names the header and, where the gate is token-OR-network, the allowlist that is the other half." + }, + "setupCodeAuth": { + "type": "apiKey", + "in": "query", + "name": "code", + "description": "A single-use provisioning code, redeemed in the query string because the caller is a browser being pointed at a URL. Spent on any attempt, right or wrong, and short-lived." } } }, "tags": [ { - "name": "core-identity" + "name": "core-apitokens" }, { - "name": "core-platform" + "name": "core-applications" }, { - "name": "plugin-employees" + "name": "core-assets" }, { - "name": "plugin-knowledgebase" + "name": "core-auditlogs" }, { - "name": "core-catalog" + "name": "core-auth" }, { - "name": "plugin-computers" + "name": "core-businessunits" }, { - "name": "plugin-measuringtools" + "name": "core-collector" }, { - "name": "plugin-machines" + "name": "core-customfields" }, { - "name": "plugin-geenforce" + "name": "core-dashboard" }, { - "name": "plugin-notifications" - }, - { - "name": "plugin-slides" - }, - { - "name": "plugin-printedparts" - }, - { - "name": "plugin-network" - }, - { - "name": "plugin-usb" - }, - { - "name": "plugin-printers" - }, - { - "name": "plugin-warranty" - }, - { - "name": "plugin-backups" - }, - { - "name": "plugin-tools" + "name": "core-dashboarddefaults" }, { "name": "core-docs" }, { - "name": "core-map" + "name": "core-employees" }, { - "name": "guides" + "name": "core-locations" }, { - "name": "plugin-endpoints" + "name": "core-maplevels" + }, + { + "name": "core-mappositions" + }, + { + "name": "core-models" + }, + { + "name": "core-modeltypes" + }, + { + "name": "core-operatingsystems" + }, + { + "name": "core-plugins" + }, + { + "name": "core-pluginui" + }, + { + "name": "core-reports" }, { "name": "core-scripts" }, { - "name": "endpoints-delivery" + "name": "core-search" }, { - "name": "endpoints-siteagent" + "name": "core-settings" + }, + { + "name": "core-setup" + }, + { + "name": "core-shopdbbackups" + }, + { + "name": "core-supportteams" + }, + { + "name": "core-users" + }, + { + "name": "core-vendors" + }, + { + "name": "plugin-backups" + }, + { + "name": "plugin-computers" + }, + { + "name": "plugin-endpoints" + }, + { + "name": "plugin-guides" + }, + { + "name": "plugin-knowledgebase" + }, + { + "name": "plugin-machines" + }, + { + "name": "plugin-network" + }, + { + "name": "plugin-notifications" + }, + { + "name": "plugin-printers" + }, + { + "name": "plugin-slides" + }, + { + "name": "plugin-tools" + }, + { + "name": "plugin-warranty" } ], "paths": { - "/api/auth/login": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Authenticate and issue JWT access+refresh tokens; per-IP fixed-window rate limit (429) and 5-strike/15-min account...", - "description": "Authenticate and issue JWT access+refresh tokens; per-IP fixed-window rate limit (429) and 5-strike/15-min account lockout\n\n**Auth:** none\n\n**Params:** body: username, password (both required)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/auth/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"password\":\"secret123\"}'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: username, password (both required)" - } - } - } - } - } - }, - "/api/auth/refresh": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Exchange a refresh token for a new access token (rejects inactive/deleted users)", - "description": "Exchange a refresh token for a new access token (rejects inactive/deleted users)\n\n**Auth:** jwt (refresh token)\n\n**Params:** none; refresh token in Authorization header\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/auth/refresh -H \"Authorization: Bearer $REFRESH_TOKEN\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none; refresh token in Authorization header" - } - } - } - } - } - }, - "/api/auth/me": { - "get": { - "tags": [ - "core-identity" - ], - "summary": "Return the authenticated user's profile, roles, permissions, mustchangepassword flag", - "description": "Return the authenticated user's profile, roles, permissions, mustchangepassword flag\n\n**Auth:** jwt\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/auth/me -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/auth/change-password": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Self-service password change; forced first-login change skips current_password; clears lockout state", - "description": "Self-service password change; forced first-login change skips current_password; clears lockout state\n\n**Auth:** jwt\n\n**Params:** body: new_password (min 8, required), current_password (required unless mustchangepassword is set)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/auth/change-password -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"current_password\":\"old\",\"new_password\":\"newpass123\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: new_password (min 8, required), current_password (required unless mustchangepassword is set)" - } - } - } - } - } - }, - "/api/auth/logout": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Logout stub for frontend token cleanup (no server-side blacklist yet)", - "description": "Logout stub for frontend token cleanup (no server-side blacklist yet)\n\n**Auth:** jwt\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/auth/logout -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none" - } - } - } - } - } - }, - "/api/users": { - "get": { - "tags": [ - "core-identity" - ], - "summary": "List all users ordered by username", - "description": "List all users ordered by username\n\n**Auth:** jwt + require_role admin\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/users -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - }, - "post": { - "tags": [ - "core-identity" - ], - "summary": "Create a user, assign roles, audit-log, and best-effort send a welcome email with the temp password", - "description": "Create a user, assign roles, audit-log, and best-effort send a welcome email with the temp password\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: username, email, password (required); firstname, lastname, isactive, roles[roleids], mustchangepassword (default true), sendwelcome (default true)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/users -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"username\":\"jdoe\",\"email\":\"jdoe@example.com\",\"password\":\"Temp1234\",\"roles\":[2]}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: username, email, password (required); firstname, lastname, isactive, roles[roleids], mustchangepassword (default true), sendwelcome (default true)" - } - } - } - } - } - }, - "/api/users/{userid}": { - "get": { - "tags": [ - "core-identity" - ], - "summary": "Get one user; non-admins may only fetch their own record (403 otherwise)", - "description": "Get one user; non-admins may only fetch their own record (403 otherwise)\n\n**Auth:** jwt (admin or self, checked inline)\n\n**Params:** path: userid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/users/7 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "userid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-identity" - ], - "summary": "Update a user; email uniqueness enforced; admins can also change active state, roles, and unlock the account; changes...", - "description": "Update a user; email uniqueness enforced; admins can also change active state, roles, and unlock the account; changes audit-logged\n\n**Auth:** jwt (admin or self; isactive/roles/unlock fields admin-only)\n\n**Params:** path: userid; body: email, firstname, lastname, password; admin-only: isactive, roles[roleids], unlock (bool)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/users/7 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"firstname\":\"Jane\",\"unlock\":true}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "userid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: userid; body: email, firstname, lastname, password; admin-only: isactive, roles[roleids], unlock (bool)" - } - } - } - } - }, - "delete": { - "tags": [ - "core-identity" - ], - "summary": "Delete a user; revokes their API tokens and detaches (nulls userid on) their audit-log rows first", - "description": "Delete a user; revokes their API tokens and detaches (nulls userid on) their audit-log rows first\n\n**Auth:** jwt + require_role admin\n\n**Params:** path: userid (cannot be your own account)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/users/7 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "userid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/users/permissions": { - "get": { - "tags": [ - "core-identity" - ], - "summary": "List assignable permissions (core + enabled plugins) both flat and grouped by category, for the role grid", - "description": "List assignable permissions (core + enabled plugins) both flat and grouped by category, for the role grid\n\n**Auth:** jwt\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/users/permissions -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/users/roles": { - "get": { - "tags": [ - "core-identity" - ], - "summary": "List all roles with description, color, user count, permission names, and isadmin flag", - "description": "List all roles with description, color, user count, permission names, and isadmin flag\n\n**Auth:** jwt\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/users/roles -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - }, - "post": { - "tags": [ - "core-identity" - ], - "summary": "Create a role and assign permissions by name; 409 if rolename exists", - "description": "Create a role and assign permissions by name; 409 if rolename exists\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: rolename (required), description, color, permissions[names]\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/users/roles -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"rolename\":\"viewer\",\"permissions\":[\"assets.view\"]}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: rolename (required), description, color, permissions[names]" - } - } - } - } - } - }, - "/api/users/roles/{roleid}": { - "put": { - "tags": [ - "core-identity" - ], - "summary": "Update a role's description/color/permissions; admin role's permission set cannot be modified; audit-logged", - "description": "Update a role's description/color/permissions; admin role's permission set cannot be modified; audit-logged\n\n**Auth:** jwt + require_role admin\n\n**Params:** path: roleid; body: description, color, permissions[names] (permissions immutable on the admin role)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/users/roles/3 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"color\":\"#0057b8\",\"permissions\":[\"assets.view\",\"assets.edit\"]}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "roleid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: roleid; body: description, color, permissions[names] (permissions immutable on the admin role)" - } - } - } - } - }, - "delete": { - "tags": [ - "core-identity" - ], - "summary": "Delete a role; refuses for the admin role or any role still assigned to users", - "description": "Delete a role; refuses for the admin role or any role still assigned to users\n\n**Auth:** jwt + require_role admin\n\n**Params:** path: roleid\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/users/roles/3 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "roleid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, "/api/apitokens": { "get": { "tags": [ - "core-identity" + "core-apitokens" ], - "summary": "List the caller's own API tokens (never returns hashes or secrets); admins may list all", - "description": "List the caller's own API tokens (never returns hashes or secrets); admins may list all\n\n**Auth:** jwt\n\n**Params:** query: all=true (admin only, lists everyone's tokens with owner info)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/apitokens?all=true' -H \"Authorization: Bearer $TOK\"\n```", + "summary": "List the caller's own tokens. Admins may pass ?all=true for everyone's.", + "description": "List the caller's own tokens. Admins may pass ?all=true for everyone's.\n\n**Auth:** jwt\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/apitokens -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -925,7 +268,48 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "createddate": "2026-01-01T00:00:00.000000Z", + "displayprefix": "", + "expiresat": null, + "isactive": true, + "isexpired": false, + "lastusedat": null, + "name": "member token", + "resourcescopes": null, + "scopes": null, + "tokenid": 2, + "tokenprefix": "", + "userid": 2, + "username": "" + }, + { + "createddate": "2026-01-01T00:00:00.000000Z", + "displayprefix": "", + "expiresat": null, + "isactive": true, + "isexpired": false, + "lastusedat": null, + "name": "admin token", + "resourcescopes": null, + "scopes": null, + "tokenid": 1, + "tokenprefix": "", + "userid": 1, + "username": "" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -943,14 +327,25 @@ "403": { "description": "Authenticated, but not permitted." } - } + }, + "parameters": [ + { + "name": "all", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "false" + } + } + ] }, "post": { "tags": [ - "core-identity" + "core-apitokens" ], - "summary": "Create a personal API token for the caller; the full secret is returned once in this response and never again", - "description": "Create a personal API token for the caller; the full secret is returned once in this response and never again\n\n**Auth:** jwt + require_permission apitokens.create\n\n**Params:** body: name (required), expiresat (date/datetime), scopes[permission names, ceiling = owner's permissions], resourcescopes[resource names, plugin-defined]\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/apitokens -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"name\":\"import-script\",\"scopes\":[\"assets.edit\"],\"expiresat\":\"2026-12-31\"}'\n```", + "summary": "Create a token for the caller. Returns the full secret ONCE.", + "description": "Create a token for the caller. Returns the full secret ONCE.\n\n**Auth:** permission:apitokens.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/apitokens -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -961,7 +356,35 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "displayprefix": "", + "expiresat": null, + "isactive": true, + "isexpired": false, + "lastusedat": null, + "name": "direct token", + "resourcescopes": null, + "scopes": [ + "applications.create" + ], + "secret": "", + "tokenid": 1, + "tokenprefix": "", + "userid": 1, + "warning": "Save this token now. It will not be shown again. Store it somewhere safe." + }, + "message": "Token created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -987,7 +410,13 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: name (required), expiresat (date/datetime), scopes[permission names, ceiling = owner's permissions], resourcescopes[resource names, plugin-defined]" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "name": "direct token", + "scopes": [ + "applications.create" + ] } } } @@ -995,73 +424,12 @@ } }, "/api/apitokens/{tokenid}": { - "put": { - "tags": [ - "core-identity" - ], - "summary": "Rename, rescope, or (de)activate a token; scope ceiling is always the owner, even when an admin edits", - "description": "Rename, rescope, or (de)activate a token; scope ceiling is always the owner, even when an admin edits\n\n**Auth:** jwt + require_permission apitokens.create (own token, or any if admin)\n\n**Params:** path: tokenid; body: name, isactive (bool), scopes[names, validated against the token OWNER's permissions], resourcescopes[names]\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/apitokens/4 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"isactive\":false}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "tokenid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: tokenid; body: name, isactive (bool), scopes[names, validated against the token OWNER's permissions], resourcescopes[names]" - } - } - } - } - }, "delete": { "tags": [ - "core-identity" + "core-apitokens" ], - "summary": "Revoke (deactivate, not delete) a token; audit-logged", - "description": "Revoke (deactivate, not delete) a token; audit-logged\n\n**Auth:** jwt + require_permission apitokens.create (own token, or any if admin)\n\n**Params:** path: tokenid\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/apitokens/4 -H \"Authorization: Bearer $TOK\"\n```", + "summary": "Revoke (deactivate) a token. Own token, or any if admin.", + "description": "Revoke (deactivate) a token. Own token, or any if admin.\n\n**Auth:** permission:apitokens.create\n\n**Params:** path: tokenid (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/apitokens/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -1072,7 +440,17 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "message": "Token revoked", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -1100,362 +478,17 @@ "in": "path", "required": true, "schema": { - "type": "string" - } - } - ] - } - }, - "/api/setup/needs-admin": { - "get": { - "tags": [ - "core-identity" - ], - "summary": "Return {needsadmin: true} when zero users exist, so the login screen can offer first-run setup", - "description": "Return {needsadmin: true} when zero users exist, so the login screen can offer first-run setup\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/setup/needs-admin\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/setup/create-admin": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Bootstrap the very first admin account (creates the admin role if missing)", - "description": "Bootstrap the very first admin account (creates the admin role if missing)\n\n**Auth:** none (only functions while zero users exist; 403 afterwards)\n\n**Params:** body: username, email, password (all required)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/setup/create-admin -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"email\":\"admin@example.com\",\"password\":\"ChangeMe123\"}'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: username, email, password (all required)" - } - } - } - } - } - }, - "/api/setup/seed-reference": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Idempotently seed core reference data, permissions, and default settings (runs the flask seed CLI routines)", - "description": "Idempotently seed core reference data, permissions, and default settings (runs the flask seed CLI routines)\n\n**Auth:** jwt + require_role admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/setup/seed-reference -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none" - } - } - } - } - } - }, - "/api/setup/seed-starter": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Idempotently add a starter list of common hardware vendors (Dell, HP, Lenovo, ...)", - "description": "Idempotently add a starter list of common hardware vendors (Dell, HP, Lenovo, ...)\n\n**Auth:** jwt + require_role admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/setup/seed-starter -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none" - } - } - } - } - } - }, - "/api/setup/complete": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Set the setup_complete setting to true, marking the first-run wizard finished", - "description": "Set the setup_complete setting to true, marking the first-run wizard finished\n\n**Auth:** jwt + require_role admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/setup/complete -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none" - } - } - } - } - } - }, - "/api/settings": { - "get": { - "tags": [ - "core-identity" - ], - "summary": "List settings; unauthenticated callers see only the public allowlist (branding+map categories plus named bootstrap...", - "description": "List settings; unauthenticated callers see only the public allowlist (branding+map categories plus named bootstrap keys); secrets always masked as ********\n\n**Auth:** jwt-optional\n\n**Params:** query: category (filter)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/settings?category=branding'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-identity" - ], - "summary": "Create a new setting; 409 if the key exists", - "description": "Create a new setting; 409 if the key exists\n\n**Auth:** jwt + require_permission settings.edit\n\n**Params:** body: key (required), value, valuetype (default string), category (default general), description\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"key\":\"facility_name\",\"value\":\"West Jefferson\",\"category\":\"site\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: key (required), value, valuetype (default string), category (default general), description" - } - } - } - } - } - }, - "/api/settings/{key}": { - "get": { - "tags": [ - "core-identity" - ], - "summary": "Get one setting; non-public keys return 404 (not 403) to unauthenticated callers; secret values masked", - "description": "Get one setting; non-public keys return 404 (not 403) to unauthenticated callers; secret values masked\n\n**Auth:** jwt-optional\n\n**Params:** path: key\n\n**Example:**\n```\ncurl http://localhost:5001/api/settings/facility_name\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "key", - "in": "path", - "required": true, - "schema": { - "type": "string" + "type": "integer" } } ] }, "put": { "tags": [ - "core-identity" + "core-apitokens" ], - "summary": "Update a setting (upserts a plugin-scoped string row if the key is new); audit-logged with secrets masked; invalidates...", - "description": "Update a setting (upserts a plugin-scoped string row if the key is new); audit-logged with secrets masked; invalidates the 5-min settings cache\n\n**Auth:** jwt + require_permission settings.edit\n\n**Params:** path: key; body: value (required; bool coerced to 'true'/'false'; secret mask ******** means leave unchanged)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/settings/facility_name -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"value\":\"West Jefferson\"}'\n```", + "summary": "Rename, rescope, or deactivate a token. Own token, or any if admin.", + "description": "Rename, rescope, or deactivate a token. Own token, or any if admin.\n\n**Auth:** permission:apitokens.create\n\n**Params:** path: tokenid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/apitokens/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -1466,7 +499,33 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "displayprefix": "", + "expiresat": null, + "isactive": true, + "isexpired": false, + "lastusedat": null, + "name": "test token", + "resourcescopes": null, + "scopes": [ + "applications.edit" + ], + "tokenid": 1, + "tokenprefix": "", + "userid": 1 + }, + "message": "Token updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -1490,11 +549,11 @@ }, "parameters": [ { - "name": "key", + "name": "tokenid", "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } ], @@ -1505,177 +564,1160 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "path: key; body: value (required; bool coerced to 'true'/'false'; secret mask ******** means leave unchanged)" + "description": "path: tokenid (int); body: JSON (fields not yet documented)" + }, + "example": { + "scopes": [ + "applications.edit" + ] } } } } } }, - "/api/settings/seed": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Idempotently create any missing default settings (identifier toggles, search toggles, map, SMTP, SAML, etc.)", - "description": "Idempotently create any missing default settings (identifier toggles, search toggles, map, SMTP, SAML, etc.)\n\n**Auth:** jwt + require_permission settings.edit\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/seed -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none" - } - } - } - } - } - }, - "/api/settings/test-email": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Send a test email to verify SMTP config; always 200 with a sent flag, SMTP errors returned with credentials scrubbed", - "description": "Send a test email to verify SMTP config; always 200 with a sent flag, SMTP errors returned with credentials scrubbed\n\n**Auth:** jwt + require_permission settings.edit\n\n**Params:** body: to (optional; falls back to alert_recipients setting)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/test-email -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"to\":\"me@example.com\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: to (optional; falls back to alert_recipients setting)" - } - } - } - } - } - }, - "/api/settings/map-blueprint": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Upload a floor-map blueprint image to the instance maps dir and point map_blueprint_ at its served URL", - "description": "Upload a floor-map blueprint image to the instance maps dir and point map_blueprint_ at its served URL\n\n**Auth:** jwt + require_role admin\n\n**Params:** multipart/form-data: file (png/jpg/jpeg/gif/webp/svg), theme=light|dark\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/map-blueprint -H \"Authorization: Bearer $TOK\" -F 'file=@floor.png' -F 'theme=light'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "multipart/form-data: file (png/jpg/jpeg/gif/webp/svg), theme=light|dark" - } - } - } - } - } - }, - "/api/settings/map-blueprint/{filename}": { + "/api/applications": { "get": { "tags": [ - "core-identity" + "core-applications" ], - "summary": "Serve an uploaded floor-map blueprint image (public so kiosk dashboards can load it)", - "description": "Serve an uploaded floor-map blueprint image (public so kiosk dashboards can load it)\n\n**Auth:** none\n\n**Params:** path: filename\n\n**Example:**\n```\ncurl -O http://localhost:5001/api/settings/map-blueprint/blueprint-light.png\n```", + "summary": "List all applications.", + "description": "List all applications.\n\n**Auth:** permission:applications.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "appname", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "installable", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "showhidden", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "false" + } + } + ] + }, + "post": { + "tags": [ + "core-applications" + ], + "summary": "Create a new application.", + "description": "Create a new application.\n\n**Auth:** permission:applications.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/applications -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Before Revoke", + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": null, + "installersha256": null, + "installpath": null, + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "supportteamid": null, + "supportteamname": null, + "teamurl": null + }, + "message": "Application created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "appname": "No Backdate", + "createddate": "2026-01-01 00:00:00" + } + } + } + } + } + }, + "/api/applications/{app_id}": { + "delete": { + "tags": [ + "core-applications" + ], + "summary": "Permanently delete an application, its versions, and its uploads.", + "description": "Permanently delete an application, its versions, and its uploads.\n\n**Auth:** permission:applications.delete\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/applications/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "deactivatedpolicies": [ + "Required app: Doomed", + "Hand made push" + ], + "policyresult": "kept" + }, + "message": "Application deleted; deactivated 2 policy(ies) that referenced it: Required app: Doomed, Hand made push", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "This route reads a JSON body. The example is a request that worked." + }, + "example": { + "confirm": "OK" + } + } + } + } + }, + "get": { + "tags": [ + "core-applications" + ], + "summary": "Get a single application with details.", + "description": "Get a single application with details.\n\n**Auth:** permission:applications.view\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "TeamApp", + "contacts": [ + { + "name": "Alice", + "sso": "a01" + } + ], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": null, + "installedcount": 0, + "installersha256": null, + "installpath": null, + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "knowledgebase": [], + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "supportteamid": 1, + "supportteamname": "Controls", + "teamurl": "https://sn.example/controls", + "versions": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-applications" + ], + "summary": "Update an application.", + "description": "Update an application.\n\n**Auth:** permission:applications.edit\n\n**Params:** path: app_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/applications/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Test App", + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": null, + "installersha256": null, + "installpath": null, + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "policynote": "A configuration policy was created for this application, but it is not attached to any business policy and installs nothing until an admin adds it to one.", + "policyresult": "created", + "supportteamid": null, + "supportteamname": null, + "teamurl": null + }, + "message": "Application updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: app_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "ismonitorable": false, + "monitorlaunchpath": "", + "monitorprocessname": "" + } + } + } + } + } + }, + "/api/applications/{app_id}/image": { + "delete": { + "tags": [ + "core-applications" + ], + "summary": "Remove an application's image and clear the field.", + "description": "Remove an application's image and clear the field.\n\n**Auth:** permission:applications.edit\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/applications/1/image -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "core-applications" + ], + "summary": "Upload (or replace) an application's image.", + "description": "Upload (or replace) an application's image.\n\n**Auth:** permission:applications.edit\n\n**Params:** path: app_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/applications/1/image -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Test App", + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": "/api/applications/image/application-1.png", + "installersha256": null, + "installpath": null, + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "supportteamid": null, + "supportteamname": null, + "teamurl": null + }, + "message": "Application image uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: app_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/applications/{app_id}/installed": { + "get": { + "tags": [ + "core-applications" + ], + "summary": "List all computers that have this application installed.", + "description": "List all computers that have this application installed.\n\n**Auth:** permission:applications.view\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications/1/installed -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/applications/{app_id}/package": { + "delete": { + "tags": [ + "core-applications" + ], + "summary": "Remove an uploaded installer and clear the path it set.", + "description": "Remove an uploaded installer and clear the path it set.\n\n**Auth:** permission:applications.edit\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/applications/1/package -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Test App", + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": null, + "installersha256": null, + "installpath": "", + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "supportteamid": null, + "supportteamname": null, + "teamurl": null + }, + "message": "Installer removed", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "core-applications" + ], + "summary": "Upload (or replace) the installer for an application.", + "description": "Upload (or replace) the installer for an application.\n\n**Auth:** permission:applications.edit\n\n**Params:** path: app_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/applications/1/package -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Test App", + "appversionid": null, + "bytes": 130, + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "detectedversion": "", + "documentationpath": null, + "image": null, + "installersha256": "", + "installpath": "/api/applications/package/1/setup.exe", + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "sha256": "", + "supportteamid": null, + "supportteamname": null, + "teamurl": null, + "uploadedfilename": "setup.exe" + }, + "message": "Installer uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: app_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/applications/{app_id}/revert": { + "post": { + "tags": [ + "core-applications" + ], + "summary": "Strip an application back to a catalogue record.", + "description": "Strip an application back to a catalogue record.\n\n**Auth:** permission:applications.edit\n\n**Params:** path: app_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/applications/1/revert -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "appdescription": null, + "appid": 1, + "applicationlink": null, + "applicationnotes": null, + "appname": "Test App", + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "documentationpath": null, + "image": "/api/applications/image/application-1.png", + "installersha256": null, + "installpath": null, + "isactive": true, + "ishidden": false, + "isinstallable": false, + "islicenced": false, + "ismonitorable": false, + "isprinter": false, + "isrequired": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "monitorlaunchpath": null, + "monitorprocessname": null, + "supportteamid": null, + "supportteamname": null, + "teamurl": null + }, + "message": "Application reverted to record", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: app_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "confirm": "OK" + } + } + } + } + } + }, + "/api/applications/{app_id}/versions": { + "get": { + "tags": [ + "core-applications" + ], + "summary": "List all versions for an application.", + "description": "List all versions for an application.\n\n**Auth:** permission:applications.view\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications/1/versions -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "core-applications" + ], + "summary": "Create a new version for an application.", + "description": "Create a new version for an application.\n\n**Auth:** permission:applications.create\n\n**Params:** path: app_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/applications/1/versions -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "appid": 1, + "appversionid": 1, + "dateadded": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "notes": null, + "releasedate": null, + "sha256": "", + "silentargs": "/S /v/qn", + "version": "1.2.3" + }, + "message": "Version created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: app_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "sha256": "", + "silentargs": "/S /v/qn", + "version": "1.2.3" + } + } + } + } + } + }, + "/api/applications/image/{filename}": { + "get": { + "tags": [ + "core-applications" + ], + "summary": "Serve an uploaded application image.", + "description": "Serve an uploaded application image.\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** path: filename (path)\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications/image/example.png\n```", "security": [], "responses": { "200": { "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -1704,13 +1746,13 @@ ] } }, - "/api/settings/branding-logo": { - "post": { + "/api/applications/machines/{machine_id}": { + "get": { "tags": [ - "core-identity" + "core-applications" ], - "summary": "Upload a branding logo/favicon to the instance branding dir and set the matching branding setting...", - "description": "Upload a branding logo/favicon to the instance branding dir and set the matching branding setting (site_logo/qr_logo/badge_logo/site_favicon)\n\n**Auth:** jwt + require_role admin\n\n**Params:** multipart/form-data: file (map image types plus .ico), kind=site|qr|badge|favicon\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/branding-logo -H \"Authorization: Bearer $TOK\" -F 'file=@logo.svg' -F 'kind=site'\n```", + "summary": "List all applications installed on a computer.", + "description": "List all applications installed on a computer.\n\n**Auth:** permission:applications.view\n\n**Params:** path: machine_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications/machines/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -1721,7 +1763,29 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "appdescription": null, + "appid": 1, + "appname": "Test App", + "appversionid": null, + "computerid": 1, + "id": 1, + "installeddate": "2026-01-01T00:00:00.000000Z", + "installedversion": "1.2.3", + "isactive": true + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -1738,36 +1802,41 @@ }, "403": { "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." } }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "multipart/form-data: file (map image types plus .ico), kind=site|qr|badge|favicon" - } + "parameters": [ + { + "name": "machine_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" } } - } - } - }, - "/api/settings/branding/{filename}": { - "get": { + ] + }, + "post": { "tags": [ - "core-identity" + "core-applications" + ], + "summary": "Install an application on a computer.", + "description": "Install an application on a computer.\n\n**Auth:** permission:applications.create\n\n**Params:** path: machine_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/applications/machines/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } ], - "summary": "Serve an uploaded branding logo (public - kiosks and print pages read it)", - "description": "Serve an uploaded branding logo (public - kiosks and print pages read it)\n\n**Auth:** none\n\n**Params:** path: filename\n\n**Example:**\n```\ncurl -O http://localhost:5001/api/settings/branding/logo-site.svg\n```", - "security": [], "responses": { "200": { "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -1779,6 +1848,209 @@ } } }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "machine_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: machine_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/applications/machines/{machine_id}/{app_id}": { + "delete": { + "tags": [ + "core-applications" + ], + "summary": "Uninstall an application from a computer.", + "description": "Uninstall an application from a computer.\n\n**Auth:** permission:applications.delete\n\n**Params:** path: machine_id (int), app_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/applications/machines/1/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "machine_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-applications" + ], + "summary": "Update installed application (e.g., change version).", + "description": "Update installed application (e.g., change version).\n\n**Auth:** permission:applications.edit\n\n**Params:** path: machine_id (int), app_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/applications/machines/1/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "machine_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: machine_id (int), app_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/applications/package/{filename}": { + "get": { + "tags": [ + "core-applications" + ], + "summary": "Download an uploaded installer.", + "description": "Download an uploaded installer.\n\n**Auth:** permission:applications.download\n\n**Params:** path: filename (path)\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications/package/example.png -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, "404": { "description": "No such record." } @@ -1796,13 +2068,13 @@ ] } }, - "/api/settings/asset-import/script": { + "/api/assets": { "get": { "tags": [ - "core-identity" + "core-assets" ], - "summary": "Download Collect-ShopDB.ps1 with this site CSV path already embedded, so the collector script needs no hand editing...", - "description": "Download Collect-ShopDB.ps1 with this site CSV path already embedded, so the collector script needs no hand editing before it runs\n\n**Auth:** admin role\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/settings/asset-import/script -o Collect-ShopDB.ps1\n```", + "summary": "List all assets with filtering and pagination.", + "description": "List all assets with filtering and pagination.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -1813,7 +2085,57 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "assetid": 1, + "assetnumber": "A-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "machine", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "mill", + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "serialnumber": "", + "statusid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -1831,1085 +2153,188 @@ "403": { "description": "Authenticated, but not permitted." } - } - } - }, - "/api/settings/asset-import/templates": { - "get": { - "tags": [ - "core-identity" - ], - "summary": "List the importable CSV tables in the order they must be uploaded - foreign keys mean the order is not optional", - "description": "List the importable CSV tables in the order they must be uploaded - foreign keys mean the order is not optional\n\n**Auth:** admin role\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/settings/asset-import/templates\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/settings/asset-import/templates/{tablename}": { - "get": { - "tags": [ - "core-identity" - ], - "summary": "Download a generated CSV template for one importable table, with its real column names as the header row", - "description": "Download a generated CSV template for one importable table, with its real column names as the header row\n\n**Auth:** admin role\n\n**Params:** path: tablename\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/settings/asset-import/templates/assets -o assets.csv\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } }, "parameters": [ { - "name": "tablename", - "in": "path", - "required": true, + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "businessunit_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "businessunitid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "dir", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "asc" + } + }, + { + "name": "include_type_data", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "false" + } + }, + { + "name": "location_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "locationid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, "schema": { "type": "string" } - } - ] - } - }, - "/api/settings/asset-import/import": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Validate uploaded CSVs and, only when commit=true, load them. The default is a dry run that reports what WOULD change...", - "description": "Validate uploaded CSVs and, only when commit=true, load them. The default is a dry run that reports what WOULD change; a committing run writes an AuditLog row with the reconciled count\n\n**Auth:** admin role\n\n**Params:** multipart: one file per table; form: commit (false = validate only)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -F \"assets=@assets.csv\" -F \"commit=true\" http://localhost:5001/api/settings/asset-import/import\n```", - "security": [ + }, { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "assetnumber" } }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "multipart: one file per table; form: commit (false = validate only)" - } - } - } - } - } - }, - "/api/settings/asset-import/winc/preview": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Dry run a WINC machine-adoption import. Writes nothing; returns the plan, a summary, the needs-create groups an admin...", - "description": "Dry run a WINC machine-adoption import. Writes nothing; returns the plan, a summary, the needs-create groups an admin must acknowledge, and the planhash that authorises the matching commit\n\n**Auth:** jwt + require_role admin\n\n**Params:** multipart/form-data: files (one WINC/Maximo CSV export)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/asset-import/winc/preview -H \"Authorization: Bearer $TOK\" -F files=@winc.csv\n```", - "security": [ { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } + "name": "status_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" } }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "multipart/form-data: files (one WINC/Maximo CSV export)" - } - } - } - } - } - }, - "/api/settings/asset-import/winc/commit": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Apply a previewed WINC plan in one transaction, journalled so it can be undone. planhash is required and must match a...", - "description": "Apply a previewed WINC plan in one transaction, journalled so it can be undone. planhash is required and must match a freshly rebuilt plan: without it the drift check compared a packet against the plan it was built from and could never fail, so an admin could approve one plan and apply another\n\n**Auth:** jwt + require_role admin\n\n**Params:** multipart/form-data: files (the same export); form: planhash (REQUIRED, from the preview), acknowledge (comma-separated needs-create group names)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/asset-import/winc/commit -H \"Authorization: Bearer $TOK\" -F files=@winc.csv -F planhash=$HASH -F acknowledge=businessunit\n```", - "security": [ { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } + "name": "statusid", + "in": "query", + "required": false, + "schema": { + "type": "integer" } }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "multipart/form-data: files (the same export); form: planhash (REQUIRED, from the preview), acknowledge (comma-separated needs-create group names)" - } - } - } - } - } - }, - "/api/settings/asset-import/winc/batches": { - "get": { - "tags": [ - "core-identity" - ], - "summary": "List applied and undone import batches, newest first, with kind, row count, who applied it and when", - "description": "List applied and undone import batches, newest first, with kind, row count, who applied it and when\n\n**Auth:** jwt + require_role admin\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/settings/asset-import/winc/batches -H \"Authorization: Bearer $TOK\"\n```", - "security": [ { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "string" } }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/settings/asset-import/winc/undo": { - "post": { - "tags": [ - "core-identity" - ], - "summary": "Reverse an applied batch by replaying its journal backwards. Conservative: a cell is put back only if it still holds...", - "description": "Reverse an applied batch by replaying its journal backwards. Conservative: a cell is put back only if it still holds what the import wrote, and a created row is deleted only when nothing outside the batch references it\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: batchid (required)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/asset-import/winc/undo -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"batchid\":\"...\"}'\n```", - "security": [ { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } + "name": "type_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" } }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: batchid (required)" - } - } - } - } - } - }, - "/api/reports": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "List all available reports (6 core cards plus cards contributed by enabled plugins via the get_reports hook)", - "description": "List all available reports (6 core cards plus cards contributed by enabled plugins via the get_reports hook)\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/reports\n```", - "security": [ - {}, { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/reports/machines-by-type": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Machine count grouped by machine type (dualpath secondary bays collapsed when the site setting is on); 404 if machines...", - "description": "Machine count grouped by machine type (dualpath secondary bays collapsed when the site setting is on); 404 if machines plugin absent\n\n**Auth:** jwt-optional\n\n**Params:** businessunitid (int filter), format=json|csv\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/reports/machines-by-type?businessunitid=2&format=csv'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/reports/assets-by-status": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Asset count grouped by status with status colors", - "description": "Asset count grouped by status with status colors\n\n**Auth:** jwt-optional\n\n**Params:** assettypeid, businessunitid, format=json|csv\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/reports/assets-by-status?assettypeid=1'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/reports/kb-popularity": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Most-clicked knowledge base articles; 503 if knowledgebase plugin absent", - "description": "Most-clicked knowledge base articles; 503 if knowledgebase plugin absent\n\n**Auth:** jwt-optional\n\n**Params:** limit (default 20, max 100), format=json|csv\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/reports/kb-popularity?limit=10'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/reports/software-compliance": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Required applications vs installed per PC with compliance rate and up to 100 non-compliant PCs per app; 503 if...", - "description": "Required applications vs installed per PC with compliance rate and up to 100 non-compliant PCs per app; 503 if computers plugin absent\n\n**Auth:** jwt-optional\n\n**Params:** appid (filter to one app), format=json|csv\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/reports/software-compliance?appid=5'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/reports/asset-inventory": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Complete asset inventory summary broken down by type, status, and location", - "description": "Complete asset inventory summary broken down by type, status, and location\n\n**Auth:** jwt-optional\n\n**Params:** businessunitid, locationid, format=json|csv\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/reports/asset-inventory?locationid=3&format=csv'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/reports/pc-relationships": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "PC-to-shop-floor-machine relationships (machine number, vendor, model, hostname, IP) matched in both edge directions...", - "description": "PC-to-shop-floor-machine relationships (machine number, vendor, model, hostname, IP) matched in both edge directions via raw SQL UNION\n\n**Auth:** jwt-optional\n\n**Params:** format=json|csv\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/reports/pc-relationships?format=csv'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/reports/email": { - "post": { - "tags": [ - "core-platform" - ], - "summary": "Email a report's rows as an HTML table on demand; recipient defaults to the site Alert Recipients setting; intended...", - "description": "Email a report's rows as an HTML table on demand; recipient defaults to the site Alert Recipients setting; intended cron target via a PAT scoped to reports.export\n\n**Auth:** jwt + permission:reports.export\n\n**Params:** body: subject, columns [{key,label}], rows [{..}], intro (optional), to (optional email)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/reports/email -H \"Authorization: Bearer $PAT\" -H 'Content-Type: application/json' -d '{\"subject\":\"Warranty Report\",\"columns\":[{\"key\":\"vendor\",\"label\":\"Vendor\"}],\"rows\":[{\"vendor\":\"Haas\"}],\"to\":\"ops@example.com\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: subject, columns [{key,label}], rows [{..}], intro (optional), to (optional email)" - } - } - } - } - } - }, - "/api/search": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Global search across assets (including the gaugelabreference and maintenancereference identifiers), applications, KB...", - "description": "Global search across assets (including the gaugelabreference and maintenancereference identifiers), applications, KB, employees, USB devices, printed items, notifications, custom fields, hostnames, IPs/subnets, vendor/model/type; ServiceNOW ticket prefixes and smart redirects; results capped at 50, types filterable via search__enabled settings. Retired (isactive=0) rows are excluded everywhere, including KB articles whose topic application is retired\n\n**Auth:** jwt-optional\n\n**Params:** q (required, 2-200 chars)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/search?q=WKSTN0042'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/search/suggest": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "A better spelling for a query that found nothing, or null. Its own endpoint so any list page can ask after rendering no...", - "description": "A better spelling for a query that found nothing, or null. Its own endpoint so any list page can ask after rendering no rows, rather than a suggestion key added to every search route. The vocabulary is harvested from searched columns (asset numbers and names, vendors, models, applications, asset types) and cached, so site-specific words are correctable; digits must match exactly, which keeps CSF16 from being 'corrected' to CSF17. Suggests, never rewrites\n\n**Auth:** jwt-optional\n\n**Params:** q (required, 2-200 chars)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/search/suggest?q=keyecne'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/dashboard": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Dashboard summary: asset counts by type (machines/PCs/network/printers/measuring tools, dualpath-collapsed), counts by...", - "description": "Dashboard summary: asset counts by type (machines/PCs/network/printers/measuring tools, dualpath-collapsed), counts by status, 10 most recent assets\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/dashboard/summary": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Alias route for the same dashboard summary handler as GET /api/dashboard", - "description": "Alias route for the same dashboard summary handler as GET /api/dashboard\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/summary\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/dashboard/stats": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Asset counts grouped by every asset type with display category labels", - "description": "Asset counts grouped by every asset type with display category labels\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/stats\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/dashboard/navigation": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Sidebar navigation items: core entries merged with get_navigation_items from every enabled plugin, sorted by position", - "description": "Sidebar navigation items: core entries merged with get_navigation_items from every enabled plugin, sorted by position\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/navigation\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/dashboard/capabilities": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Booleans describing what THIS caller may reach, so the app chrome can avoid drawing controls that would only answer...", - "description": "Booleans describing what THIS caller may reach, so the app chrome can avoid drawing controls that would only answer 401; cansearch reports whether the caller holds assets.view, which a signed-out visitor does when the guest role has been granted it\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/capabilities\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/dashboard/widgets": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Dashboard widget definitions aggregated from enabled plugins (get_dashboard_widgets hook), sorted by position", - "description": "Dashboard widget definitions aggregated from enabled plugins (get_dashboard_widgets hook), sorted by position\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/widgets\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/dashboard/health": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Health check: runs SELECT 1 against the DB, returns ok/degraded plus app version", - "description": "Health check: runs SELECT 1 against the DB, returns ok/degraded plus app version\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/health\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/dashboarddefaults/visitor-location": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Resolve the business unit for a kiosk/lobby display by FQDN first then IP (caller IP used when ipaddress omitted); null...", - "description": "Resolve the business unit for a kiosk/lobby display by FQDN first then IP (caller IP used when ipaddress omitted); null businessunitid when unmapped\n\n**Auth:** none\n\n**Params:** fqdn (optional), ipaddress (optional, defaults to client IP)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/dashboarddefaults/visitor-location?fqdn=display01.example.net'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/dashboarddefaults/display-role": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Resolve what a display PC should show at boot: role (dashboard/lobby/partskiosk), frontend path, and business unit...", - "description": "Resolve what a display PC should show at boot: role (dashboard/lobby/partskiosk), frontend path, and business unit; null role when unmapped\n\n**Auth:** none\n\n**Params:** fqdn (optional), ipaddress (optional, defaults to client IP)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/dashboarddefaults/display-role?ipaddress=10.1.2.3'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/dashboarddefaults": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "List all active display-to-business-unit mappings", - "description": "List all active display-to-business-unit mappings\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboarddefaults\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-platform" - ], - "summary": "Create a display mapping; requires fqdn or ipaddress, dashboard role requires businessunitid, 409 on duplicate fqdn/IP...", - "description": "Create a display mapping; requires fqdn or ipaddress, dashboard role requires businessunitid, 409 on duplicate fqdn/IP; audit-logged\n\n**Auth:** jwt + role:admin\n\n**Params:** body: fqdn, ipaddress, displayrole (dashboard|lobby|partskiosk, default dashboard), businessunitid, description\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/dashboarddefaults -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"ipaddress\":\"10.1.2.3\",\"displayrole\":\"dashboard\",\"businessunitid\":2}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: fqdn, ipaddress, displayrole (dashboard|lobby|partskiosk, default dashboard), businessunitid, description" - } - } - } - } - } - }, - "/api/dashboarddefaults/{default_id}": { - "put": { - "tags": [ - "core-platform" - ], - "summary": "Update a display mapping; non-dashboard roles get businessunitid nulled, dashboard role must keep one", - "description": "Update a display mapping; non-dashboard roles get businessunitid nulled, dashboard role must keep one\n\n**Auth:** jwt + role:admin\n\n**Params:** body: any of fqdn, ipaddress, displayrole, businessunitid, description\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/dashboarddefaults/7 -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"displayrole\":\"lobby\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "default_id", - "in": "path", - "required": true, + "name": "typeid", + "in": "query", + "required": false, "schema": { "type": "integer" } } + ] + }, + "post": { + "tags": [ + "core-assets" ], + "summary": "Create a new asset.", + "description": "Create a new asset.\n\n**Auth:** permission:assets.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/assets -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, "requestBody": { "required": true, "content": { @@ -2917,18 +2342,20 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: any of fqdn, ipaddress, displayrole, businessunitid, description" + "description": "body: JSON (fields not yet documented)" } } } } - }, + } + }, + "/api/assets/{asset_id}": { "delete": { "tags": [ - "core-platform" + "core-assets" ], - "summary": "Soft-delete (deactivate) a display mapping", - "description": "Soft-delete (deactivate) a display mapping\n\n**Auth:** jwt + role:admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/dashboarddefaults/7 -H \"Authorization: Bearer $JWT\"\n```", + "summary": "Delete (soft delete) an asset.", + "description": "Delete (soft delete) an asset.\n\n**Auth:** permission:assets.delete\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/assets/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -2939,7 +2366,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -2963,7 +2392,183 @@ }, "parameters": [ { - "name": "default_id", + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "core-assets" + ], + "summary": "Get a single asset with full details.", + "description": "Get a single asset with full details.\n\n**Auth:** permission:assets.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "include_type_data", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + } + ] + }, + "put": { + "tags": [ + "core-assets" + ], + "summary": "Update an asset.", + "description": "Update an asset.\n\n**Auth:** permission:assets.edit\n\n**Params:** path: asset_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/assets/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: asset_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/assets/{asset_id}/communications": { + "get": { + "tags": [ + "core-assets" + ], + "summary": "Get all communications for an asset.", + "description": "Get all communications for an asset.\n\n**Auth:** permission:assets.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/1/communications -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", "in": "path", "required": true, "schema": { @@ -2973,16 +2578,16 @@ ] } }, - "/api/collector/{pluginname}": { - "post": { + "/api/assets/{asset_id}/relationships": { + "get": { "tags": [ - "core-platform" + "core-assets" ], - "summary": "Generic collector ingest (ADR-006): schema-validated identity field, idempotent upsert via the plugin's...", - "description": "Generic collector ingest (ADR-006): schema-validated identity field, idempotent upsert via the plugin's apply_collector_payload; audit-logged; 404 when no collector registered for the plugin\n\n**Auth:** api-key (X-API-Key: per-plugin COLLECTOR_API_KEY_ or shared COLLECTOR_API_KEY, or a collector.ingest-scoped managed PAT via Bearer/X-API-Key)\n\n**Params:** body: JSON payload whose schema identityfield (e.g. hostname) is required; rest is plugin-defined\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/computers -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"serialnumber\":\"ABC123\"}'\n```", + "summary": "Get all relationships for an asset.", + "description": "Get all relationships for an asset.\n\n**Auth:** permission:assets.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/1/relationships -H \"Authorization: Bearer $TOK\"\n```", "security": [ { - "apiKeyAuth": [] + "bearerAuth": [] } ], "responses": { @@ -2990,7 +2595,66 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "incoming": [ + { + "createddate": "2026-01-01T00:00:00.000000Z", + "inheritsposition": true, + "isactive": true, + "isdirectional": true, + "label": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "relationshipid": 1, + "relationshiptypeid": 1, + "relationshiptypename": "controls", + "sourceasset": { + "assetid": 1, + "assetnumber": "PC-1", + "assettypecolor": null, + "assettypeid": 2, + "assettypename": "computer", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "serialnumber": "", + "statusid": 1 + }, + "sourceassetid": 1, + "targetassetid": 2 + } + ], + "outgoing": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -3014,35 +2678,23 @@ }, "parameters": [ { - "name": "pluginname", + "name": "asset_id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: JSON payload whose schema identityfield (e.g. hostname) is required; rest is plugin-defined" - } - } - } - } + ] } }, - "/api/collector/_schemas": { + "/api/assets/lookup/{assetnumber}": { "get": { "tags": [ - "core-platform" + "core-assets" ], - "summary": "List collector schemas for all enabled plugins that accept collector input", - "description": "List collector schemas for all enabled plugins that accept collector input\n\n**Auth:** jwt\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/collector/_schemas -H \"Authorization: Bearer $JWT\"\n```", + "summary": "Look up an asset by its asset number.", + "description": "Look up an asset by its asset number.\n\n**Auth:** permission:assets.view\n\n**Params:** path: assetnumber (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/lookup/x -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -3053,7 +2705,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -3070,220 +2724,33 @@ }, "403": { "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/collector/pc": { - "post": { - "tags": [ - "core-platform" - ], - "summary": "Legacy computers-specific ingest: update one PC matched by hostname (or asset number) - lastreporteddate, lastboottime...", - "description": "Legacy computers-specific ingest: update one PC matched by hostname (or asset number) - lastreporteddate, lastboottime, loggedinuser, serialnumber\n\n**Auth:** api-key\n\n**Params:** body: hostname (required), lastboottime (ISO), currentuser, serialnumber\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/pc -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"currentuser\":\"212345678\"}'\n```", - "security": [ - { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." + "404": { + "description": "No such record." } }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: hostname (required), lastboottime (ISO), currentuser, serialnumber" - } - } - } - } - } - }, - "/api/collector/apps": { - "post": { - "tags": [ - "core-platform" - ], - "summary": "Update installed applications for one PC; only apps already in the Application table are tracked, others skipped...", - "description": "Update installed applications for one PC; only apps already in the Application table are tracked, others skipped; returns created/updated/skipped counts\n\n**Auth:** api-key\n\n**Params:** body: hostname (required), apps [{appname, version}] (required)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/apps -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"apps\":[{\"appname\":\"PC-DMIS\",\"version\":\"2023.2\"}]}'\n```", - "security": [ + "parameters": [ { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: hostname (required), apps [{appname, version}] (required)" - } + "name": "assetnumber", + "in": "path", + "required": true, + "schema": { + "type": "string" } } - } + ] } }, - "/api/collector/heartbeat": { - "post": { - "tags": [ - "core-platform" - ], - "summary": "Record PC online heartbeat (single hostname or batch); stamps lastreporteddate, returns updated count and notfound list", - "description": "Record PC online heartbeat (single hostname or batch); stamps lastreporteddate, returns updated count and notfound list\n\n**Auth:** api-key\n\n**Params:** body: hostname (string) or hostnames (array)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/heartbeat -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"hostnames\":[\"pc1\",\"pc2\"]}'\n```", - "security": [ - { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: hostname (string) or hostnames (array)" - } - } - } - } - } - }, - "/api/collector/bulk": { - "post": { - "tags": [ - "core-platform" - ], - "summary": "Bulk update many PCs in one call (lastreporteddate, currentuser, lastboottime per entry); returns...", - "description": "Bulk update many PCs in one call (lastreporteddate, currentuser, lastboottime per entry); returns updated/notfound/errors\n\n**Auth:** api-key\n\n**Params:** body: pcs [{hostname (required), currentuser, lastboottime}]\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/bulk -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"pcs\":[{\"hostname\":\"pc1\",\"currentuser\":\"212345678\"}]}'\n```", - "security": [ - { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: pcs [{hostname (required), currentuser, lastboottime}]" - } - } - } - } - } - }, - "/api/collector/status": { + "/api/assets/map": { "get": { "tags": [ - "core-platform" + "core-assets" ], - "summary": "Collector API liveness/credential check; returns timestamp and the collector endpoint list", - "description": "Collector API liveness/credential check; returns timestamp and the collector endpoint list\n\n**Auth:** api-key\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/collector/status -H \"X-API-Key: $KEY\"\n```", + "summary": "Get all assets with map positions for unified floor map display.", + "description": "Get all assets with map positions for unified floor map display.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/map -H \"Authorization: Bearer $TOK\"\n```", "security": [ { - "apiKeyAuth": [] + "bearerAuth": [] } ], "responses": { @@ -3291,7 +2758,116 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assets": [ + { + "assetid": 1, + "assetnumber": "2007", + "assettype": "machine", + "assettypeid": 1, + "businessunit": null, + "businessunitid": null, + "displayname": "", + "dualpathpartner": null, + "levelid": null, + "location": null, + "locationid": null, + "mapx": 100, + "mapy": 100, + "name": null, + "primaryip": null, + "serialnumber": "", + "status": null, + "statuscolor": null, + "statusid": 1, + "typedata": { + "assetid": 1, + "controllermodelid": null, + "controllervendorid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "islocationonly": false, + "lastmaintenancedate": null, + "machineid": 1, + "machinetypeid": null, + "maintenanceintervaldays": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "nextmaintenancedate": null, + "requiresmanualconfig": false, + "vendorid": null + } + }, + { + "assetid": 2, + "assetnumber": "2008", + "assettype": "machine", + "assettypeid": 1, + "businessunit": null, + "businessunitid": null, + "displayname": "", + "dualpathpartner": null, + "levelid": null, + "location": null, + "locationid": null, + "mapx": 110, + "mapy": 110, + "name": null, + "primaryip": null, + "serialnumber": "", + "status": null, + "statuscolor": null, + "statusid": 1, + "typedata": { + "assetid": 2, + "controllermodelid": null, + "controllervendorid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "islocationonly": false, + "lastmaintenancedate": null, + "machineid": 2, + "machinetypeid": null, + "maintenanceintervaldays": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "nextmaintenancedate": null, + "requiresmanualconfig": false, + "vendorid": null + } + } + ], + "filters": { + "assettypes": [ + { + "assettype": "machine", + "assettypeid": 1, + "color": null, + "icon": null + } + ], + "businessunits": [], + "locations": [], + "statuses": [], + "subtypes": { + "Computer": [], + "Machine": [], + "Network Device": [], + "Printer": [] + } + }, + "total": 2 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -3309,16 +2885,1048 @@ "403": { "description": "Authenticated, but not permitted." } + }, + "parameters": [ + { + "name": "assettype", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "businessunitid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "locationid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "statusid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "subtype", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/assets/relationships": { + "post": { + "tags": [ + "core-assets" + ], + "summary": "Create a relationship between two assets.", + "description": "Create a relationship between two assets.\n\n**Auth:** permission:assets.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/assets/relationships -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "inheritsposition": true, + "isactive": true, + "label": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "propagated": [ + { + "createddate": "2026-01-01T00:00:00.000000Z", + "inheritsposition": true, + "isactive": true, + "label": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "relationshipid": 3, + "relationshiptypeid": 1, + "sourceassetid": 1, + "targetassetid": 3 + } + ], + "propagatedcount": 1, + "relationshipid": 2, + "relationshiptypeid": 1, + "sourceassetid": 1, + "targetassetid": 2 + }, + "message": "Relationship created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "relationshiptypeid": 1, + "sourceassetid": 1, + "targetassetid": 2 + } + } + } + } + } + }, + "/api/assets/relationships/{rel_id}": { + "delete": { + "tags": [ + "core-assets" + ], + "summary": "Delete an asset relationship.", + "description": "Delete an asset relationship.\n\n**Auth:** permission:assets.delete\n\n**Params:** path: rel_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/assets/relationships/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "message": "Relationship deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "rel_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/assets/relationshiptypes": { + "get": { + "tags": [ + "core-assets" + ], + "summary": "List all asset relationship types.", + "description": "List all asset relationship types.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/relationshiptypes -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "color": null, + "description": null, + "isdirectional": false, + "propagatesthrough": [], + "relationshiptype": "WiFi", + "relationshiptypeid": 1 + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + }, + "post": { + "tags": [ + "core-assets" + ], + "summary": "Create a new asset relationship type.", + "description": "Create a new asset relationship type.\n\n**Auth:** permission:assets.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/assets/relationshiptypes -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "color": null, + "description": null, + "isdirectional": true, + "propagatesthrough": [], + "relationshiptype": "Backup For", + "relationshiptypeid": 1 + }, + "message": "Relationship type created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "isdirectional": false, + "relationshiptype": "Dualpath" + } + } + } + } + } + }, + "/api/assets/relationshiptypes/{type_id}": { + "delete": { + "tags": [ + "core-assets" + ], + "summary": "Delete a relationship type. Refused if any relationship still uses it.", + "description": "Delete a relationship type. Refused if any relationship still uses it.\n\n**Auth:** permission:assets.delete\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/assets/relationshiptypes/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-assets" + ], + "summary": "Update a relationship type.", + "description": "Update a relationship type.\n\n**Auth:** permission:assets.edit\n\n**Params:** path: type_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/assets/relationshiptypes/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "color": null, + "description": null, + "isdirectional": false, + "propagatesthrough": [], + "relationshiptype": "USB", + "relationshiptypeid": 1 + }, + "message": "Relationship type updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: type_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "isdirectional": false + } + } + } + } + } + }, + "/api/assets/statuses": { + "get": { + "tags": [ + "core-assets" + ], + "summary": "List all asset statuses.", + "description": "List all asset statuses.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/statuses -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "core-assets" + ], + "summary": "Create a new asset status.", + "description": "Create a new asset status.\n\n**Auth:** permission:assets.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/assets/statuses -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/assets/statuses/{status_id}": { + "delete": { + "tags": [ + "core-assets" + ], + "summary": "Delete an asset status. Refused if any asset still uses it.", + "description": "Delete an asset status. Refused if any asset still uses it.\n\n**Auth:** permission:assets.delete\n\n**Params:** path: status_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/assets/statuses/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "status_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "core-assets" + ], + "summary": "Get a single asset status.", + "description": "Get a single asset status.\n\n**Auth:** permission:assets.view\n\n**Params:** path: status_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/statuses/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "status_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-assets" + ], + "summary": "Update an asset status.", + "description": "Update an asset status.\n\n**Auth:** permission:assets.edit\n\n**Params:** path: status_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/assets/statuses/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "status_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: status_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/assets/types": { + "get": { + "tags": [ + "core-assets" + ], + "summary": "List all asset types.", + "description": "List all asset types.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/types -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "core-assets" + ], + "summary": "Create a new asset type.", + "description": "Create a new asset type.\n\n**Auth:** permission:assets.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/assets/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/assets/types/{type_id}": { + "get": { + "tags": [ + "core-assets" + ], + "summary": "Get a single asset type.", + "description": "Get a single asset type.\n\n**Auth:** permission:assets.view\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/types/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-assets" + ], + "summary": "Update an asset type's display fields (color/icon/description). The name, plugin, and table are structural and not...", + "description": "Update an asset type's display fields (color/icon/description). The name, plugin, and table are structural and not editable here.\n\n**Auth:** permission:assets.edit\n\n**Params:** path: type_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/assets/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: type_id (int); body: JSON (fields not yet documented)" + } + } + } } } }, "/api/auditlogs": { "get": { "tags": [ - "core-platform" + "core-auditlogs" ], - "summary": "List audit logs with filtering and pagination, newest first; rows enriched with best-effort SSO-to-full-name resolution", - "description": "List audit logs with filtering and pagination, newest first; rows enriched with best-effort SSO-to-full-name resolution\n\n**Auth:** jwt + permission:audit.view\n\n**Params:** page (default 1), perpage (default 50, max 200), action (created|updated|deleted), entitytype, userid (int), search (entityname/username ilike), from_date, to_date (ISO)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/auditlogs?action=deleted&perpage=100' -H \"Authorization: Bearer $JWT\"\n```", + "summary": "List audit logs with filtering and pagination.", + "description": "List audit logs with filtering and pagination.\n\n**Auth:** permission:audit.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/auditlogs -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -3329,7 +3937,25 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 50, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -3347,16 +3973,84 @@ "403": { "description": "Authenticated, but not permitted." } - } + }, + "parameters": [ + { + "name": "action", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "entitytype", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "from_date", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "to_date", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "userid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] } }, "/api/auditlogs/entity/{entitytype}/{entityid}": { "get": { "tags": [ - "core-platform" + "core-auditlogs" ], - "summary": "Full audit history for one entity, newest first", - "description": "Full audit history for one entity, newest first\n\n**Auth:** jwt + permission:audit.view\n\n**Params:** path only\n\n**Example:**\n```\ncurl http://localhost:5001/api/auditlogs/entity/Asset/42 -H \"Authorization: Bearer $JWT\"\n```", + "summary": "Get audit history for a specific entity.", + "description": "Get audit history for a specific entity.\n\n**Auth:** permission:audit.view\n\n**Params:** path: entitytype (string), entityid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/auditlogs/entity/x/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -3367,7 +4061,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -3409,89 +4105,13 @@ ] } }, - "/api/auditlogs/stats": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Audit statistics: counts by action and entity type, last-7-days activity count, top 5 most active users", - "description": "Audit statistics: counts by action and entity type, last-7-days activity count, top 5 most active users\n\n**Auth:** jwt + permission:audit.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/auditlogs/stats -H \"Authorization: Bearer $JWT\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/auditlogs/retention": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Audit log size (total entries, oldest/newest timestamps) and, when a retention period is configured, how many entries a...", - "description": "Audit log size (total entries, oldest/newest timestamps) and, when a retention period is configured, how many entries a purge would delete. Drives the Site & Facility retention card so the count is seen before anything is deleted\n\n**Auth:** jwt + audit.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/auditlogs/retention -H \"Authorization: Bearer $TOKEN\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, "/api/auditlogs/purge": { "post": { "tags": [ - "core-platform" + "core-auditlogs" ], - "summary": "Delete audit entries older than the retention window, in committed batches. Requires audit.purge, not merely...", - "description": "Delete audit entries older than the retention window, in committed batches. Requires audit.purge, not merely audit.view. Batch-capped so the request cannot outlive a proxy timeout; an incomplete run returns complete=false with the number still remaining rather than reporting a finished job. Refuses with 400 when no retention period is configured\n\n**Auth:** jwt + audit.purge\n\n**Params:** body: days (optional, defaults to the audit_retentiondays setting), dryrun (optional bool)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/auditlogs/purge -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"days\":365,\"dryrun\":true}'\n```", + "summary": "Delete entries older than the retention window.", + "description": "Delete entries older than the retention window.\n\n**Auth:** permission:audit.purge\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/auditlogs/purge -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -3502,7 +4122,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -3528,7 +4150,5518 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: days (optional, defaults to the audit_retentiondays setting), dryrun (optional bool)" + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/auditlogs/retention": { + "get": { + "tags": [ + "core-auditlogs" + ], + "summary": "Size of the log and what the configured retention would remove.", + "description": "Size of the log and what the configured retention would remove.\n\n**Auth:** permission:audit.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/auditlogs/retention -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "expired": 0, + "newest": "2026-01-01T00:00:00.000000", + "oldest": "2026-01-01T00:00:00.000000", + "retentiondays": 30, + "total": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/auditlogs/stats": { + "get": { + "tags": [ + "core-auditlogs" + ], + "summary": "Get audit log statistics.", + "description": "Get audit log statistics.\n\n**Auth:** permission:audit.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/auditlogs/stats -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "actions": {}, + "activeUsers": [], + "entities": {}, + "recentCount": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/auth/change-password": { + "post": { + "tags": [ + "core-auth" + ], + "summary": "Change the authenticated user's own password.", + "description": "Change the authenticated user's own password.\n\n**Auth:** jwt\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/auth/change-password -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "mustchangepassword": "" + }, + "message": "Password changed", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "current_password": "", + "new_password": "" + } + } + } + } + } + }, + "/api/auth/login": { + "post": { + "tags": [ + "core-auth" + ], + "summary": "Authenticate user and return JWT tokens.", + "description": "Authenticate user and return JWT tokens.\n\n**Auth:** none (the login surface itself; rate limited, 5-strike lockout)\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/auth/login -H 'Content-Type: application/json' -d '{}'\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "access_token": "", + "expires_in": 3600, + "refresh_token": "", + "token_type": "", + "user": { + "email": "", + "firstname": "", + "lastname": "", + "mustchangepassword": "", + "permissions": [ + "assets.view", + "assets.create", + "assets.edit", + "" + ], + "roles": [ + "admin" + ], + "userid": 1, + "username": "" + } + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "password": "", + "username": "" + } + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "tags": [ + "core-auth" + ], + "summary": "Logout user (for frontend token cleanup).", + "description": "Logout user (for frontend token cleanup).\n\n**Auth:** jwt\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/auth/logout -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/auth/me": { + "get": { + "tags": [ + "core-auth" + ], + "summary": "Get current authenticated user info.", + "description": "Get current authenticated user info.\n\n**Auth:** jwt\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/auth/me -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "email": "", + "firstname": "", + "lastname": "", + "mustchangepassword": "", + "permissions": [ + "assets.view", + "assets.create", + "assets.edit", + "" + ], + "roles": [ + "admin" + ], + "userid": 1, + "username": "" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/auth/refresh": { + "post": { + "tags": [ + "core-auth" + ], + "summary": "Refresh access token using refresh token.", + "description": "Refresh access token using refresh token.\n\n**Auth:** jwt (refresh token)\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/auth/refresh -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/businessunits": { + "get": { + "tags": [ + "core-businessunits" + ], + "summary": "List all business units.", + "description": "List all business units.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/businessunits -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "businessunit", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "tags": [ + "core-businessunits" + ], + "summary": "Create a new business unit.", + "description": "Create a new business unit.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/businessunits -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/businessunits/{bu_id}": { + "delete": { + "tags": [ + "core-businessunits" + ], + "summary": "Delete (deactivate) a business unit.", + "description": "Delete (deactivate) a business unit.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: bu_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/businessunits/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "bu_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "core-businessunits" + ], + "summary": "Get a single business unit.", + "description": "Get a single business unit.\n\n**Auth:** permission:assets.view\n\n**Params:** path: bu_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/businessunits/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "bu_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-businessunits" + ], + "summary": "Update a business unit.", + "description": "Update a business unit.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: bu_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/businessunits/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "bu_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: bu_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/collector/{pluginname}": { + "post": { + "tags": [ + "core-collector" + ], + "summary": "Generic collector ingest for any plugin (ADR-006).", + "description": "Generic collector ingest for any plugin (ADR-006).\n\n**Auth:** api-key (per-plugin collector token)\n\n**Params:** path: pluginname (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/x -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "accessprotocols": [], + "action": "updated", + "assetid": 2, + "identityvalue": "LEGACYPC", + "machinelinks": [], + "measuringtoollinkcount": 0, + "measuringtoollinks": [], + "partmarkers": [ + { + "assetid": 3, + "assetnumber": "LEGACYPC-PARTMARKER", + "operationassetid": null + } + ], + "printerlinkcount": 0, + "printerlinks": [], + "status": "ok", + "warnings": [ + "mapped ComputerType not found: Shopfloor PC", + "machine number '0615' is this PC's own asset number; marker not filed under an operation. Rename the PC asset to its hostname, or create the operation asset." + ] + }, + "message": "computers collector updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "pluginname", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: pluginname (string); body: JSON (fields not yet documented)" + }, + "example": { + "hostname": "", + "queues": [ + { + "drivername": "HP Universal Printing PS", + "isdefault": true, + "portaddress": "", + "portname": "IP_10.20.0.11", + "queuename": "PRINTER-A" + }, + { + "drivername": "HP Universal Printing PS", + "isdefault": false, + "portaddress": "", + "portname": "IP_10.20.0.12", + "queuename": "PRINTER-B" + }, + { + "drivername": "HP Universal Printing PS", + "isdefault": false, + "portaddress": "", + "portname": "IP_10.20.0.99", + "queuename": "Reception Copier" + } + ] + } + } + } + } + } + }, + "/api/collector/_schemas": { + "get": { + "tags": [ + "core-collector" + ], + "summary": "List collector schemas for all enabled plugins (JWT-protected).", + "description": "List collector schemas for all enabled plugins (JWT-protected).\n\n**Auth:** jwt\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/collector/_schemas -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/collector/apps": { + "post": { + "tags": [ + "core-collector" + ], + "summary": "Update installed applications for a PC (matched by hostname).", + "description": "Update installed applications for a PC (matched by hostname).\n\n**Auth:** api-key (collector token, legacy shared key)\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/apps -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/collector/bulk": { + "post": { + "tags": [ + "core-collector" + ], + "summary": "Bulk update multiple PCs at once.", + "description": "Bulk update multiple PCs at once.\n\n**Auth:** api-key (collector token, legacy shared key)\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/bulk -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "errors": [], + "notfound": [ + "WJLEG01" + ], + "timestamp": "2026-01-01T00:00:00.000000", + "updated": 0 + }, + "message": "0 PC(s) updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "pcs": [ + { + "hostname": "" + } + ] + } + } + } + } + } + }, + "/api/collector/heartbeat": { + "post": { + "tags": [ + "core-collector" + ], + "summary": "Record PC online status / heartbeat (single or batch).", + "description": "Record PC online status / heartbeat (single or batch).\n\n**Auth:** api-key (collector token, legacy shared key)\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/heartbeat -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "notfound": [ + "WJLEG01" + ], + "timestamp": "2026-01-01T00:00:00.000000", + "updated": 0 + }, + "message": "0 PC(s) heartbeat recorded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "hostname": "" + } + } + } + } + } + }, + "/api/collector/pc": { + "post": { + "tags": [ + "core-collector" + ], + "summary": "Update one PC from the collection script (matched by hostname).", + "description": "Update one PC from the collection script (matched by hostname).\n\n**Auth:** api-key (collector token, legacy shared key)\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/pc -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/collector/status": { + "get": { + "tags": [ + "core-collector" + ], + "summary": "Check collector API status.", + "description": "Check collector API status.\n\n**Auth:** api-key (per-plugin collector token)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/collector/status -H \"X-API-Key: $KEY\"\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "endpoints": [ + "POST /api/collector/", + "GET /api/collector/_schemas", + "POST /api/collector/pc", + "" + ], + "status": "ok", + "timestamp": "2026-01-01T00:00:00.000000" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/customfields": { + "get": { + "tags": [ + "core-customfields" + ], + "summary": "List custom-field definitions. Filter by ?assettypeid=. ?active=false includes inactive ones.", + "description": "List custom-field definitions. Filter by ?assettypeid=. ?active=false includes inactive ones.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/customfields -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "assettypeid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "core-customfields" + ], + "summary": "UNDOCUMENTED: customfields.create_field has no docstring.", + "description": "UNDOCUMENTED: customfields.create_field has no docstring.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/customfields -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/customfields/{fieldid}": { + "delete": { + "tags": [ + "core-customfields" + ], + "summary": "UNDOCUMENTED: customfields.delete_field has no docstring.", + "description": "UNDOCUMENTED: customfields.delete_field has no docstring.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: fieldid (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/customfields/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "fieldid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-customfields" + ], + "summary": "UNDOCUMENTED: customfields.update_field has no docstring.", + "description": "UNDOCUMENTED: customfields.update_field has no docstring.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: fieldid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/customfields/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "fieldid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: fieldid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/customfields/asset/{assetid}": { + "get": { + "tags": [ + "core-customfields" + ], + "summary": "Active custom fields for an asset's type, merged with its values.", + "description": "Active custom fields for an asset's type, merged with its values.\n\n**Auth:** permission:assets.view\n\n**Params:** path: assetid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/customfields/asset/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "assetid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-customfields" + ], + "summary": "Upsert values for an asset. Body: {values: {fieldid: value, ...}}.", + "description": "Upsert values for an asset. Body: {values: {fieldid: value, ...}}.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: assetid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/customfields/asset/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "assetid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: assetid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/dashboard": { + "get": { + "tags": [ + "core-dashboard" + ], + "summary": "Get dashboard summary data (asset-based).", + "description": "Get dashboard summary data (asset-based).\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "activeassets": 0, + "bystatus": { + "Unknown": 2 + }, + "counts": { + "machines": 2, + "networkdevices": 0, + "pcs": 0, + "printers": 0, + "total": 2 + }, + "countsactiveonly": true, + "dualpathcollapsed": false, + "inactiveexcluded": 0, + "inrepair": 0, + "recent": [ + { + "assetid": 2, + "assetnumber": "2008", + "assettype": "machine", + "createddate": "2026-01-01T00:00:00.000000Z" + }, + { + "assetid": 1, + "assetnumber": "2007", + "assettype": "machine", + "createddate": "2026-01-01T00:00:00.000000Z" + } + ], + "statuses": { + "Unknown": 2 + }, + "totalassets": 2, + "totalmachines": 2, + "totalnetwork": 0, + "totalpc": 0, + "totalprinter": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/dashboard/capabilities": { + "get": { + "tags": [ + "core-dashboard" + ], + "summary": "What THIS caller can reach, for chrome that would otherwise only 401.", + "description": "What THIS caller can reach, for chrome that would otherwise only 401.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/capabilities -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "cansearch": false + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + }, + "/api/dashboard/health": { + "get": { + "tags": [ + "core-dashboard" + ], + "summary": "Health check endpoint (no auth required).", + "description": "Health check endpoint (no auth required).\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/health\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "database": "healthy", + "status": "ok" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + }, + "/api/dashboard/navigation": { + "get": { + "tags": [ + "core-dashboard" + ], + "summary": "Get navigation items from all loaded plugins.", + "description": "Get navigation items from all loaded plugins.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/navigation -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "icon": "layout-dashboard", + "name": "Dashboard", + "permission": "assets.view", + "position": 0, + "route": "/" + }, + { + "icon": "map", + "name": "Map", + "permission": "assets.view", + "position": 4, + "route": "/map" + }, + { + "icon": "bell", + "name": "Notifications", + "permission": "notifications.view", + "plugin": "notifications", + "position": 5, + "route": "/notifications" + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + }, + "/api/dashboard/stats": { + "get": { + "tags": [ + "core-dashboard" + ], + "summary": "Get detailed statistics by asset type.", + "description": "Get detailed statistics by asset type.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/stats -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "bytype": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/dashboard/summary": { + "get": { + "tags": [ + "core-dashboard" + ], + "summary": "Get dashboard summary data (asset-based).", + "description": "Get dashboard summary data (asset-based).\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/summary -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "activeassets": 0, + "bystatus": {}, + "counts": { + "machines": 0, + "networkdevices": 0, + "pcs": 0, + "printers": 0, + "total": 0 + }, + "countsactiveonly": true, + "dualpathcollapsed": true, + "inactiveexcluded": 0, + "inrepair": 0, + "recent": [], + "statuses": {}, + "totalassets": 0, + "totalmachines": 0, + "totalnetwork": 0, + "totalpc": 0, + "totalprinter": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/dashboard/widgets": { + "get": { + "tags": [ + "core-dashboard" + ], + "summary": "Aggregate dashboard widget definitions from all enabled plugins.", + "description": "Aggregate dashboard widget definitions from all enabled plugins.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/widgets -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/dashboarddefaults": { + "get": { + "tags": [ + "core-dashboarddefaults" + ], + "summary": "List all visitor-IP -> business-unit mappings.", + "description": "List all visitor-IP -> business-unit mappings.\n\n**Auth:** permission:settings.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboarddefaults -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + }, + "post": { + "tags": [ + "core-dashboarddefaults" + ], + "summary": "Create a visitor-IP -> business-unit mapping.", + "description": "Create a visitor-IP -> business-unit mapping.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/dashboarddefaults -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "businessunit": "Materials", + "businessunitid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "dashboarddefaultid": 1, + "description": "Materials lobby kiosk", + "displaypath": "/shopfloor", + "displayrole": "Dashboard", + "fqdn": "", + "ipaddress": "", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z" + }, + "message": "Mapping created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "businessunitid": 1, + "description": "Materials lobby kiosk", + "ipaddress": "" + } + } + } + } + } + }, + "/api/dashboarddefaults/{default_id}": { + "delete": { + "tags": [ + "core-dashboarddefaults" + ], + "summary": "Delete (deactivate) a mapping.", + "description": "Delete (deactivate) a mapping.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: default_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/dashboarddefaults/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "message": "Mapping deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "default_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-dashboarddefaults" + ], + "summary": "Update a mapping.", + "description": "Update a mapping.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: default_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/dashboarddefaults/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "businessunit": "Materials", + "businessunitid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "dashboarddefaultid": 1, + "description": "moved", + "displaypath": "/shopfloor", + "displayrole": "Dashboard", + "fqdn": "", + "ipaddress": "", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z" + }, + "message": "Mapping updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "default_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: default_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "description": "moved" + } + } + } + } + } + }, + "/api/dashboarddefaults/display-role": { + "get": { + "tags": [ + "core-dashboarddefaults" + ], + "summary": "Resolve what a single 'display' PC should show, from its own IP.", + "description": "Resolve what a single 'display' PC should show, from its own IP.\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboarddefaults/display-role\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "businessunit": null, + "businessunitid": null, + "fqdn": "", + "ipaddress": "", + "path": "/parts-kiosk", + "role": "3DPrintRoom" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "parameters": [ + { + "name": "fqdn", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ipaddress", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/dashboarddefaults/visitor-location": { + "get": { + "tags": [ + "core-dashboarddefaults" + ], + "summary": "Resolve the business unit for the calling display by its IP.", + "description": "Resolve the business unit for the calling display by its IP.\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboarddefaults/visitor-location\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "businessunit": "Materials", + "businessunitid": 1, + "fqdn": "", + "ipaddress": "" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "parameters": [ + { + "name": "fqdn", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ipaddress", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/docs": { + "get": { + "tags": [ + "core-docs" + ], + "summary": "Redoc page. url_for keeps the asset URLs correct under any mount.", + "description": "Redoc page. url_for keeps the asset URLs correct under any mount.\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/docs/\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + }, + "/api/docs/llms.txt": { + "get": { + "tags": [ + "core-docs" + ], + "summary": "Concise LLM-oriented API guide.", + "description": "Concise LLM-oriented API guide.\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/docs/llms.txt\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + }, + "/api/docs/openapi.json": { + "get": { + "tags": [ + "core-docs" + ], + "summary": "The generated OpenAPI 3.1 spec (machine + LLM readable).", + "description": "The generated OpenAPI 3.1 spec (machine + LLM readable).\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/docs/openapi.json\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + }, + "/api/docs/redoc.standalone.js": { + "get": { + "tags": [ + "core-docs" + ], + "summary": "Vendored Redoc bundle (offline).", + "description": "Vendored Redoc bundle (offline).\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/docs/redoc.standalone.js\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + }, + "/api/employees/lookup": { + "get": { + "tags": [ + "core-employees" + ], + "summary": "Look up multiple employees by SSO list.", + "description": "Look up multiple employees by SSO list.\n\n**Auth:** permission:employees.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/employees/lookup -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "sso", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "" + } + } + ] + } + }, + "/api/employees/lookup/{sso}": { + "get": { + "tags": [ + "core-employees" + ], + "summary": "Look up a single employee by SSO.", + "description": "Look up a single employee by SSO.\n\n**Auth:** permission:employees.view\n\n**Params:** path: sso (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/employees/lookup/x -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "First_Name": "Test", + "Last_Name": "Person", + "Picture": null, + "Role": null, + "SSO": 210000001, + "Team": null, + "photofilename": "photo-210000001.png", + "photourl": "/api/employees/photo/photo-210000001.png" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "sso", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/employees/search": { + "get": { + "tags": [ + "core-employees" + ], + "summary": "Search employees by name.", + "description": "Search employees by name.\n\n**Auth:** permission:employees.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/employees/search -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "First_Name": "Dana", + "Last_Name": "Reyes", + "Picture": null, + "Role": null, + "SSO": 990005, + "Team": null, + "photofilename": null, + "photourl": null + }, + { + "First_Name": "Sam", + "Last_Name": "Reyes", + "Picture": null, + "Role": null, + "SSO": 990006, + "Team": null, + "photofilename": null, + "photourl": null + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 10 + } + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "" + } + } + ] + } + }, + "/api/locations": { + "get": { + "tags": [ + "core-locations" + ], + "summary": "List all locations.", + "description": "List all locations.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/locations -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "locationname", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "tags": [ + "core-locations" + ], + "summary": "Create a new location.", + "description": "Create a new location.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/locations -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/locations/{location_id}": { + "delete": { + "tags": [ + "core-locations" + ], + "summary": "Delete (deactivate) a location.", + "description": "Delete (deactivate) a location.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: location_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/locations/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "location_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "core-locations" + ], + "summary": "Get a single location.", + "description": "Get a single location.\n\n**Auth:** permission:assets.view\n\n**Params:** path: location_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/locations/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "location_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-locations" + ], + "summary": "Update a location.", + "description": "Update a location.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: location_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/locations/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "location_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: location_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/locations/types": { + "get": { + "tags": [ + "core-locations" + ], + "summary": "List location types. ?active=false includes inactive ones.", + "description": "List location types. ?active=false includes inactive ones.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/locations/types -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + } + ] + }, + "post": { + "tags": [ + "core-locations" + ], + "summary": "UNDOCUMENTED: locations.create_location_type has no docstring.", + "description": "UNDOCUMENTED: locations.create_location_type has no docstring.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/locations/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/locations/types/{type_id}": { + "delete": { + "tags": [ + "core-locations" + ], + "summary": "UNDOCUMENTED: locations.delete_location_type has no docstring.", + "description": "UNDOCUMENTED: locations.delete_location_type has no docstring.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/locations/types/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-locations" + ], + "summary": "UNDOCUMENTED: locations.update_location_type has no docstring.", + "description": "UNDOCUMENTED: locations.update_location_type has no docstring.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: type_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/locations/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: type_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/maplevels": { + "get": { + "tags": [ + "core-maplevels" + ], + "summary": "Every active level, grouped by building, in display order.", + "description": "Every active level, grouped by building, in display order.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/maplevels -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "buildings": [ + { + "buildingid": 1, + "buildingname": "Public Works", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "levels": [ + { + "assetcount": 0, + "blueprintdark": null, + "blueprintlight": "floorplan.svg", + "buildingid": 1, + "buildingname": "Public Works", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "isdefault": false, + "levelid": 1, + "levelname": "Ground", + "mapheight": 2550, + "mapwidth": 3300, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "sortorder": 1 + } + ], + "modifieddate": "2026-01-01T00:00:00.000000Z", + "sortorder": 1 + } + ], + "defaultlevelid": 1, + "totalplaced": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "post": { + "tags": [ + "core-maplevels" + ], + "summary": "UNDOCUMENTED: maplevels.create_level has no docstring.", + "description": "UNDOCUMENTED: maplevels.create_level has no docstring.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/maplevels -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/maplevels/{levelid}": { + "delete": { + "tags": [ + "core-maplevels" + ], + "summary": "Deactivate a level, refusing while assets are still placed on it.", + "description": "Deactivate a level, refusing while assets are still placed on it.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: levelid (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/maplevels/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "levelid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "core-maplevels" + ], + "summary": "UNDOCUMENTED: maplevels.get_level has no docstring.", + "description": "UNDOCUMENTED: maplevels.get_level has no docstring.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** path: levelid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/maplevels/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "blueprintdark": null, + "blueprintlight": "floorplan.svg", + "buildingid": 1, + "buildingname": "Public Works", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "isdefault": false, + "levelid": 1, + "levelname": "Ground", + "mapheight": 2550, + "mapwidth": 3300, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "sortorder": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "levelid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "patch": { + "tags": [ + "core-maplevels" + ], + "summary": "UNDOCUMENTED: maplevels.update_level has no docstring.", + "description": "UNDOCUMENTED: maplevels.update_level has no docstring.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: levelid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PATCH http://localhost:5001/api/maplevels/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "levelid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: levelid (int); body: JSON (fields not yet documented)" + } + } + } + } + }, + "put": { + "tags": [ + "core-maplevels" + ], + "summary": "UNDOCUMENTED: maplevels.update_level has no docstring.", + "description": "UNDOCUMENTED: maplevels.update_level has no docstring.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: levelid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PATCH http://localhost:5001/api/maplevels/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "levelid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: levelid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/maplevels/{levelid}/blueprint": { + "post": { + "tags": [ + "core-maplevels" + ], + "summary": "Upload this level's blueprint for one theme.", + "description": "Upload this level's blueprint for one theme.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: levelid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/maplevels/1/blueprint -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "levelid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: levelid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/maplevels/{levelid}/blueprint/{filename}": { + "get": { + "tags": [ + "core-maplevels" + ], + "summary": "Serve a level's blueprint image. Public, for the same reason as above.", + "description": "Serve a level's blueprint image. Public, for the same reason as above.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** path: levelid (int), filename (path)\n\n**Example:**\n```\ncurl http://localhost:5001/api/maplevels/1/blueprint/example.png -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "levelid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "may contain slashes" + } + } + ] + } + }, + "/api/maplevels/buildings": { + "post": { + "tags": [ + "core-maplevels" + ], + "summary": "UNDOCUMENTED: maplevels.create_building has no docstring.", + "description": "UNDOCUMENTED: maplevels.create_building has no docstring.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/maplevels/buildings -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/maplevels/buildings/{buildingid}": { + "patch": { + "tags": [ + "core-maplevels" + ], + "summary": "Rename or reorder a building. Levels move with it; nothing repositions.", + "description": "Rename or reorder a building. Levels move with it; nothing repositions.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: buildingid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PATCH http://localhost:5001/api/maplevels/buildings/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "buildingid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: buildingid (int); body: JSON (fields not yet documented)" + } + } + } + } + }, + "put": { + "tags": [ + "core-maplevels" + ], + "summary": "Rename or reorder a building. Levels move with it; nothing repositions.", + "description": "Rename or reorder a building. Levels move with it; nothing repositions.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: buildingid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PATCH http://localhost:5001/api/maplevels/buildings/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "buildingid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: buildingid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/mappositions/positions": { + "post": { + "tags": [ + "core-mappositions" + ], + "summary": "Set positions for many assets at once.", + "description": "Set positions for many assets at once.\n\n**Auth:** permission:assets.edit\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/mappositions/positions -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "snapshotid": 1, + "updated": 1 + }, + "message": "1 position(s) saved", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "positions": [ + { + "assetid": 1, + "levelid": 2, + "mapx": 42, + "mapy": 43 + } + ] + } + } + } + } + } + }, + "/api/mappositions/snapshots": { + "get": { + "tags": [ + "core-mappositions" + ], + "summary": "Snapshots, newest first. Metadata only - the positions are large.", + "description": "Snapshots, newest first. Metadata only - the positions are large.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/mappositions/snapshots -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/mappositions/snapshots/{snapshotid}/restore": { + "post": { + "tags": [ + "core-mappositions" + ], + "summary": "Put the positions in this snapshot back.", + "description": "Put the positions in this snapshot back.\n\n**Auth:** permission:assets.edit\n\n**Params:** path: snapshotid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/mappositions/snapshots/1/restore -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "restored": 3, + "skippedassetids": [] + }, + "message": "3 marker(s) restored", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "snapshotid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: snapshotid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/mappositions/transform": { + "post": { + "tags": [ + "core-mappositions" + ], + "summary": "Move every placed marker on a level by a transform read off landmarks.", + "description": "Move every placed marker on a level by a transform read off landmarks.\n\n**Auth:** permission:assets.edit\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/mappositions/transform -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetcount": 3, + "dryrun": false, + "levelid": 1, + "moves": [ + { + "assetid": 1, + "assetnumber": "M000", + "fromx": 100, + "fromy": 100, + "name": null, + "outofbounds": false, + "tox": 100, + "toy": 1550 + }, + { + "assetid": 2, + "assetnumber": "M001", + "fromx": 1500, + "fromy": 1200, + "name": null, + "outofbounds": true, + "tox": 1500, + "toy": 2650 + }, + { + "assetid": 3, + "assetnumber": "M002", + "fromx": 3200, + "fromy": 2500, + "name": null, + "outofbounds": true, + "tox": 3200, + "toy": 3950 + } + ], + "outofboundscount": 2, + "snapshotid": 1, + "tolevelid": 1, + "transform": { + "offsetx": 0.0, + "offsety": 1450.0, + "scalex": 1.0, + "scaley": 1.0 + } + }, + "message": "3 marker(s) moved; snapshot 1 can restore them", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "dryrun": false, + "landmarks": [ + { + "fromx": 0, + "fromy": 0, + "tox": 0, + "toy": 1450 + }, + { + "fromx": 1000, + "fromy": 1000, + "tox": 1000, + "toy": 2450 + } + ], + "levelid": 1 + } + } + } + } + } + }, + "/api/mappositions/verify": { + "post": { + "tags": [ + "core-mappositions" + ], + "summary": "Mark markers as reviewed against the current drawing without moving them.", + "description": "Mark markers as reviewed against the current drawing without moving them.\n\n**Auth:** permission:assets.edit\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/mappositions/verify -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "updated": 1 + }, + "message": "1 marker(s) unverified", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "assetids": [ + 1 + ], + "unverify": true + } + } + } + } + } + }, + "/api/models": { + "get": { + "tags": [ + "core-models" + ], + "summary": "List all vendor catalog models.", + "description": "List all vendor catalog models.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/models -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "category", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "modelnumber", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "modeltype", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "vendor", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "core-models" + ], + "summary": "Create a new model.", + "description": "Create a new model.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/models -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/models/{model_id}": { + "delete": { + "tags": [ + "core-models" + ], + "summary": "Delete (deactivate) a model.", + "description": "Delete (deactivate) a model.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: model_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/models/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "core-models" + ], + "summary": "Get a single model.", + "description": "Get a single model.\n\n**Auth:** permission:assets.view\n\n**Params:** path: model_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/models/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-models" + ], + "summary": "Update a model.", + "description": "Update a model.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: model_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/models/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: model_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/models/{model_id}/image": { + "delete": { + "tags": [ + "core-models" + ], + "summary": "Clear a model image and delete the uploaded file if we own it.", + "description": "Clear a model image and delete the uploaded file if we own it.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: model_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/models/1/image -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "documentationurl": null, + "imageurl": null, + "isactive": true, + "modelnumber": "TESTMODEL-1", + "modelnumberid": 1, + "modeltypeid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "vendorid": null + }, + "message": "Model image removed", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "core-models" + ], + "summary": "Upload (or replace) the photo for a model.", + "description": "Upload (or replace) the photo for a model.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: model_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/models/1/image -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "documentationurl": null, + "imageurl": "/api/models/image/model-1.jpg", + "isactive": true, + "modelnumber": "TESTMODEL-1", + "modelnumberid": 1, + "modeltypeid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "vendorid": null + }, + "message": "Model image uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: model_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/models/image/{filename}": { + "get": { + "tags": [ + "core-models" + ], + "summary": "Serve an uploaded model image (public - asset detail pages read it).", + "description": "Serve an uploaded model image (public - asset detail pages read it).\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** path: filename (path)\n\n**Example:**\n```\ncurl http://localhost:5001/api/models/image/example.png\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "may contain slashes" + } + } + ] + } + }, + "/api/modeltypes": { + "get": { + "tags": [ + "core-modeltypes" + ], + "summary": "List all model types with optional filtering.", + "description": "List all model types with optional filtering.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/modeltypes -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "category", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "modeltype", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "tags": [ + "core-modeltypes" + ], + "summary": "Create a new model type.", + "description": "Create a new model type.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/modeltypes -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/modeltypes/{type_id}": { + "delete": { + "tags": [ + "core-modeltypes" + ], + "summary": "Delete (deactivate) a model type.", + "description": "Delete (deactivate) a model type.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/modeltypes/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "core-modeltypes" + ], + "summary": "Get a single model type.", + "description": "Get a single model type.\n\n**Auth:** permission:assets.view\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/modeltypes/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-modeltypes" + ], + "summary": "Update a model type.", + "description": "Update a model type.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: type_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/modeltypes/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: type_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/operatingsystems": { + "get": { + "tags": [ + "core-operatingsystems" + ], + "summary": "List all operating systems.", + "description": "List all operating systems.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/operatingsystems -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "osname", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "osversion", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "tags": [ + "core-operatingsystems" + ], + "summary": "Create a new operating system.", + "description": "Create a new operating system.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/operatingsystems -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/operatingsystems/{os_id}": { + "delete": { + "tags": [ + "core-operatingsystems" + ], + "summary": "Delete (deactivate) an operating system.", + "description": "Delete (deactivate) an operating system.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: os_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/operatingsystems/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "os_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "core-operatingsystems" + ], + "summary": "Get a single operating system.", + "description": "Get a single operating system.\n\n**Auth:** permission:assets.view\n\n**Params:** path: os_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/operatingsystems/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "os_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-operatingsystems" + ], + "summary": "Update an operating system.", + "description": "Update an operating system.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: os_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/operatingsystems/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "os_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: os_id (int); body: JSON (fields not yet documented)" } } } @@ -3538,12 +9671,11 @@ "/api/plugins": { "get": { "tags": [ - "core-platform" + "core-plugins" ], - "summary": "List all discovered plugins (enabled or not) with metadata plus the framework contract version", - "description": "List all discovered plugins (enabled or not) with metadata plus the framework contract version\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/plugins\n```", + "summary": "List all discovered plugins (enabled or not) with their metadata and the framework contract version.", + "description": "List all discovered plugins (enabled or not) with their metadata and the framework contract version.\n\n**Auth:** permission:settings.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/plugins -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -3553,7 +9685,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -3564,39 +9698,12 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } - } - } - } - }, - "/api/plugins/enabled": { - "get": { - "tags": [ - "core-platform" - ], - "summary": "Flat sorted array of enabled plugin names (registry read, no DB); deliberately anonymous so kiosk routes can gate...", - "description": "Flat sorted array of enabled plugin names (registry read, no DB); deliberately anonymous so kiosk routes can gate plugin-owned frontend routes\n\n**Auth:** none (jwt-optional decorator, no claims used)\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/plugins/enabled\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } } } @@ -3604,10 +9711,10 @@ "/api/plugins/{name}": { "put": { "tags": [ - "core-platform" + "core-plugins" ], - "summary": "Enable or disable a plugin (route changes need an app restart); 409 when unknown or a dependency conflict refuses the...", - "description": "Enable or disable a plugin (route changes need an app restart); 409 when unknown or a dependency conflict refuses the change\n\n**Auth:** jwt + role:admin\n\n**Params:** body: enabled (bool, required)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/plugins/warranty -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"enabled\":false}'\n```", + "summary": "Enable or disable a plugin. Takes effect on the next app restart for route/navigation changes.", + "description": "Enable or disable a plugin. Takes effect on the next app restart for route/navigation changes.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: name (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/plugins/x -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -3618,7 +9725,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -3657,32 +9766,47 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: enabled (bool, required)" + "description": "path: name (string); body: JSON (fields not yet documented)" } } } } } }, - "/api/pluginui/settings-cards": { + "/api/plugins/enabled": { "get": { "tags": [ - "core-platform" + "core-plugins" ], - "summary": "Merge enabled plugins' settings-catalog cards (get_settings_cards hook) sorted by position for the settings...", - "description": "Merge enabled plugins' settings-catalog cards (get_settings_cards hook) sorted by position for the settings rail/overview\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/pluginui/settings-cards\n```", + "summary": "Return just the names of enabled plugins as a flat array.", + "description": "Return just the names of enabled plugins as a flat array.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/plugins/enabled -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] - } + }, + {} ], "responses": { "200": { "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + "backups", + "computers", + "employees", + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -3697,15 +9821,79 @@ } } }, + "/api/plugins/settings-routes": { + "get": { + "tags": [ + "core-plugins" + ], + "summary": "Return settings routes contributed by all enabled plugins.", + "description": "Return settings routes contributed by all enabled plugins.\n\n**Auth:** permission:settings.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/plugins/settings-routes -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "routes": [ + { + "component": "drawn.vue", + "hub": "model-catalog", + "name": "drawn-route", + "path": "drawn", + "tab": "Drawn" + }, + { + "component": ".plugins-staged/computers/views/OperatingSystemsList.vue", + "hub": "model-catalog", + "name": "model-catalog-os", + "path": "operatingsystems", + "tab": "Operating Systems" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, "/api/pluginui/asset-panels": { "get": { "tags": [ - "core-platform" + "core-pluginui" ], - "summary": "Asset-detail panels from enabled plugins (get_asset_panels hook) matching one asset's type ('*' wildcard supported)...", - "description": "Asset-detail panels from enabled plugins (get_asset_panels hook) matching one asset's type ('*' wildcard supported), sorted by position\n\n**Auth:** jwt-optional\n\n**Params:** assetid (int, required); 400 without it, 404 if asset missing\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/pluginui/asset-panels?assetid=42'\n```", + "summary": "Return the asset-detail panels matching one asset's type (get_asset_panels).", + "description": "Return the asset-detail panels matching one asset's type (get_asset_panels).\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/pluginui/asset-panels -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -3715,7 +9903,122 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "assettypes": [ + "computer" + ], + "empty": "No agent report yet.", + "endpoint": "/api/endpoints/asset/{assetid}/status", + "group": "details", + "id": "endpoints-status", + "plugin": "endpoints", + "position": 10, + "render": "keyvalue", + "title": "Endpoint status" + }, + { + "assettypes": [ + "computer" + ], + "collapsible": true, + "commandendpoint": "/api/endpoints/{assetid}/commands", + "empty": "No monitored services reported.", + "endpoint": "/api/endpoints/asset/{assetid}/services", + "facets": { + "key": "statelabel", + "order": [ + "Running", + "Stopped", + "Paused", + "" + ] + }, + "filter": { + "equals": "service", + "key": "kind" + }, + "group": "serviceprocess", + "id": "endpoints-tracked-services", + "map": { + "action": "action", + "badge": { + "color": "statecolor", + "label": "statelabel" + }, + "title": "name" + }, + "permission": "endpoints.command", + "plugin": "endpoints", + "position": 12, + "render": "list", + "status": { + "key": "statecolor", + "levels": { + "green": "ok", + "red": "critical", + "yellow": "warning" + } + }, + "title": "Tracked Services" + }, + { + "assettypes": [ + "computer" + ], + "collapsible": true, + "commandendpoint": "/api/endpoints/{assetid}/commands", + "empty": "No monitored processes reported.", + "endpoint": "/api/endpoints/asset/{assetid}/services", + "facets": { + "key": "statelabel", + "order": [ + "Running", + "Stopped", + "Paused", + "" + ] + }, + "filter": { + "equals": "process", + "key": "kind" + }, + "group": "serviceprocess", + "id": "endpoints-tracked-processes", + "map": { + "action": "action", + "badge": { + "color": "statecolor", + "label": "statelabel" + }, + "title": "name" + }, + "permission": "endpoints.command", + "plugin": "endpoints", + "position": 13, + "render": "list", + "status": { + "key": "statecolor", + "levels": { + "green": "ok", + "red": "critical", + "yellow": "warning" + } + }, + "title": "Tracked Processes" + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -3726,6 +10029,235 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "assetid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/pluginui/asset-presentation": { + "get": { + "tags": [ + "core-pluginui" + ], + "summary": "Merge enabled plugins' asset-type presentation entries (hook).", + "description": "Merge enabled plugins' asset-type presentation entries (hook).\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/pluginui/asset-presentation -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "assettype": "stubtype", + "plugin": "backups", + "route": "/stubtype/by-asset/{assetid}" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/pluginui/asset-type-descriptors": { + "get": { + "tags": [ + "core-pluginui" + ], + "summary": "Merge enabled plugins' asset-type subtype descriptors.", + "description": "Merge enabled plugins' asset-type subtype descriptors.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/pluginui/asset-type-descriptors -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "assettype": "computer", + "edit_permission": "computers.manage", + "optional_tabs": [], + "plugin": "computers", + "subtype_color_field": "color", + "subtype_description_field": "description", + "subtype_endpoint": "/computers/types", + "subtype_id_field": "computertypeid", + "subtype_label_field": "computertype" + }, + { + "assettype": "machine", + "edit_permission": "machines.manage", + "optional_tabs": [], + "plugin": "machines", + "subtype_color_field": "color", + "subtype_description_field": "description", + "subtype_endpoint": "/machines/types", + "subtype_id_field": "machinetypeid", + "subtype_label_field": "machinetype" + }, + { + "assettype": "network_device", + "edit_permission": "network.manage", + "optional_tabs": [], + "plugin": "network", + "subtype_color_field": "color", + "subtype_description_field": "description", + "subtype_endpoint": "/network/types", + "subtype_id_field": "networkdevicetypeid", + "subtype_label_field": "networkdevicetype" + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/pluginui/core-type-descriptors": { + "get": { + "tags": [ + "core-pluginui" + ], + "summary": "Return type descriptors for core-owned non-asset types.", + "description": "Return type descriptors for core-owned non-asset types.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/pluginui/core-type-descriptors -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "category": "location", + "edit_permission": "admin", + "group": "other", + "optional_tabs": [], + "subtype_color_field": "color", + "subtype_description_field": "description", + "subtype_endpoint": "/locations/types", + "subtype_id_field": "locationtypeid", + "subtype_label_field": "locationtype", + "type_name": "Location Type", + "type_name_plural": "Location Types" + }, + { + "category": "notification", + "custom_component": "NotificationTypesTab", + "edit_permission": "admin", + "group": "other", + "optional_tabs": [], + "subtype_color_field": "typecolor", + "subtype_description_field": "typedescription", + "subtype_endpoint": "/notifications/types", + "subtype_id_field": "notificationtypeid", + "subtype_label_field": "typename", + "type_name": "Notification Type", + "type_name_plural": "Notification Types" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } } } @@ -3733,12 +10265,11 @@ "/api/pluginui/map-overlays": { "get": { "tags": [ - "core-platform" + "core-pluginui" ], - "summary": "Merge enabled plugins' map overlay declarations (get_map_overlays hook), sorted by position", - "description": "Merge enabled plugins' map overlay declarations (get_map_overlays hook), sorted by position\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/pluginui/map-overlays\n```", + "summary": "Merge enabled plugins' map overlay declarations (get_map_overlays hook).", + "description": "Merge enabled plugins' map overlay declarations (get_map_overlays hook).\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/pluginui/map-overlays -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -3748,7 +10279,25 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "endpoint": "/api/whatever", + "id": "due-soon", + "legend": true, + "plugin": "backups", + "title": "Due soon" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -3759,19 +10308,24 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } } } }, - "/api/pluginui/asset-presentation": { + "/api/pluginui/settings-cards": { "get": { "tags": [ - "core-platform" + "core-pluginui" ], - "summary": "Merge enabled plugins' asset-type presentation entries (icon + detail route per type) used by search rows and...", - "description": "Merge enabled plugins' asset-type presentation entries (icon + detail route per type) used by search rows and cross-links\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/pluginui/asset-presentation\n```", + "summary": "Merge enabled plugins' settings-catalog cards (get_settings_cards hook).", + "description": "Merge enabled plugins' settings-catalog cards (get_settings_cards hook).\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/pluginui/settings-cards -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -3781,7 +10335,1359 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "description": "Collection interval, share root for part-marker snapshots, staleness threshold and revision retention", + "group": "Machines", + "icon": "package", + "plugin": "backups", + "position": 21, + "title": "Backups", + "to": "/settings/backups" + }, + { + "description": "Download the collector script stamped with this site's URL and ranges", + "group": "Computers", + "icon": "download", + "plugin": "computers", + "position": 26, + "title": "Asset reporter", + "to": "/settings/collector" + }, + { + "description": "The shared agent key PCs use until they enroll, the site agent installer builder, and what the agent reports.", + "group": "Endpoints", + "icon": "key", + "plugin": "endpoints", + "position": 27, + "title": "Endpoints", + "to": "/settings/endpoints/agentkey" + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/reports": { + "get": { + "tags": [ + "core-reports" + ], + "summary": "List all available reports.", + "description": "List all available reports.\n\n**Auth:** permission:reports.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/reports -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "reports": [ + { + "category": "inventory", + "description": "Machine count grouped by machine type", + "endpoint": "/api/reports/machines-by-type", + "id": "machines-by-type", + "name": "Machines by Type" + }, + { + "category": "inventory", + "description": "Asset count grouped by status", + "endpoint": "/api/reports/assets-by-status", + "id": "assets-by-status", + "name": "Assets by Status" + }, + { + "category": "usage", + "description": "Most clicked knowledge base articles", + "endpoint": "/api/reports/kb-popularity", + "id": "kb-popularity", + "name": "KB Popularity" + }, + "" + ], + "total": 13 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/reports/asset-inventory": { + "get": { + "tags": [ + "core-reports" + ], + "summary": "Report: Complete asset inventory summary.", + "description": "Report: Complete asset inventory summary.\n\n**Auth:** permission:reports.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/reports/asset-inventory -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "data": { + "bylocation": [], + "bystatus": [], + "bytype": [] + }, + "generated": "2026-01-01T00:00:00.000000", + "report": "asset_inventory", + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "businessunitid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "locationid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/reports/assets-by-status": { + "get": { + "tags": [ + "core-reports" + ], + "summary": "Report: Asset count grouped by status.", + "description": "Report: Asset count grouped by status.\n\n**Auth:** permission:reports.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/reports/assets-by-status -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "data": [], + "generated": "2026-01-01T00:00:00.000000", + "report": "assets_by_status", + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "assettypeid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "businessunitid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/reports/email": { + "post": { + "tags": [ + "core-reports" + ], + "summary": "Email a report's data as an HTML table (on-demand delivery).", + "description": "Email a report's data as an HTML table (on-demand delivery).\n\n**Auth:** permission:reports.export\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/reports/email -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "reason": "notconfigured", + "sent": false + }, + "message": "Email is not configured (SMTP disabled or host unset).", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "columns": [ + { + "key": "vendor", + "label": "Vendor" + } + ], + "rows": [ + { + "vendor": "Dell" + } + ], + "subject": "Warranty Report", + "to": "" + } + } + } + } + } + }, + "/api/reports/kb-popularity": { + "get": { + "tags": [ + "core-reports" + ], + "summary": "Report: Most clicked knowledge base articles.", + "description": "Report: Most clicked knowledge base articles.\n\n**Auth:** permission:reports.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/reports/kb-popularity -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "data": [], + "generated": "2026-01-01T00:00:00.000000", + "report": "kb_popularity", + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20 + } + } + ] + } + }, + "/api/reports/machines-by-type": { + "get": { + "tags": [ + "core-reports" + ], + "summary": "Report: Machine count grouped by machine type.", + "description": "Report: Machine count grouped by machine type.\n\n**Auth:** permission:reports.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/reports/machines-by-type -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "data": [], + "generated": "2026-01-01T00:00:00.000000", + "report": "machines_by_type", + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "businessunitid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/reports/pc-relationships": { + "get": { + "tags": [ + "core-reports" + ], + "summary": "Report: PCs with relationships to shop floor machines.", + "description": "Report: PCs with relationships to shop floor machines.\n\n**Auth:** permission:reports.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/reports/pc-relationships -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "data": [ + { + "hostname": "", + "ip": "", + "machine_number": "4242", + "model": "", + "vendor": "" + } + ], + "generated": "2026-01-01T00:00:00.000000", + "report": "pc_relationships", + "total": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/reports/software-compliance": { + "get": { + "tags": [ + "core-reports" + ], + "summary": "Report: Required applications vs installed (per PC).", + "description": "Report: Required applications vs installed (per PC).\n\n**Auth:** permission:reports.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/reports/software-compliance -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "data": [], + "generated": "2026-01-01T00:00:00.000000", + "message": "No required applications defined", + "report": "software_compliance" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "appid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/scripts": { + "get": { + "tags": [ + "core-scripts" + ], + "summary": "List scripts (active by default).", + "description": "List scripts (active by default).\n\n**Auth:** permission:endpoints.runscript\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "tags": [ + "core-scripts" + ], + "summary": "Create a script catalog entry (body uploaded separately).", + "description": "Create a script catalog entry (body uploaded separately).\n\n**Auth:** permission:endpoints.runscript\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "captureoutput": false, + "createddate": "2026-01-01T00:00:00.000000Z", + "errormode": "default", + "filename": null, + "hasbody": false, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "runcontext": "system", + "scriptdescription": null, + "scriptid": 1, + "scriptname": "autologon", + "sha256": null, + "workingdirectory": "C:\\Program Files\\Sysinternals" + }, + "message": "Script created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "scriptname": "autologon", + "workingdirectory": "C:\\Program Files\\Sysinternals" + } + } + } + } + } + }, + "/api/scripts/{script_id}": { + "delete": { + "tags": [ + "core-scripts" + ], + "summary": "UNDOCUMENTED: scripts.delete_script has no docstring.", + "description": "UNDOCUMENTED: scripts.delete_script has no docstring.\n\n**Auth:** permission:endpoints.runscript\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/scripts/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "script_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "core-scripts" + ], + "summary": "UNDOCUMENTED: scripts.get_script has no docstring.", + "description": "UNDOCUMENTED: scripts.get_script has no docstring.\n\n**Auth:** permission:endpoints.runscript\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "script_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-scripts" + ], + "summary": "UNDOCUMENTED: scripts.update_script has no docstring.", + "description": "UNDOCUMENTED: scripts.update_script has no docstring.\n\n**Auth:** permission:endpoints.runscript\n\n**Params:** path: script_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/scripts/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "captureoutput": true, + "createddate": "2026-01-01T00:00:00.000000Z", + "errormode": "default", + "filename": null, + "hasbody": false, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "runcontext": "system", + "scriptdescription": "now documented", + "scriptid": 1, + "scriptname": "keep-my-flag", + "sha256": null, + "workingdirectory": null + }, + "message": "Script updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "script_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: script_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "scriptdescription": "now documented" + } + } + } + } + } + }, + "/api/scripts/{script_id}/body": { + "post": { + "tags": [ + "core-scripts" + ], + "summary": "Upload (or replace) the .ps1 body for a script.", + "description": "Upload (or replace) the .ps1 body for a script.\n\n**Auth:** permission:endpoints.runscript\n\n**Params:** path: script_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts/1/body -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "bytes": 23, + "captureoutput": false, + "createddate": "2026-01-01T00:00:00.000000Z", + "errormode": "default", + "filename": "script-1.ps1", + "hasbody": true, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "runcontext": "system", + "scriptdescription": null, + "scriptid": 1, + "scriptname": "digest-check", + "sha256": "", + "uploadedfilename": "digest-check.ps1", + "workingdirectory": null + }, + "message": "Script body uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "script_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: script_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/scripts/body/{filename}": { + "get": { + "tags": [ + "core-scripts" + ], + "summary": "Operator-facing script body download (JWT/RBAC).", + "description": "Operator-facing script body download (JWT/RBAC).\n\n**Auth:** permission:endpoints.runscript\n\n**Params:** path: filename (path)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/body/example.png -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "may contain slashes" + } + } + ] + } + }, + "/api/search": { + "get": { + "tags": [ + "core-search" + ], + "summary": "Global search across multiple entity types.", + "description": "Global search across multiple entity types.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/search -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "counts": { + "knowledgebase": 2 + }, + "degraded": [ + "Employee" + ], + "query": "zzkeyword", + "results": [ + { + "id": 1, + "linkurl": "u", + "relevance": 25.0, + "subtitle": "Livesearchapp", + "title": "Zzrunbook for the live one", + "type": "knowledgebase", + "url": "/knowledgebase/1" + }, + { + "id": 3, + "linkurl": "u", + "relevance": 25.0, + "subtitle": null, + "title": "Zzrunbook with no topic", + "type": "knowledgebase", + "url": "/knowledgebase/3" + } + ], + "total": 2, + "total_all": 2 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "" + } + } + ] + } + }, + "/api/search/suggest": { + "get": { + "tags": [ + "core-search" + ], + "summary": "A better spelling for a query that found nothing, or null.", + "description": "A better spelling for a query that found nothing, or null.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/search/suggest -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "query": "keyecne", + "suggestion": "keyence" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "" + } + } + ] + } + }, + "/api/settings": { + "get": { + "tags": [ + "core-settings" + ], + "summary": "List all settings, optionally filtered by category.", + "description": "List all settings, optionally filtered by category.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/settings -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "category": "auth", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "SAML Assertion Consumer Service URL", + "key": "saml_acs_url", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "settingid": 111, + "value": "", + "valuetype": "string" + }, + { + "category": "auth", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "SAML group name that grants admin role", + "key": "saml_admin_group", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "settingid": 114, + "value": "", + "valuetype": "string" + }, + { + "category": "auth", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "Allow local username/password login when SAML is enabled", + "key": "saml_allow_local_login", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "settingid": 112, + "value": true, + "valuetype": "boolean" + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -3793,23 +11699,37 @@ } } } - } - } - }, - "/api/employees/search": { - "get": { + }, + "parameters": [ + { + "name": "category", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + }, + "post": { "tags": [ - "plugin-employees" + "core-settings" + ], + "summary": "Create a new setting (admin only).", + "description": "Create a new setting (admin only).\n\n**Auth:** permission:settings.edit\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } ], - "summary": "Search employees by first name, last name, or SSO substring (self-hosted table or external HR DB depending on...", - "description": "Search employees by first name, last name, or SSO substring (self-hosted table or external HR DB depending on employee_directory_mode setting).\n\n**Auth:** none\n\n**Params:** q (query string, min 2 chars, required), limit (max results, default 10, capped 50)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/employees/search?q=smith&limit=5'\n```", - "security": [], "responses": { "200": { "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -3820,24 +11740,66 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } } } } }, - "/api/employees/lookup/{sso}": { + "/api/settings/{key}": { "get": { "tags": [ - "plugin-employees" + "core-settings" + ], + "summary": "Get a single setting by key.", + "description": "Get a single setting by key.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** path: key (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/settings/x -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + }, + {} ], - "summary": "Look up a single employee by numeric SSO; returns directory fields plus resolved photourl.", - "description": "Look up a single employee by numeric SSO; returns directory fields plus resolved photourl.\n\n**Auth:** none\n\n**Params:** sso (path, numeric)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/employees/lookup/210009518'\n```", - "security": [], "responses": { "200": { "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "category": "plugin.endpoints", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "Accept a one-line install bootstrap token in the ?t= query string as well as the X-Endpoint-Bootstrap-Token header. On (the default) keeps the pasted install commands and the Download .ps1 link workin...", + "key": "plugin.endpoints.bootstrapquerytoken", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "settingid": 1, + "value": false, + "valuetype": "boolean" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -3855,7 +11817,300 @@ }, "parameters": [ { - "name": "sso", + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + }, + "put": { + "tags": [ + "core-settings" + ], + "summary": "Update a setting value.", + "description": "Update a setting value.\n\n**Auth:** permission:settings.edit\n\n**Params:** path: key (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/settings/x -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "category": "plugin.endpoints", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "Accept a one-line install bootstrap token in the ?t= query string as well as the X-Endpoint-Bootstrap-Token header. On (the default) keeps the pasted install commands and the Download .ps1 link workin...", + "key": "plugin.endpoints.bootstrapquerytoken", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "settingid": 1, + "value": false, + "valuetype": "boolean" + }, + "message": "Setting updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: key (string); body: JSON (fields not yet documented)" + }, + "example": { + "value": "" + } + } + } + } + } + }, + "/api/settings/asset-import/import": { + "post": { + "tags": [ + "core-settings" + ], + "summary": "Validate uploaded CSVs and, when commit=true, load them.", + "description": "Validate uploaded CSVs and, when commit=true, load them.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/asset-import/import -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/settings/asset-import/script": { + "get": { + "tags": [ + "core-settings" + ], + "summary": "Download Collect-ShopDB.ps1 with this site's CSV path embedded.", + "description": "Download Collect-ShopDB.ps1 with this site's CSV path embedded.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/settings/asset-import/script -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/settings/asset-import/templates": { + "get": { + "tags": [ + "core-settings" + ], + "summary": "List the importable CSV tables in the order they must be uploaded.", + "description": "List the importable CSV tables in the order they must be uploaded.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/settings/asset-import/templates -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "tables": [ + { + "filename": "assetstatuses.csv", + "table": "assetstatuses" + }, + { + "filename": "assettypes.csv", + "table": "assettypes" + }, + { + "filename": "locationtypes.csv", + "table": "locationtypes" + }, + "" + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/settings/asset-import/templates/{tablename}": { + "get": { + "tags": [ + "core-settings" + ], + "summary": "Download a generated CSV template for a single importable table.", + "description": "Download a generated CSV template for a single importable table.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: tablename (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/settings/asset-import/templates/x -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "tablename", "in": "path", "required": true, "schema": { @@ -3865,403 +12120,405 @@ ] } }, - "/api/employees/lookup": { + "/api/settings/asset-import/winc/batches": { "get": { "tags": [ - "plugin-employees" + "core-settings" ], - "summary": "Bulk lookup of multiple employees by SSO list; returns employees array plus a joined names string.", - "description": "Bulk lookup of multiple employees by SSO list; returns employees array plus a joined names string.\n\n**Auth:** none\n\n**Params:** sso (query, comma-separated numeric SSOs, at least one required)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/employees/lookup?sso=210009518,210001234'\n```", + "summary": "List applied/undone WINC import batches, newest first (for the undo UI).", + "description": "List applied/undone WINC import batches, newest first (for the undo UI).\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/settings/asset-import/winc/batches -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "batches": [ + { + "batchid": "", + "createdat": "2026-01-01 00:00:00.000000", + "createdby": "", + "kind": "winc-machine-adoption", + "rowcount": 1, + "status": "undone", + "undoneat": "2026-01-01 00:00:00.000000" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/settings/asset-import/winc/commit": { + "post": { + "tags": [ + "core-settings" + ], + "summary": "Apply a WINC plan reversibly. Requires acknowledgment for needs-create.", + "description": "Apply a WINC plan reversibly. Requires acknowledgment for needs-create.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/asset-import/winc/commit -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "batchid": "", + "blocking": false, + "committed": true, + "kind": "winc-machine-adoption", + "needscreate": { + "businessunit": { + "Large Parts": 1 + } + }, + "planhash": "", + "rowcount": 1, + "summary": { + "ambiguous_match": 0, + "businessunit_blank": 0, + "businessunit_needs-create": 1, + "businessunit_resolved": 0, + "businessunit_unresolved": 0, + "export_rows": 2, + "in_scope": 1, + "location_blank": 0, + "location_needs-create": 0, + "location_resolved": 1, + "location_unresolved": 0, + "non_workstation_rows": 1, + "skipped_no_existing_machine": 0 + }, + "uncreatable": {}, + "unresolved": {} + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/settings/asset-import/winc/preview": { + "post": { + "tags": [ + "core-settings" + ], + "summary": "Read-only WINC analysis: report what a commit would do. Writes nothing.", + "description": "Read-only WINC analysis: report what a commit would do. Writes nothing.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/asset-import/winc/preview -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "blocking": true, + "committed": false, + "kind": "winc-machine-adoption", + "needscreate": { + "businessunit": { + "Large Parts": 1 + }, + "location": { + "T6": 1 + } + }, + "planhash": "", + "rowcount": 1, + "summary": { + "ambiguous_match": 0, + "businessunit_blank": 0, + "businessunit_needs-create": 1, + "businessunit_resolved": 0, + "businessunit_unresolved": 0, + "export_rows": 1, + "in_scope": 1, + "location_blank": 0, + "location_needs-create": 1, + "location_resolved": 0, + "location_unresolved": 0, + "non_workstation_rows": 0, + "skipped_no_existing_machine": 0 + }, + "uncreatable": { + "location": { + "T6": 1 + } + }, + "unresolved": {} + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/settings/asset-import/winc/undo": { + "post": { + "tags": [ + "core-settings" + ], + "summary": "Reverse an applied WINC import batch by id. Conservative and reversible.", + "description": "Reverse an applied WINC import batch by id. Conservative and reversible.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/asset-import/winc/undo -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "batchid": "", + "conflicts": [ + { + "column": null, + "operation": "insert", + "reason": "still referenced by dashboarddefaults.businessunitid", + "table": "businessunits", + "targetid": 1 + } + ], + "reverted": 3 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "batchid": "" + } + } + } + } + } + }, + "/api/settings/branding-logo": { + "post": { + "tags": [ + "core-settings" + ], + "summary": "Upload a branding logo image and point the matching setting at it.", + "description": "Upload a branding logo image and point the matching setting at it.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/branding-logo -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "key": "site_favicon", + "value": "/api/settings/branding/logo-favicon.ico" + }, + "message": "Logo uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/settings/branding/{filename}": { + "get": { + "tags": [ + "core-settings" + ], + "summary": "Serve an uploaded branding logo (public - kiosks/print pages read it).", + "description": "Serve an uploaded branding logo (public - kiosks/print pages read it).\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** path: filename (path)\n\n**Example:**\n```\ncurl http://localhost:5001/api/settings/branding/example.png\n```", "security": [], "responses": { "200": { "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/employees/directory": { - "get": { - "tags": [ - "plugin-employees" - ], - "summary": "List the full self-hosted directory for the management page; 400 when directory mode is external.", - "description": "List the full self-hosted directory for the management page; 400 when directory mode is external.\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/employees/directory'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-employees" - ], - "summary": "Create a self-hosted directory employee; 409 if SSO exists, 400 in external mode.", - "description": "Create a self-hosted directory employee; 409 if SSO exists, 400 in external mode.\n\n**Auth:** jwt + require_role admin\n\n**Params:** JSON body: sso (numeric, required), firstname, lastname (required), team, role, picture (also accepts external-style keys SSO/First_Name/Last_Name/Team/Role/Picture)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"sso\":210009518,\"firstname\":\"Jane\",\"lastname\":\"Doe\",\"team\":\"CNC\",\"role\":\"Machinist\"}' 'http://localhost:5001/api/employees/directory'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "JSON body: sso (numeric, required), firstname, lastname (required), team, role, picture (also accepts external-style keys SSO/First_Name/Last_Name/Team/Role/Picture)" - } - } - } - } - } - }, - "/api/employees/directory/{sso}": { - "put": { - "tags": [ - "plugin-employees" - ], - "summary": "Update a self-hosted directory employee's name/team/role/picture; 404 if missing, 400 in external mode.", - "description": "Update a self-hosted directory employee's name/team/role/picture; 404 if missing, 400 in external mode.\n\n**Auth:** jwt + require_role admin\n\n**Params:** sso (path); JSON body: firstname, lastname, team, role, picture (external-style keys also accepted; team/role/picture can be cleared)\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"team\":\"Quality\"}' 'http://localhost:5001/api/employees/directory/210009518'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "sso", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "sso (path); JSON body: firstname, lastname, team, role, picture (external-style keys also accepted; team/role/picture can be cleared)" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-employees" - ], - "summary": "Delete a self-hosted directory employee; 404 if missing, 400 in external mode.", - "description": "Delete a self-hosted directory employee; 404 if missing, 400 in external mode.\n\n**Auth:** jwt + require_role admin\n\n**Params:** sso (path)\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/employees/directory/210009518'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "sso", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/employees/directory/import": { - "post": { - "tags": [ - "plugin-employees" - ], - "summary": "Bulk upsert the self-hosted directory from CSV (headers SSO,First_Name,Last_Name,Team,Role,Picture case-insensitive...", - "description": "Bulk upsert the self-hosted directory from CSV (headers SSO,First_Name,Last_Name,Team,Role,Picture case-insensitive; plain firstname/lastname also accepted); returns added/updated/skipped counts.\n\n**Auth:** jwt + require_role admin\n\n**Params:** multipart file= OR JSON body {\"csv\": \"...\"}; rows missing numeric sso or names are skipped\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -F 'file=@employees.csv' 'http://localhost:5001/api/employees/directory/import'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "multipart file= OR JSON body {\"csv\": \"...\"}; rows missing numeric sso or names are skipped" - } - } - } - } - } - }, - "/api/employees/{sso}/photo": { - "post": { - "tags": [ - "plugin-employees" - ], - "summary": "Upload or replace a self-hosted employee's photo (saved as photo- in instance employeephotos dir; old file...", - "description": "Upload or replace a self-hosted employee's photo (saved as photo- in instance employeephotos dir; old file wiped even on extension change); 409 in external mode, 404 if employee missing.\n\n**Auth:** jwt + require_role admin\n\n**Params:** sso (path); multipart/form-data file=, extensions .png/.jpg/.jpeg/.gif/.webp only\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -F 'file=@jane.jpg' 'http://localhost:5001/api/employees/210009518/photo'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "sso", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "sso (path); multipart/form-data file=, extensions .png/.jpg/.jpeg/.gif/.webp only" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-employees" - ], - "summary": "Clear a self-hosted employee's photo record and delete the uploaded file; 409 in external mode, 404 if employee missing.", - "description": "Clear a self-hosted employee's photo record and delete the uploaded file; 409 in external mode, 404 if employee missing.\n\n**Auth:** jwt + require_role admin\n\n**Params:** sso (path)\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/employees/210009518/photo'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "sso", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/employees/photo/{filename}": { - "get": { - "tags": [ - "plugin-employees" - ], - "summary": "Serve an uploaded employee photo file from the instance employeephotos dir (public so kiosk recognition cards can read...", - "description": "Serve an uploaded employee photo file from the instance employeephotos dir (public so kiosk recognition cards can read it).\n\n**Auth:** none\n\n**Params:** filename (path, e.g. photo-210009518.jpg)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/employees/photo/photo-210009518.jpg'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -4290,95 +12547,13 @@ ] } }, - "/api/employees/resolve/{sso}": { - "get": { - "tags": [ - "plugin-employees" - ], - "summary": "Resolve one SSO to a name and photo, and say HOW it resolved or why it did not. Built because the failure is otherwise...", - "description": "Resolve one SSO to a name and photo, and say HOW it resolved or why it did not. Built because the failure is otherwise invisible: the shop-floor board resolves names through functions that returned None on any miss, so a directory problem and a typo looked identical\n\n**Auth:** jwt\n\n**Params:** sso in path\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/employees/resolve/123456789\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "sso", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/knowledgebase": { - "get": { - "tags": [ - "plugin-knowledgebase" - ], - "summary": "List active KB articles with search, topic filter, natural-key lookup, sorting, and pagination; each item includes its...", - "description": "List active KB articles with search, topic filter, natural-key lookup, sorting, and pagination; each item includes its linked application (appid/appname) or null.\n\n**Auth:** jwt-optional\n\n**Params:** query: page, per_page, search (ILIKE on shortdescription/keywords/application name), appid (int filter), linkurl (exact natural-key match), shortdescription (exact match), sort (clicks|topic|description|lastupdated, default clicks), order (asc|desc, default desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/knowledgebase?search=printer&sort=clicks&order=desc&page=1&per_page=20'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, + "/api/settings/map-blueprint": { "post": { "tags": [ - "plugin-knowledgebase" + "core-settings" ], - "summary": "Create a new KB article; validates required fields and that appid (if given) exists; honors X-Import-Mode timestamp...", - "description": "Create a new KB article; validates required fields and that appid (if given) exists; honors X-Import-Mode timestamp preservation via apply_import_timestamps; returns 201.\n\n**Auth:** permission:kb.create (jwt required)\n\n**Params:** body JSON: shortdescription (required), linkurl (required), appid (optional int, must exist), keywords (optional); import-mode may pass timestamp fields\n\n**Example:**\n```\ncurl -X POST 'http://localhost:5001/api/knowledgebase' -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"shortdescription\":\"VPN setup guide\",\"linkurl\":\"https://wiki.example.com/vpn\",\"appid\":3,\"keywords\":\"vpn,remote\"}'\n```", + "summary": "Upload a floor-map blueprint image and point the setting at it.", + "description": "Upload a floor-map blueprint image and point the setting at it.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/map-blueprint -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -4389,7 +12564,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -4415,2417 +12592,29 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body JSON: shortdescription (required), linkurl (required), appid (optional int, must exist), keywords (optional); import-mode may pass timestamp fields" + "description": "body: JSON (fields not yet documented)" } } } } } }, - "/api/knowledgebase/stats": { + "/api/settings/map-blueprint/{filename}": { "get": { "tags": [ - "plugin-knowledgebase" + "core-settings" ], - "summary": "Return aggregate stats for active articles: totalclicks (sum of clicks) and totalarticles (count).", - "description": "Return aggregate stats for active articles: totalclicks (sum of clicks) and totalarticles (count).\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/knowledgebase/stats'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/knowledgebase/{link_id}": { - "get": { - "tags": [ - "plugin-knowledgebase" - ], - "summary": "Fetch a single active article by id with its application (appid/appname) or null; 404 if missing or inactive.", - "description": "Fetch a single active article by id with its application (appid/appname) or null; 404 if missing or inactive.\n\n**Auth:** jwt-optional\n\n**Params:** path: link_id (int)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/knowledgebase/42'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "link_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - }, - "put": { - "tags": [ - "plugin-knowledgebase" - ], - "summary": "Update an article's shortdescription, linkurl, appid, keywords, and/or isactive; validates appid if changed; honors...", - "description": "Update an article's shortdescription, linkurl, appid, keywords, and/or isactive; validates appid if changed; honors import timestamps; 404 if article missing.\n\n**Auth:** permission:kb.edit (jwt required)\n\n**Params:** path: link_id (int); body JSON: any of shortdescription, linkurl, appid, keywords, isactive\n\n**Example:**\n```\ncurl -X PUT 'http://localhost:5001/api/knowledgebase/42' -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"keywords\":\"vpn,zscaler\",\"isactive\":true}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "link_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: link_id (int); body JSON: any of shortdescription, linkurl, appid, keywords, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-knowledgebase" - ], - "summary": "Soft-delete an article by setting isactive=false (row is retained); 404 if article missing.", - "description": "Soft-delete an article by setting isactive=false (row is retained); 404 if article missing.\n\n**Auth:** permission:kb.delete (jwt required)\n\n**Params:** path: link_id (int)\n\n**Example:**\n```\ncurl -X DELETE 'http://localhost:5001/api/knowledgebase/42' -H \"Authorization: Bearer $TOKEN\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "link_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/knowledgebase/{link_id}/click": { - "post": { - "tags": [ - "plugin-knowledgebase" - ], - "summary": "Increment the article's click counter and return the target linkurl plus new click count (used for redirect tracking)...", - "description": "Increment the article's click counter and return the target linkurl plus new click count (used for redirect tracking); 404 if missing or inactive.\n\n**Auth:** jwt-optional\n\n**Params:** path: link_id (int); no body\n\n**Example:**\n```\ncurl -X POST 'http://localhost:5001/api/knowledgebase/42/click'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "link_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: link_id (int); no body" - } - } - } - } - } - }, - "/api/assets/types": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List asset types (paginated, active-only by default)", - "description": "List asset types (paginated, active-only by default)\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active=false to include inactive\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/types?active=false\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Create asset type (409 on duplicate name)", - "description": "Create asset type (409 on duplicate name)\n\n**Auth:** permission:assets.create\n\n**Params:** body: assettype (req), pluginname, tablename, description, icon, color\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/assets/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"assettype\":\"Robot\",\"icon\":\"mdi-robot\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: assettype (req), pluginname, tablename, description, icon, color" - } - } - } - } - } - }, - "/api/assets/types/{type_id}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Get one asset type", - "description": "Get one asset type\n\n**Auth:** jwt-optional\n\n**Params:** path: type_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/types/1\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update asset type display fields only (name/plugin/table are structural, not editable)", - "description": "Update asset type display fields only (name/plugin/table are structural, not editable)\n\n**Auth:** permission:assets.edit\n\n**Params:** body: description, icon, color, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/assets/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"color\":\"#ff0000\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: description, icon, color, isactive" - } - } - } - } - } - }, - "/api/assets/statuses": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List asset statuses (paginated, active-only by default)", - "description": "List asset statuses (paginated, active-only by default)\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active=false\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/statuses\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Create asset status (409 on duplicate)", - "description": "Create asset status (409 on duplicate)\n\n**Auth:** permission:assets.create\n\n**Params:** body: status (req), description, color\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/assets/statuses -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"status\":\"In Repair\",\"color\":\"#f90\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: status (req), description, color" - } - } - } - } - } - }, - "/api/assets/statuses/{status_id}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Get one asset status", - "description": "Get one asset status\n\n**Auth:** jwt-optional\n\n**Params:** path: status_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/statuses/1\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "status_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update asset status (rename conflict-checked)", - "description": "Update asset status (rename conflict-checked)\n\n**Auth:** permission:assets.edit\n\n**Params:** body: status, description, color, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/assets/statuses/2 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"isactive\":false}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "status_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: status, description, color, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Hard-delete asset status; 409 if any asset still uses it", - "description": "Hard-delete asset status; 409 if any asset still uses it\n\n**Auth:** permission:assets.delete\n\n**Params:** path: status_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/assets/statuses/9 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "status_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/assets/relationshiptypes": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List relationship types incl. read-only propagatesthrough rails", - "description": "List relationship types incl. read-only propagatesthrough rails\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/relationshiptypes\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Create relationship type (409 on duplicate)", - "description": "Create relationship type (409 on duplicate)\n\n**Auth:** permission:assets.create\n\n**Params:** body: relationshiptype (req), description, color, isdirectional (default true)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/assets/relationshiptypes -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"relationshiptype\":\"controls\",\"isdirectional\":true}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: relationshiptype (req), description, color, isdirectional (default true)" - } - } - } - } - } - }, - "/api/assets/relationshiptypes/{type_id}": { - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update relationship type (rename conflict-checked)", - "description": "Update relationship type (rename conflict-checked)\n\n**Auth:** permission:assets.edit\n\n**Params:** body: relationshiptype, description, color, isdirectional\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/assets/relationshiptypes/3 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"color\":\"#00f\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: relationshiptype, description, color, isdirectional" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Hard-delete relationship type; 409 while relationships use it", - "description": "Hard-delete relationship type; 409 while relationships use it\n\n**Auth:** permission:assets.delete\n\n**Params:** path: type_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/assets/relationshiptypes/3 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/assets": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List assets with filtering, search, sorting, pagination", - "description": "List assets with filtering, search, sorting, pagination\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, search (assetnumber/name/serialnumber ilike), type (name), typeid|type_id, statusid|status_id, locationid|location_id, businessunitid|businessunit_id, sort (assetnumber|name|createddate|modifieddate), dir (asc|desc), include_type_data\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/assets?type=machine&search=205&include_type_data=true'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Create asset (duplicate assetnumber 409, assettypeid validated); honors X-Import-Mode timestamps", - "description": "Create asset (duplicate assetnumber 409, assettypeid validated); honors X-Import-Mode timestamps\n\n**Auth:** permission:assets.create\n\n**Params:** body: assetnumber (req), assettypeid (req), name, serialnumber, statusid (default 1), locationid, businessunitid, mapx, mapy, notes\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/assets -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"assetnumber\":\"0205\",\"assettypeid\":1,\"name\":\"Grinder 5\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: assetnumber (req), assettypeid (req), name, serialnumber, statusid (default 1), locationid, businessunitid, mapx, mapy, notes" - } - } - } - } - } - }, - "/api/assets/{asset_id}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Get one asset with full details", - "description": "Get one asset with full details\n\n**Auth:** jwt-optional\n\n**Params:** include_type_data (default true)\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/42\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update asset (allowed fields incl. isactive); honors X-Import-Mode", - "description": "Update asset (allowed fields incl. isactive); honors X-Import-Mode\n\n**Auth:** permission:assets.edit\n\n**Params:** body: assetnumber, name, serialnumber, assettypeid, statusid, locationid, businessunitid, mapx, mapy, notes, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/assets/42 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"locationid\":3}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: assetnumber, name, serialnumber, assettypeid, statusid, locationid, businessunitid, mapx, mapy, notes, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Soft-delete asset (isactive=false)", - "description": "Soft-delete asset (isactive=false)\n\n**Auth:** permission:assets.delete\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/assets/42 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/assets/lookup/{assetnumber}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Look up an active asset by asset number (returns full type data)", - "description": "Look up an active asset by asset number (returns full type data)\n\n**Auth:** jwt-optional\n\n**Params:** path: assetnumber (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/lookup/0205\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "assetnumber", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/assets/{asset_id}/relationships": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Get outgoing + incoming active relationships for an asset with partner asset dicts", - "description": "Get outgoing + incoming active relationships for an asset with partner asset dicts\n\n**Auth:** jwt-optional\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/42/relationships\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/assets/relationships": { - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Create relationship, then fan out across symmetric propagation rails (Dualpath); response carries propagated...", - "description": "Create relationship, then fan out across symmetric propagation rails (Dualpath); response carries propagated + propagatedcount\n\n**Auth:** permission:assets.create\n\n**Params:** body: sourceassetid (req), targetassetid (req), relationshiptypeid (req), notes; X-Import-Mode honored\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/assets/relationships -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"sourceassetid\":10,\"targetassetid\":42,\"relationshiptypeid\":1}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: sourceassetid (req), targetassetid (req), relationshiptypeid (req), notes; X-Import-Mode honored" - } - } - } - } - } - }, - "/api/assets/relationships/{rel_id}": { - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Soft-delete one relationship row (no cascade to propagated partner rows)", - "description": "Soft-delete one relationship row (no cascade to propagated partner rows)\n\n**Auth:** permission:assets.delete\n\n**Params:** path: rel_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/assets/relationships/7 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "rel_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/assets/map": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Unified floor-map payload: all mapped assets (with type data, primary IP, dualpath collapse) plus filter option lists", - "description": "Unified floor-map payload: all mapped assets (with type data, primary IP, dualpath collapse) plus filter option lists\n\n**Auth:** jwt-optional\n\n**Params:** assettype (name), subtype (id, per-type), businessunitid, statusid, locationid, search\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/assets/map?assettype=machine&statusid=1'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/assets/{asset_id}/communications": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List active communications (IPs etc.) for an asset with comtype_name", - "description": "List active communications (IPs etc.) for an asset with comtype_name\n\n**Auth:** jwt-optional\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/assets/42/communications\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/locations/types": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List location types", - "description": "List location types\n\n**Auth:** jwt-optional\n\n**Params:** active=false includes inactive\n\n**Example:**\n```\ncurl http://localhost:5001/api/locations/types\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Create location type; reactivates a soft-deleted same-name type instead of 409", - "description": "Create location type; reactivates a soft-deleted same-name type instead of 409\n\n**Auth:** admin\n\n**Params:** body: locationtype (req), description, color\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/locations/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"locationtype\":\"Operation\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: locationtype (req), description, color" - } - } - } - } - } - }, - "/api/locations/types/{type_id}": { - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update location type (rename conflict-checked)", - "description": "Update location type (rename conflict-checked)\n\n**Auth:** admin\n\n**Params:** body: locationtype, description, color, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/locations/types/2 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"color\":\"#0a0\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: locationtype, description, color, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Hard-delete location type; 409 while locations use it", - "description": "Hard-delete location type; 409 while locations use it\n\n**Auth:** admin\n\n**Params:** path: type_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/locations/types/2 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/locations": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List locations (paginated); exact locationname lookup for idempotent import", - "description": "List locations (paginated); exact locationname lookup for idempotent import\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, locationname (exact), search (name/building ilike)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/locations?locationname=Building%201'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Create location (409 on duplicate name); honors X-Import-Mode", - "description": "Create location (409 on duplicate name); honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: locationname (req), building, floor, room, description, locationtypeid, parentlocationid, mapimage, mapwidth, mapheight\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/locations -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"locationname\":\"Cell 12\",\"building\":\"B1\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: locationname (req), building, floor, room, description, locationtypeid, parentlocationid, mapimage, mapwidth, mapheight" - } - } - } - } - } - }, - "/api/locations/{location_id}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Get one location", - "description": "Get one location\n\n**Auth:** jwt-optional\n\n**Params:** path: location_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/locations/3\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "location_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update location (rename conflict-checked); honors X-Import-Mode", - "description": "Update location (rename conflict-checked); honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: locationname, building, floor, room, description, locationtypeid, parentlocationid, mapimage, mapwidth, mapheight, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/locations/3 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"room\":\"104\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "location_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: locationname, building, floor, room, description, locationtypeid, parentlocationid, mapimage, mapwidth, mapheight, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Soft-delete location", - "description": "Soft-delete location\n\n**Auth:** admin\n\n**Params:** path: location_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/locations/3 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "location_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/vendors": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List vendors (paginated); exact vendor lookup for idempotent import", - "description": "List vendors (paginated); exact vendor lookup for idempotent import\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, vendor (exact), search (ilike)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/vendors?search=fanuc'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Create vendor (409 on duplicate); honors X-Import-Mode", - "description": "Create vendor (409 on duplicate); honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: vendor (req), description, website, supportphone, supportemail, notes\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/vendors -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"vendor\":\"Fanuc\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: vendor (req), description, website, supportphone, supportemail, notes" - } - } - } - } - } - }, - "/api/vendors/{vendor_id}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Get one vendor", - "description": "Get one vendor\n\n**Auth:** jwt-optional\n\n**Params:** path: vendor_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/vendors/5\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "vendor_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update vendor (rename conflict-checked); honors X-Import-Mode", - "description": "Update vendor (rename conflict-checked); honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: vendor, description, website, supportphone, supportemail, notes, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/vendors/5 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"website\":\"https://fanuc.com\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "vendor_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: vendor, description, website, supportphone, supportemail, notes, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Soft-delete vendor", - "description": "Soft-delete vendor\n\n**Auth:** admin\n\n**Params:** path: vendor_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/vendors/5 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "vendor_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/models": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List vendor catalog models (paginated) with flattened vendor/modeltype names; exact modelnumber+vendor lookup for import", - "description": "List vendor catalog models (paginated) with flattened vendor/modeltype names; exact modelnumber+vendor lookup for import\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, vendor (id), modeltype (id), modelnumber (exact), search (ilike)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/models?vendor=5&search=30i'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Create model (409 on duplicate modelnumber+vendorid); honors X-Import-Mode", - "description": "Create model (409 on duplicate modelnumber+vendorid); honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: modelnumber (req), vendorid, modeltypeid, description, imageurl, documentationurl, notes\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/models -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"modelnumber\":\"R-30iB\",\"vendorid\":5}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: modelnumber (req), vendorid, modeltypeid, description, imageurl, documentationurl, notes" - } - } - } - } - } - }, - "/api/models/{model_id}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Get one model with nested vendor + modeltype dicts", - "description": "Get one model with nested vendor + modeltype dicts\n\n**Auth:** jwt-optional\n\n**Params:** path: model_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/models/12\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "model_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update model; honors X-Import-Mode", - "description": "Update model; honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: modelnumber, vendorid, modeltypeid, description, imageurl, documentationurl, notes, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/models/12 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"notes\":\"EOL 2027\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "model_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: modelnumber, vendorid, modeltypeid, description, imageurl, documentationurl, notes, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Soft-delete model", - "description": "Soft-delete model\n\n**Auth:** admin\n\n**Params:** path: model_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/models/12 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "model_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/models/{model_id}/image": { - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Upload/replace model photo (saved as instance/modelimages/model-, one per model); sets imageurl", - "description": "Upload/replace model photo (saved as instance/modelimages/model-, one per model); sets imageurl\n\n**Auth:** admin\n\n**Params:** multipart/form-data: file= (.png/.jpg/.jpeg/.gif/.webp/.svg)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/models/12/image -H \"Authorization: Bearer $TOK\" -F file=@robot.jpg\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "model_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "multipart/form-data: file= (.png/.jpg/.jpeg/.gif/.webp/.svg)" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Clear imageurl and delete the uploaded file only if it lives under /api/models/image/ (external URLs untouched)", - "description": "Clear imageurl and delete the uploaded file only if it lives under /api/models/image/ (external URLs untouched)\n\n**Auth:** admin\n\n**Params:** path: model_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/models/12/image -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "model_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/models/image/{filename}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Serve an uploaded model image (deliberately public - asset detail pages read it without auth)", - "description": "Serve an uploaded model image (deliberately public - asset detail pages read it without auth)\n\n**Auth:** none\n\n**Params:** path: filename\n\n**Example:**\n```\ncurl http://localhost:5001/api/models/image/model-12.jpg\n```", + "summary": "Serve an uploaded blueprint image (public - kiosks read it).", + "description": "Serve an uploaded blueprint image (public - kiosks read it).\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** path: filename (path)\n\n**Example:**\n```\ncurl http://localhost:5001/api/settings/map-blueprint/example.png\n```", "security": [], "responses": { "200": { "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -6847,21 +12636,21 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "description": "may contain slashes" } } ] } }, - "/api/modeltypes": { - "get": { + "/api/settings/seed": { + "post": { "tags": [ - "core-catalog" + "core-settings" ], - "summary": "List model types (types the vendor MODELS catalog, not machines); exact modeltype lookup for import", - "description": "List model types (types the vendor MODELS catalog, not machines); exact modeltype lookup for import\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, category, modeltype (exact), search (ilike)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/modeltypes?category=Equipment'\n```", + "summary": "Seed default settings if they don't exist.", + "description": "Seed default settings if they don't exist.\n\n**Auth:** permission:settings.edit\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/seed -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ - {}, { "bearerAuth": [] } @@ -6871,7 +12660,20 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "created": 112 + }, + "message": "112 default settings created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -6882,15 +12684,394 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": {} + } + } + } + } + }, + "/api/settings/test-email": { + "post": { + "tags": [ + "core-settings" + ], + "summary": "Send a test email to verify SMTP configuration.", + "description": "Send a test email to verify SMTP configuration.\n\n**Auth:** permission:settings.edit\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/test-email -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "error": "[Errno -2] Name or service not known", + "sent": false + }, + "message": "Test email failed: [Errno -2] Name or service not known", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "to": "" + } + } + } + } + } + }, + "/api/setup/complete": { + "post": { + "tags": [ + "core-setup" + ], + "summary": "Flag the first-run setup as finished.", + "description": "Flag the first-run setup as finished.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/setup/complete -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/setup/create-admin": { + "post": { + "tags": [ + "core-setup" + ], + "summary": "Create the first admin. Only works while there are zero users, setup has not been marked complete, and the caller...", + "description": "Create the first admin. Only works while there are zero users, setup has not been marked complete, and the caller proves operator control (setup token or a loopback request).\n\n**Auth:** none (first run only: refused once an admin exists)\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/setup/create-admin -H 'Content-Type: application/json' -d '{}'\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/setup/needs-admin": { + "get": { + "tags": [ + "core-setup" + ], + "summary": "True when no users exist yet - the app's very first run. Public so the login screen can offer to create the first admin.", + "description": "True when no users exist yet - the app's very first run. Public so the login screen can offer to create the first admin.\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/setup/needs-admin\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "needsadmin": false + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + }, + "/api/setup/seed-reference": { + "post": { + "tags": [ + "core-setup" + ], + "summary": "Seed core reference data + permissions + default settings. Idempotent - reuses the same routines as the `flask seed...", + "description": "Seed core reference data + permissions + default settings. Idempotent - reuses the same routines as the `flask seed ...` CLI commands.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/setup/seed-reference -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/setup/seed-starter": { + "post": { + "tags": [ + "core-setup" + ], + "summary": "Add common vendors that are not already present. Idempotent.", + "description": "Add common vendors that are not already present. Idempotent.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/setup/seed-starter -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/shopdbbackups": { + "get": { + "tags": [ + "core-shopdbbackups" + ], + "summary": "List every retained backup set, newest first, with a compatibility flag.", + "description": "List every retained backup set, newest first, with a compatibility flag.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/shopdbbackups -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "count": 0, + "lastgoodat": null, + "requestid": "00000000", + "retention": { + "keepdaily": 5, + "keepmonthly": 6, + "keepweekly": 4 + }, + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } } }, "post": { "tags": [ - "core-catalog" + "core-shopdbbackups" ], - "summary": "Create model type (409 on duplicate); category defaults to Equipment; honors X-Import-Mode", - "description": "Create model type (409 on duplicate); category defaults to Equipment; honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: modeltype (req), category, description, icon\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/modeltypes -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"modeltype\":\"Controller\"}'\n```", + "summary": "Kick off an on-demand backup run.", + "description": "Kick off an on-demand backup run.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/shopdbbackups -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -6901,7 +13082,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -6927,64 +13110,20 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: modeltype (req), category, description, icon" + "description": "body: JSON (fields not yet documented)" } } } } } }, - "/api/modeltypes/{type_id}": { + "/api/shopdbbackups/{snapshotid}": { "get": { "tags": [ - "core-catalog" + "core-shopdbbackups" ], - "summary": "Get one model type", - "description": "Get one model type\n\n**Auth:** jwt-optional\n\n**Params:** path: type_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/modeltypes/2\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update model type (rename conflict-checked); honors X-Import-Mode", - "description": "Update model type (rename conflict-checked); honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: modeltype, category, description, icon, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/modeltypes/2 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"icon\":\"mdi-chip\"}'\n```", + "summary": "Return one set's full manifest plus a resolved compatibility verdict.", + "description": "Return one set's full manifest plus a resolved compatibility verdict.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: snapshotid (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/shopdbbackups/x -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -6995,7 +13134,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -7019,68 +13160,7 @@ }, "parameters": [ { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: modeltype, category, description, icon, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Soft-delete model type; 409 while models use it", - "description": "Soft-delete model type; 409 while models use it\n\n**Auth:** admin\n\n**Params:** path: type_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/modeltypes/2 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", + "name": "snapshotid", "in": "path", "required": true, "schema": { @@ -7090,44 +13170,13 @@ ] } }, - "/api/businessunits": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List business units (paginated); exact businessunit lookup for import", - "description": "List business units (paginated); exact businessunit lookup for import\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, businessunit (exact), search (name/code ilike)\n\n**Example:**\n```\ncurl http://localhost:5001/api/businessunits\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, + "/api/shopdbbackups/{snapshotid}/restore": { "post": { "tags": [ - "core-catalog" + "core-shopdbbackups" ], - "summary": "Create business unit (409 on duplicate); honors X-Import-Mode", - "description": "Create business unit (409 on duplicate); honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: businessunit (req), code, description, parentid\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/businessunits -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"businessunit\":\"Blades\",\"code\":\"BLD\"}'\n```", + "summary": "Queue a restore of a set, gated on schema compatibility.", + "description": "Queue a restore of a set, gated on schema compatibility.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: snapshotid (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/shopdbbackups/x/restore -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -7138,101 +13187,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: businessunit (req), code, description, parentid" - } - } - } - } - } - }, - "/api/businessunits/{bu_id}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Get one business unit with parent + children", - "description": "Get one business unit with parent + children\n\n**Auth:** jwt-optional\n\n**Params:** path: bu_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/businessunits/1\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "bu_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update business unit (rename conflict-checked); honors X-Import-Mode", - "description": "Update business unit (rename conflict-checked); honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: businessunit, code, description, parentid, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/businessunits/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"code\":\"BL\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -7256,7 +13213,7 @@ }, "parameters": [ { - "name": "bu_id", + "name": "snapshotid", "in": "path", "required": true, "schema": { @@ -7271,100 +13228,20 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: businessunit, code, description, parentid, isactive" + "description": "path: snapshotid (string); body: JSON (fields not yet documented)" } } } } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Soft-delete business unit", - "description": "Soft-delete business unit\n\n**Auth:** admin\n\n**Params:** path: bu_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/businessunits/1 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "bu_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] } }, - "/api/operatingsystems": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List operating systems (paginated); exact osname/osversion lookup for import", - "description": "List operating systems (paginated); exact osname/osversion lookup for import\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, osname (exact), osversion (exact), search (osname ilike)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/operatingsystems?osname=Windows%2011'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, + "/api/shopdbbackups/finalize-restore": { "post": { "tags": [ - "core-catalog" + "core-shopdbbackups" ], - "summary": "Create OS (409 on duplicate osname+osversion); honors X-Import-Mode", - "description": "Create OS (409 on duplicate osname+osversion); honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: osname (req), osversion, architecture, endoflife\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/operatingsystems -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"osname\":\"Windows 11\",\"osversion\":\"24H2\"}'\n```", + "summary": "Run the post-restore schema upgrade in the app context.", + "description": "Run the post-restore schema upgrade in the app context.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/shopdbbackups/finalize-restore -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -7375,7 +13252,21 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "queued": true, + "snapshotid": "wj-2026-09-06T120000Z" + }, + "message": "Schema upgrade queued.", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -7401,22 +13292,21 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: osname (req), osversion, architecture, endoflife" + "description": "body: JSON (fields not yet documented)" } } } } } }, - "/api/operatingsystems/{os_id}": { + "/api/shopdbbackups/history": { "get": { "tags": [ - "core-catalog" + "core-shopdbbackups" ], - "summary": "Get one operating system", - "description": "Get one operating system\n\n**Auth:** jwt-optional\n\n**Params:** path: os_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/operatingsystems/4\n```", + "summary": "Return the recorded history of backup and restore runs, newest first.", + "description": "Return the recorded history of backup and restore runs, newest first.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/shopdbbackups/history -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -7426,7 +13316,20 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "backups": [], + "restores": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -7438,27 +13341,83 @@ } } }, - "404": { - "description": "No such record." + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } - }, - "parameters": [ + } + } + }, + "/api/shopdbbackups/settings": { + "get": { + "tags": [ + "core-shopdbbackups" + ], + "summary": "Return destination + retention config for the settings pane.", + "description": "Return destination + retention config for the settings pane.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/shopdbbackups/settings -H \"Authorization: Bearer $TOK\"\n```", + "security": [ { - "name": "os_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } + "bearerAuth": [] } - ] + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "backupsroot": "/backups", + "destinationkind": "local", + "destinationkinds": [ + "local", + "smb", + "aws" + ], + "fields": {}, + "isarmed": true, + "retention": { + "keepdaily": 5, + "keepmonthly": 6, + "keepweekly": 4 + } + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } }, "put": { "tags": [ - "core-catalog" + "core-shopdbbackups" ], - "summary": "Update OS; honors X-Import-Mode", - "description": "Update OS; honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: osname, osversion, architecture, endoflife, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/operatingsystems/4 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"endoflife\":\"2031-10-14\"}'\n```", + "summary": "Persist the backup destination kind and its credential/config fields.", + "description": "Persist the backup destination kind and its credential/config fields.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/shopdbbackups/settings -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -7469,7 +13428,32 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "backupsroot": "/backups", + "destinationkind": "local", + "destinationkinds": [ + "local", + "smb", + "aws" + ], + "fields": {}, + "isarmed": true, + "retention": { + "keepdaily": 5, + "keepmonthly": 6, + "keepweekly": 4 + } + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -7486,21 +13470,8 @@ }, "403": { "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." } }, - "parameters": [ - { - "name": "os_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], "requestBody": { "required": true, "content": { @@ -7508,100 +13479,21 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: osname, osversion, architecture, endoflife, isactive" - } + "description": "body: JSON (fields not yet documented)" + }, + "example": {} } } } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Soft-delete OS", - "description": "Soft-delete OS\n\n**Auth:** admin\n\n**Params:** path: os_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/operatingsystems/4 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "os_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] } }, - "/api/customfields": { + "/api/shopdbbackups/status": { "get": { "tags": [ - "core-catalog" + "core-shopdbbackups" ], - "summary": "List custom-field definitions, ordered by sortorder", - "description": "List custom-field definitions, ordered by sortorder\n\n**Auth:** jwt-optional\n\n**Params:** assettypeid (int), active=false includes inactive\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/customfields?assettypeid=1'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Create field definition; fieldkey auto-slugged from label; reactivates a soft-deleted same-key field instead of 409", - "description": "Create field definition; fieldkey auto-slugged from label; reactivates a soft-deleted same-key field instead of 409\n\n**Auth:** admin\n\n**Params:** body: assettypeid (req), label (req), datatype (one of CUSTOM_FIELD_DATATYPES, default text), fieldkey, options (list or newline/comma text), showondetail, showonform, searchable, sortorder\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/customfields -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"assettypeid\":1,\"label\":\"Coolant Type\",\"datatype\":\"select\",\"options\":[\"Oil\",\"Water\"]}'\n```", + "summary": "Return the live status of the most recent backup and restore runs.", + "description": "Return the live status of the most recent backup and restore runs.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/shopdbbackups/status -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -7612,7 +13504,20 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "backup": null, + "restore": null + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -7630,249 +13535,17 @@ "403": { "description": "Authenticated, but not permitted." } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: assettypeid (req), label (req), datatype (one of CUSTOM_FIELD_DATATYPES, default text), fieldkey, options (list or newline/comma text), showondetail, showonform, searchable, sortorder" - } - } - } - } - } - }, - "/api/customfields/{fieldid}": { - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update field definition (label/datatype/options/flags/sortorder)", - "description": "Update field definition (label/datatype/options/flags/sortorder)\n\n**Auth:** admin\n\n**Params:** body: label, datatype, options, showondetail, showonform, isactive, searchable, sortorder\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/customfields/7 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"searchable\":true}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "fieldid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: label, datatype, options, showondetail, showonform, isactive, searchable, sortorder" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Hard-delete field definition AND all stored values for it", - "description": "Hard-delete field definition AND all stored values for it\n\n**Auth:** admin\n\n**Params:** path: fieldid\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/customfields/7 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "fieldid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/customfields/asset/{assetid}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Active field defs for the asset's type merged with the asset's stored values", - "description": "Active field defs for the asset's type merged with the asset's stored values\n\n**Auth:** jwt-optional\n\n**Params:** path: assetid\n\n**Example:**\n```\ncurl http://localhost:5001/api/customfields/asset/42\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "assetid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Upsert per-asset values; empty string clears a value; only fields of the asset's type accepted", - "description": "Upsert per-asset values; empty string clears a value; only fields of the asset's type accepted\n\n**Auth:** admin\n\n**Params:** body: {values: {fieldid: value, ...}}\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/customfields/asset/42 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"values\":{\"7\":\"Oil\"}}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "assetid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: {values: {fieldid: value, ...}}" - } - } - } } } }, "/api/supportteams": { "get": { "tags": [ - "core-catalog" + "core-supportteams" ], - "summary": "List support teams with contacts; exact teamname lookup for import (not paginated)", - "description": "List support teams with contacts; exact teamname lookup for import (not paginated)\n\n**Auth:** jwt-optional\n\n**Params:** active, teamname (exact)\n\n**Example:**\n```\ncurl http://localhost:5001/api/supportteams\n```", + "summary": "List support teams (with contacts). ?active and ?teamname filters.", + "description": "List support teams (with contacts). ?active and ?teamname filters.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/supportteams -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -7882,7 +13555,28 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": false, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "supportteamid": 1, + "teamname": "Retired", + "teamurl": null, + "webhookurl": null + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -7893,15 +13587,40 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } - } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "teamname", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] }, "post": { "tags": [ - "core-catalog" + "core-supportteams" ], - "summary": "Create support team (409 on duplicate); audit-logged; honors X-Import-Mode", - "description": "Create support team (409 on duplicate); audit-logged; honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: teamname (req), teamurl, webhookurl, isactive\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/supportteams -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"teamname\":\"CNC Support\"}'\n```", + "summary": "Create a new support team.", + "description": "Create a new support team.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/supportteams -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -7912,7 +13631,27 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "supportteamid": 1, + "teamname": "Controls", + "teamurl": "https://servicenow.example/group/controls", + "webhookurl": null + }, + "message": "Support team created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -7938,7 +13677,12 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: teamname (req), teamurl, webhookurl, isactive" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "createddate": "2026-01-01 00:00:00", + "modifieddate": "2026-01-01T00:00:00", + "teamname": "Legacy" } } } @@ -7946,14 +13690,13 @@ } }, "/api/supportteams/{team_id}": { - "get": { + "delete": { "tags": [ - "core-catalog" + "core-supportteams" ], - "summary": "Get one support team with contacts", - "description": "Get one support team with contacts\n\n**Auth:** jwt-optional\n\n**Params:** path: team_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/supportteams/2\n```", + "summary": "Delete a support team; 409 while any application references it.", + "description": "Delete a support team; 409 while any application references it.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: team_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/supportteams/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -7963,7 +13706,17 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "message": "Support team deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -7975,6 +13728,12 @@ } } }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, "404": { "description": "No such record." } @@ -7985,17 +13744,106 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "core-supportteams" + ], + "summary": "Get a single support team with its contacts.", + "description": "Get a single support team with its contacts.\n\n**Auth:** permission:assets.view\n\n**Params:** path: team_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/supportteams/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "contacts": [ + { + "contactid": 2, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "First", + "sortorder": 1, + "sso": "111", + "supportteamid": 1 + }, + { + "contactid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Second", + "sortorder": 2, + "sso": "222", + "supportteamid": 1 + } + ], + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "supportteamid": 1, + "teamname": "Controls", + "teamurl": null, + "webhookurl": null + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "team_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" } } ] }, "put": { "tags": [ - "core-catalog" + "core-supportteams" ], - "summary": "Update support team (rename conflict-checked); audit-logged; honors X-Import-Mode", - "description": "Update support team (rename conflict-checked); audit-logged; honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: teamname, teamurl, webhookurl, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/supportteams/2 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"webhookurl\":\"https://hooks/x\"}'\n```", + "summary": "Update a support team.", + "description": "Update a support team.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: team_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/supportteams/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -8006,7 +13854,27 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "contacts": [], + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "supportteamid": 1, + "teamname": "Controls Renamed", + "teamurl": "https://x.example", + "webhookurl": null + }, + "message": "Support team updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -8034,7 +13902,7 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } ], @@ -8045,69 +13913,24 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: teamname, teamurl, webhookurl, isactive" + "description": "path: team_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "teamname": "Controls Renamed", + "teamurl": "https://x.example" } } } } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Hard-delete team (cascade removes contacts); 409 while applications reference it; audit-logged", - "description": "Hard-delete team (cascade removes contacts); 409 while applications reference it; audit-logged\n\n**Auth:** admin\n\n**Params:** path: team_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/supportteams/2 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "team_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] } }, "/api/supportteams/{team_id}/contacts": { "post": { "tags": [ - "core-catalog" + "core-supportteams" ], - "summary": "Add contact to a team; audit-logged; honors X-Import-Mode", - "description": "Add contact to a team; audit-logged; honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: name (req), sso, sortorder, isactive\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/supportteams/2/contacts -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"name\":\"Jane Doe\",\"sso\":\"212345678\"}'\n```", + "summary": "Add a contact to a support team.", + "description": "Add a contact to a support team.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: team_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/supportteams/1/contacts -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -8118,7 +13941,27 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "contactid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Second", + "sortorder": 2, + "sso": "222", + "supportteamid": 1 + }, + "message": "Contact created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -8146,7 +13989,7 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } ], @@ -8157,7 +14000,12 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: name (req), sso, sortorder, isactive" + "description": "path: team_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "createddate": "2026-01-01 00:00:00", + "modifieddate": "2026-01-01T00:00:00", + "name": "LegacyOwner" } } } @@ -8165,12 +14013,12 @@ } }, "/api/supportteams/{team_id}/contacts/{contact_id}": { - "put": { + "delete": { "tags": [ - "core-catalog" + "core-supportteams" ], - "summary": "Update a team contact; audit-logged; honors X-Import-Mode", - "description": "Update a team contact; audit-logged; honors X-Import-Mode\n\n**Auth:** admin\n\n**Params:** body: name, sso, sortorder, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/supportteams/2/contacts/9 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"sortorder\":1}'\n```", + "summary": "Delete a support-team contact.", + "description": "Delete a support-team contact.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: team_id (int), contact_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/supportteams/1/contacts/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -8181,7 +14029,17 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "message": "Contact deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -8209,7 +14067,7 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } }, { @@ -8217,29 +14075,17 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: name, sso, sortorder, isactive" - } - } - } - } + ] }, - "delete": { + "put": { "tags": [ - "core-catalog" + "core-supportteams" ], - "summary": "Hard-delete a team contact; audit-logged", - "description": "Hard-delete a team contact; audit-logged\n\n**Auth:** admin\n\n**Params:** path: team_id, contact_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/supportteams/2/contacts/9 -H \"Authorization: Bearer $TOK\"\n```", + "summary": "Update a support-team contact.", + "description": "Update a support-team contact.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: team_id (int), contact_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/supportteams/1/contacts/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -8250,7 +14096,27 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "contactid": 2, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "First Updated", + "sortorder": 1, + "sso": "111", + "supportteamid": 1 + }, + "message": "Contact updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -8278,7 +14144,7 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } }, { @@ -8286,21 +14152,35 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } - ] + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: team_id (int), contact_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "name": "First Updated" + } + } + } + } } }, - "/api/applications": { + "/api/users": { "get": { "tags": [ - "core-catalog" + "core-users" ], - "summary": "List applications (paginated) with installedcount; hidden apps excluded unless showhidden=true; exact appname lookup...", - "description": "List applications (paginated) with installedcount; hidden apps excluded unless showhidden=true; exact appname lookup for import\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, showhidden, installable (true/false), appname (exact), search (name/description ilike)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/applications?installable=true'\n```", + "summary": "List all users.", + "description": "List all users.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/users -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -8310,7 +14190,46 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "createddate": "2026-01-01T00:00:00.000000Z", + "directpermissions": [], + "email": "", + "failedlogins": 0, + "firstname": "", + "isactive": true, + "islocked": false, + "lastlogindate": "2026-01-01T00:00:00Z", + "lastname": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "mustchangepassword": "", + "permissions": [ + "apitokens.create", + "applications.create", + "applications.delete", + "" + ], + "roles": [ + { + "color": null, + "roleid": 1, + "rolename": "admin" + } + ], + "userid": 1, + "username": "" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -8321,15 +14240,21 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } } }, "post": { "tags": [ - "core-catalog" + "core-users" ], - "summary": "Create application (409 on duplicate name); audit-logged; honors X-Import-Mode", - "description": "Create application (409 on duplicate name); audit-logged; honors X-Import-Mode\n\n**Auth:** permission:applications.create\n\n**Params:** body: appname (req), appdescription, supportteamid, isinstallable, applicationnotes, installpath, applicationlink, documentationpath, ishidden, isprinter, islicenced, isrequired, image\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/applications -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"appname\":\"PC-DMIS\",\"isinstallable\":true}'\n```", + "summary": "Create a new user.", + "description": "Create a new user.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/users -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -8340,7 +14265,48 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "directpermissions": [ + "endpoints.restart" + ], + "email": "", + "failedlogins": 0, + "firstname": "", + "isactive": true, + "islocked": false, + "lastlogindate": null, + "lastname": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "mustchangepassword": "", + "permissions": [ + "applications.view", + "assets.view", + "backups.download", + "" + ], + "roles": [ + { + "color": "#0DCAF0", + "roleid": 3, + "rolename": "technician" + } + ], + "userid": 2, + "username": "", + "warning": "User created but the welcome email could not be sent." + }, + "message": "User created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -8366,125 +14332,30 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: appname (req), appdescription, supportteamid, isinstallable, applicationnotes, installpath, applicationlink, documentationpath, ishidden, isprinter, islicenced, isrequired, image" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "directpermissions": [ + "endpoints.runscript" + ], + "email": "", + "password": "", + "roles": [], + "sendwelcome": false, + "username": "" } } } } } }, - "/api/applications/{app_id}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Get one application with active versions, installedcount, and linked KB articles", - "description": "Get one application with active versions, installedcount, and linked KB articles\n\n**Auth:** jwt-optional\n\n**Params:** path: app_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications/15\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update application (rename conflict-checked); field-level change diff audit-logged; honors X-Import-Mode", - "description": "Update application (rename conflict-checked); field-level change diff audit-logged; honors X-Import-Mode\n\n**Auth:** permission:applications.edit\n\n**Params:** body: any of appname, appdescription, supportteamid, isinstallable, applicationnotes, installpath, applicationlink, documentationpath, ishidden, isprinter, islicenced, isrequired, image, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/applications/15 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"supportteamid\":2}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: any of appname, appdescription, supportteamid, isinstallable, applicationnotes, installpath, applicationlink, documentationpath, ishidden, isprinter, islicenced, isrequired, image, isactive" - } - } - } - } - }, + "/api/users/{userid}": { "delete": { "tags": [ - "core-catalog" + "core-users" ], - "summary": "Soft-delete application; audit-logged", - "description": "Soft-delete application; audit-logged\n\n**Auth:** permission:applications.delete\n\n**Params:** path: app_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/applications/15 -H \"Authorization: Bearer $TOK\"\n```", + "summary": "Delete a user.", + "description": "Delete a user.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: userid (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/users/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -8495,7 +14366,17 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "message": "User deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -8519,220 +14400,21 @@ }, "parameters": [ { - "name": "app_id", + "name": "userid", "in": "path", "required": true, "schema": { - "type": "string" - } - } - ] - } - }, - "/api/applications/{app_id}/versions": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List active versions of an application (desc)", - "description": "List active versions of an application (desc)\n\n**Auth:** jwt-optional\n\n**Params:** path: app_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications/15/versions\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" + "type": "integer" } } ] }, - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Create app version (409 on duplicate version per app); honors X-Import-Mode for legacy dates", - "description": "Create app version (409 on duplicate version per app); honors X-Import-Mode for legacy dates\n\n**Auth:** permission:applications.create\n\n**Params:** body: version (req), releasedate, notes\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/applications/15/versions -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"version\":\"2024.2\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: version (req), releasedate, notes" - } - } - } - } - } - }, - "/api/applications/{app_id}/installed": { "get": { "tags": [ - "core-catalog" + "core-users" ], - "summary": "List computers with this app installed (503 if computers plugin absent)", - "description": "List computers with this app installed (503 if computers plugin absent)\n\n**Auth:** jwt-optional\n\n**Params:** path: app_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications/15/installed\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/applications/machines/{machine_id}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "List apps installed on a computer (machine_id is a computerid; 503 without computers plugin)", - "description": "List apps installed on a computer (machine_id is a computerid; 503 without computers plugin)\n\n**Auth:** jwt-optional\n\n**Params:** path: machine_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications/machines/8\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "machine_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Install an app on a computer; reactivates a prior soft-deleted install; 409 if already installed", - "description": "Install an app on a computer; reactivates a prior soft-deleted install; 409 if already installed\n\n**Auth:** permission:applications.create\n\n**Params:** body: appid (req), appversionid\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/applications/machines/8 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"appid\":15}'\n```", + "summary": "Get a single user.", + "description": "Get a single user.\n\n**Auth:** jwt\n\n**Params:** path: userid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/users/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -8743,7 +14425,33 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "directpermissions": [], + "email": "", + "failedlogins": 0, + "firstname": "", + "isactive": true, + "islocked": false, + "lastlogindate": "2026-01-01T00:00:00Z", + "lastname": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "mustchangepassword": "", + "permissions": [], + "roles": [], + "userid": 1, + "username": "" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -8767,590 +14475,7 @@ }, "parameters": [ { - "name": "machine_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: appid (req), appversionid" - } - } - } - } - } - }, - "/api/applications/machines/{machine_id}/{app_id}": { - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Uninstall (soft-delete install row) an app from a computer", - "description": "Uninstall (soft-delete install row) an app from a computer\n\n**Auth:** permission:applications.delete\n\n**Params:** path: machine_id, app_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/applications/machines/8/15 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "machine_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-catalog" - ], - "summary": "Update an installed-app row (change appversionid)", - "description": "Update an installed-app row (change appversionid)\n\n**Auth:** permission:applications.edit\n\n**Params:** body: appversionid\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/applications/machines/8/15 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"appversionid\":3}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "machine_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: appversionid" - } - } - } - } - } - }, - "/api/applications/{app_id}/image": { - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Upload or replace an application's image; sets application.image to the served URL", - "description": "Upload or replace an application's image; sets application.image to the served URL\n\n**Auth:** jwt + applications.edit\n\n**Params:** multipart/form-data: file= (png/jpg/gif/webp/svg/ico)\n\n**Example:**\n```\ncurl -X POST -H 'Authorization: Bearer ' -F file=@logo.png http://localhost:5001/api/applications/5/image\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "multipart/form-data: file= (png/jpg/gif/webp/svg/ico)" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Remove an application's image and clear the field", - "description": "Remove an application's image and clear the field\n\n**Auth:** jwt + applications.edit\n\n**Params:** none\n\n**Example:**\n```\ncurl -X DELETE -H 'Authorization: Bearer ' http://localhost:5001/api/applications/5/image\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/applications/image/{filename}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Serve an uploaded application image. Public: application tiles render before login", - "description": "Serve an uploaded application image. Public: application tiles render before login\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/applications/image/application-5.png\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "filename", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/applications/{app_id}/package": { - "post": { - "tags": [ - "core-catalog" - ], - "summary": "Upload or replace the installer; sets installpath to the download URL", - "description": "Upload or replace the installer; sets installpath to the download URL\n\n**Auth:** jwt + applications.edit\n\n**Params:** multipart/form-data: file= (exe/msi/msp/zip/7z/cab/iso/appx/msix/ps1/bat/txt/pdf), max 500MB\n\n**Example:**\n```\ncurl -X POST -H 'Authorization: Bearer ' -F file=@setup.msi http://localhost:5001/api/applications/5/package\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "multipart/form-data: file= (exe/msi/msp/zip/7z/cab/iso/appx/msix/ps1/bat/txt/pdf), max 500MB" - } - } - } - } - }, - "delete": { - "tags": [ - "core-catalog" - ], - "summary": "Remove an uploaded installer; clears installpath only when it pointed at the upload", - "description": "Remove an uploaded installer; clears installpath only when it pointed at the upload\n\n**Auth:** jwt + applications.edit\n\n**Params:** none\n\n**Example:**\n```\ncurl -X DELETE -H 'Authorization: Bearer ' http://localhost:5001/api/applications/5/package\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/applications/package/{filename}": { - "get": { - "tags": [ - "core-catalog" - ], - "summary": "Download an uploaded installer as an attachment. Authenticated: licensed vendor software", - "description": "Download an uploaded installer as an attachment. Authenticated: licensed vendor software\n\n**Auth:** jwt + applications.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -OJ -H 'Authorization: Bearer ' http://localhost:5001/api/applications/package/application-5.msi\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "filename", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/computers/types": { - "get": { - "tags": [ - "plugin-computers" - ], - "summary": "List computer types, paginated, active-only by default", - "description": "List computer types, paginated, active-only by default\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active (default true; 'false' includes inactive), search (ilike on computertype)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/computers/types?search=kiosk&per_page=50'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-computers" - ], - "summary": "Create (or reactivate) a computer type", - "description": "Create (or reactivate) a computer type\n\n**Auth:** permission:computers.create (jwt_required)\n\n**Params:** body: computertype (required), description, icon, color; matching a deactivated type revives it instead of 409\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"computertype\":\"Shopfloor\",\"color\":\"#0066cc\"}' 'http://localhost:5001/api/computers/types'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: computertype (required), description, icon, color; matching a deactivated type revives it instead of 409" - } - } - } - } - } - }, - "/api/computers/types/{type_id}": { - "get": { - "tags": [ - "plugin-computers" - ], - "summary": "Get a single computer type by ID", - "description": "Get a single computer type by ID\n\n**Auth:** jwt-optional\n\n**Params:** type_id in path\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers/types/3'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", + "name": "userid", "in": "path", "required": true, "schema": { @@ -9361,10 +14486,10 @@ }, "put": { "tags": [ - "plugin-computers" + "core-users" ], - "summary": "Update a computer type", - "description": "Update a computer type\n\n**Auth:** permission:computers.edit (jwt_required)\n\n**Params:** body: computertype, description, icon, color, isactive; 409 on duplicate name\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"isactive\":false}' 'http://localhost:5001/api/computers/types/3'\n```", + "summary": "Update a user.", + "description": "Update a user.\n\n**Auth:** jwt\n\n**Params:** path: userid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/users/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -9375,7 +14500,34 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "directpermissions": [], + "email": "", + "failedlogins": 0, + "firstname": "", + "isactive": true, + "islocked": false, + "lastlogindate": null, + "lastname": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "mustchangepassword": "", + "permissions": [], + "roles": [], + "userid": 2, + "username": "" + }, + "message": "User updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -9399,7 +14551,7 @@ }, "parameters": [ { - "name": "type_id", + "name": "userid", "in": "path", "required": true, "schema": { @@ -9414,18 +14566,23 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: computertype, description, icon, color, isactive; 409 on duplicate name" + "description": "path: userid (int); body: JSON (fields not yet documented)" + }, + "example": { + "password": "" } } } } - }, - "delete": { + } + }, + "/api/users/permissions": { + "get": { "tags": [ - "plugin-computers" + "core-users" ], - "summary": "Hard-delete a computer type when unused", - "description": "Hard-delete a computer type when unused\n\n**Auth:** permission:computers.delete (jwt_required)\n\n**Params:** type_id in path; 409 if any Computer still uses the type\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/computers/types/3'\n```", + "summary": "List assignable permissions grouped by category.", + "description": "List assignable permissions grouped by category.\n\n**Auth:** jwt\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/users/permissions -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -9436,7 +14593,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -9453,32 +14612,18 @@ }, "403": { "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] + } } }, - "/api/computers/protocols": { + "/api/users/roles": { "get": { "tags": [ - "plugin-computers" + "core-users" ], - "summary": "List remote-access protocols (VNC/WinRM/RDP catalog), unpaginated", - "description": "List remote-access protocols (VNC/WinRM/RDP catalog), unpaginated\n\n**Auth:** jwt-optional\n\n**Params:** active (default true; 'false' includes disabled)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers/protocols?active=false'\n```", + "summary": "List all roles with their permissions.", + "description": "List all roles with their permissions.\n\n**Auth:** jwt\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/users/roles -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -9488,7 +14633,45 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "color": null, + "description": "Administrator", + "grantable": null, + "isadmin": true, + "isguest": false, + "permissions": [], + "roleid": 1, + "rolename": "admin", + "usercount": 1 + }, + { + "color": "#6C757D", + "description": "Anyone who is not signed in. Holds nothing by default; grant it a permission to open that up to people with no account.", + "grantable": [ + "applications.view", + "assets.view", + "computers.view", + "" + ], + "isadmin": false, + "isguest": true, + "permissions": [], + "roleid": 2, + "rolename": "guest", + "usercount": 0 + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -9499,15 +14682,21 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } } }, "post": { "tags": [ - "plugin-computers" + "core-users" ], - "summary": "Create an access protocol", - "description": "Create an access protocol\n\n**Auth:** permission:computers.edit (jwt_required)\n\n**Params:** body: name, scheme, linktemplate (all required), defaultport, isactive; 409 on duplicate name\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"name\":\"VNC\",\"scheme\":\"vnc\",\"defaultport\":5900,\"linktemplate\":\"vnc://{host}:{port}\"}' 'http://localhost:5001/api/computers/protocols'\n```", + "summary": "Create a new role.", + "description": "Create a new role.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/users/roles -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -9518,7 +14707,24 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "color": "#ff8800", + "description": "colorful", + "permissions": [], + "roleid": 2, + "rolename": "painters" + }, + "message": "Role created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -9544,20 +14750,26 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: name, scheme, linktemplate (all required), defaultport, isactive; 409 on duplicate name" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "color": "#ff8800", + "description": "colorful", + "permissions": [], + "rolename": "painters" } } } } } }, - "/api/computers/protocols/{protocol_id}": { - "put": { + "/api/users/roles/{roleid}": { + "delete": { "tags": [ - "plugin-computers" + "core-users" ], - "summary": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against", - "description": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against\n\n**Auth:** permission:computers.edit (jwt_required)\n\n**Params:** protocol_id in path; body: any of protocolname, port, isactive\n\n**Example:**\n```\ncurl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"port\":5900}' http://localhost:5001/api/computers/protocols/3\n```", + "summary": "Delete a role.", + "description": "Delete a role.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: roleid (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/users/roles/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -9568,7 +14780,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -9592,11 +14806,79 @@ }, "parameters": [ { - "name": "protocol_id", + "name": "roleid", "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-users" + ], + "summary": "Update a role.", + "description": "Update a role.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: roleid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/users/roles/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "color": "#6C757D", + "description": "Anyone who is not signed in. Holds nothing by default; grant it a permission to open that up to people with no account.", + "permissions": [ + "reports.view" + ], + "roleid": 2, + "rolename": "guest" + }, + "message": "Role updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "roleid", + "in": "path", + "required": true, + "schema": { + "type": "integer" } } ], @@ -9607,18 +14889,25 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "protocol_id in path; body: any of protocolname, port, isactive" + "description": "path: roleid (int); body: JSON (fields not yet documented)" + }, + "example": { + "permissions": [ + "reports.view" + ] } } } } - }, - "patch": { + } + }, + "/api/vendors": { + "get": { "tags": [ - "plugin-computers" + "core-vendors" ], - "summary": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against", - "description": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against\n\n**Auth:** permission:computers.edit (jwt_required)\n\n**Params:** protocol_id in path; body: any of protocolname, port, isactive\n\n**Example:**\n```\ncurl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"port\":5900}' http://localhost:5001/api/computers/protocols/3\n```", + "summary": "List all vendors.", + "description": "List all vendors.\n\n**Auth:** permission:assets.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/vendors -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -9629,7 +14918,38 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "supportemail": "", + "supportphone": "", + "vendor": "Acme", + "vendorid": 1, + "website": null + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -9646,40 +14966,143 @@ }, "403": { "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." } }, "parameters": [ { - "name": "protocol_id", - "in": "path", - "required": true, + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "vendor", + "in": "query", + "required": false, "schema": { "type": "string" } } + ] + }, + "post": { + "tags": [ + "core-vendors" ], + "summary": "Create a new vendor.", + "description": "Create a new vendor.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/vendors -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "supportemail": "", + "supportphone": "", + "vendor": "Unscoped Vendor", + "vendorid": 1, + "website": null + }, + "message": "Vendor created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true, - "description": "protocol_id in path; body: any of protocolname, port, isactive" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "createddate": "2026-01-01 00:00:00", + "vendor": "Legacy Vendor" } } } } - }, + } + }, + "/api/vendors/{vendor_id}": { "delete": { "tags": [ - "plugin-computers" + "core-vendors" ], - "summary": "Delete an access protocol (soft-deactivate when in use)", - "description": "Delete an access protocol (soft-deactivate when in use)\n\n**Auth:** permission:computers.edit (jwt_required)\n\n**Params:** protocol_id in path; if referenced by any ComputerAccess it deactivates instead of deleting\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/computers/protocols/2'\n```", + "summary": "Delete (deactivate) a vendor.", + "description": "Delete (deactivate) a vendor.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: vendor_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/vendors/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -9690,7 +15113,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -9714,7 +15139,426 @@ }, "parameters": [ { - "name": "protocol_id", + "name": "vendor_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "core-vendors" + ], + "summary": "Get a single vendor.", + "description": "Get a single vendor.\n\n**Auth:** permission:assets.view\n\n**Params:** path: vendor_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/vendors/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "vendor_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "core-vendors" + ], + "summary": "Update a vendor.", + "description": "Update a vendor.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: vendor_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/vendors/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "vendor_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: vendor_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/backups/asset/{assetid}": { + "get": { + "tags": [ + "plugin-backups" + ], + "summary": "Revision history for one asset, newest first.", + "description": "Revision history for one asset, newest first.\n\n**Auth:** permission:backups.view\n\n**Params:** path: assetid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/backups/asset/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "assetid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/backups/asset/{assetid}/info": { + "get": { + "tags": [ + "plugin-backups" + ], + "summary": "A kind's 'at a glance' card, built from its LATEST revision.", + "description": "A kind's 'at a glance' card, built from its LATEST revision.\n\n**Auth:** permission:backups.view\n\n**Params:** path: assetid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/backups/asset/1/info -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "assetid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/backups/dashboard/stale": { + "get": { + "tags": [ + "plugin-backups" + ], + "summary": "Chains whose backup has stopped running.", + "description": "Chains whose backup has stopped running.\n\n**Auth:** permission:backups.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/backups/dashboard/stale -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/backups/kinds": { + "get": { + "tags": [ + "plugin-backups" + ], + "summary": "Registered backup kinds, for UI that needs to enumerate them.", + "description": "Registered backup kinds, for UI that needs to enumerate them.\n\n**Auth:** permission:backups.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/backups/kinds -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "assettypes": [ + "machine" + ], + "displayname": "", + "formats": [ + { + "ext": ".reg", + "hint": "Restore with the Load... button in the NTLARS settings dialog.", + "id": "ntlars", + "label": "NTLARS Load... (.reg)", + "mimetype": "application/octet-stream" + }, + { + "ext": ".reg", + "hint": "Use when importing outside NTLARS on a 64-bit machine.", + "id": "wow6432node", + "label": "Direct reg import (.reg, WOW6432Node)", + "mimetype": "application/octet-stream" + } + ], + "key": "ntlars", + "storagebackend": "shopdb" + }, + { + "assettypes": [ + "machine" + ], + "displayname": "", + "formats": [], + "key": "partmarker", + "storagebackend": "share" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/backups/revisions/{backuprevisionid}": { + "get": { + "tags": [ + "plugin-backups" + ], + "summary": "One revision, including its parsed payload where there is one.", + "description": "One revision, including its parsed payload where there is one.\n\n**Auth:** permission:backups.view\n\n**Params:** path: backuprevisionid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/backups/revisions/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "backuprevisionid", "in": "path", "required": true, "schema": { @@ -9724,15 +15568,14 @@ ] } }, - "/api/computers/display-kiosks": { + "/api/backups/revisions/{backuprevisionid}/diff": { "get": { "tags": [ - "plugin-computers" + "plugin-backups" ], - "summary": "List display-kiosk computers with derived F. FQDN for the Dashboard Defaults picker", - "description": "List display-kiosk computers with derived F. FQDN for the Dashboard Defaults picker\n\n**Auth:** jwt-optional\n\n**Params:** none; uses pctype mapping for gea-shopfloor-display (default 'Kiosk') and display_fqdn_domain setting\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/computers/display-kiosks'\n```", + "summary": "Diff two revisions of the same asset+kind.", + "description": "Diff two revisions of the same asset+kind.\n\n**Auth:** permission:backups.view\n\n**Params:** path: backuprevisionid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/backups/revisions/1/diff -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -9742,7 +15585,32 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "againstid": 1, + "backuprevisionid": 2, + "changecount": 1, + "changes": [ + { + "after": "OKUMA", + "aftertype": "REG_SZ", + "before": "", + "beforetype": "REG_SZ", + "change": "changed", + "keypath": "General", + "valuename": "Cnc" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -9753,8 +15621,96 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." } - } + }, + "parameters": [ + { + "name": "backuprevisionid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "against", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/backups/revisions/{backuprevisionid}/download": { + "get": { + "tags": [ + "plugin-backups" + ], + "summary": "Render a revision back to its native file format.", + "description": "Render a revision back to its native file format.\n\n**Auth:** permission:backups.download\n\n**Params:** path: backuprevisionid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/backups/revisions/1/download -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "backuprevisionid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] } }, "/api/computers": { @@ -9762,10 +15718,9 @@ "tags": [ "plugin-computers" ], - "summary": "List computers (asset + computer + resolved access links), filtered and paginated", - "description": "List computers (asset + computer + resolved access links), filtered and paginated\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, search (assetnumber/name/serialnumber/hostname/gaugelabreference/maintenancereference ilike), assetnumber (exact, for idempotent import), typeid|type_id, osid|os_id, locationid|location_id, businessunitid|businessunit_id, shopfloor (true/false), sort (hostname|assetnumber|name|lastreporteddate), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers?shopfloor=true&sort=lastreporteddate&dir=desc&per_page=25'\n```", + "summary": "List all computers with filtering and pagination.", + "description": "List all computers with filtering and pagination.\n\n**Auth:** permission:computers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -9775,7 +15730,79 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "accessmethods": [], + "assetid": 1, + "assetnumber": "PC-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "computer", + "businessunitid": null, + "computer": { + "accessprotocolnames": [], + "assetid": 1, + "azuredevicecategory": null, + "azuredeviceid": null, + "azureobjectid": null, + "computerid": 1, + "computertypeid": 1, + "computertypename": "Standard", + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "isactive": true, + "lastboottime": null, + "lastreporteddate": null, + "loggedinuser": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "osid": null, + "vendorid": null + }, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -9786,15 +15813,163 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } - } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "assetnumber", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "businessunit_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "businessunitid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "dir", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "asc" + } + }, + { + "name": "location_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "locationid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "os_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "osid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "shopfloor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "hostname" + } + }, + { + "name": "type_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "typeid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] }, "post": { "tags": [ "plugin-computers" ], - "summary": "Create a computer (Asset + Computer records, optional primary IP and access methods), audit-logged", - "description": "Create a computer (Asset + Computer records, optional primary IP and access methods), audit-logged\n\n**Auth:** permission:computers.create (jwt_required)\n\n**Params:** body: assetnumber (required); name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, mapx, mapy, notes, computertypeid, hostname, osid, vendorid, modelnumberid, loggedinuser, lastreporteddate, lastboottime, ipaddress (creates primary IP communication), accessmethods [{protocolid, portoverride?}]; X-Import-Mode header preserves legacy timestamps; 409 on duplicate assetnumber or hostname\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"assetnumber\":\"PC-0042\",\"hostname\":\"tsgwp00042\",\"computertypeid\":1,\"osid\":2,\"ipaddress\":\"10.1.2.3\",\"accessmethods\":[{\"protocolid\":1}]}' 'http://localhost:5001/api/computers'\n```", + "summary": "Create new computer (creates both Asset and Computer records).", + "description": "Create new computer (creates both Asset and Computer records).\n\n**Auth:** permission:computers.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/computers -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -9805,7 +15980,71 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "accessmethods": [], + "assetid": 1, + "assetnumber": "AUDIT-PC-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "computer", + "businessunitid": null, + "computer": { + "accessprotocolnames": [], + "assetid": 1, + "azuredevicecategory": null, + "azuredeviceid": null, + "azureobjectid": null, + "computerid": 1, + "computertypeid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "isactive": true, + "lastboottime": null, + "lastreporteddate": null, + "loggedinuser": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "osid": null, + "vendorid": null + }, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statuscolor": null, + "statusid": 1, + "statusname": "In Use" + }, + "message": "Computer created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -9831,7 +16070,13 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: assetnumber (required); name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, mapx, mapy, notes, computertypeid, hostname, osid, vendorid, modelnumberid, loggedinuser, lastreporteddate, lastboottime, ipaddress (creates primary IP communication), accessmethods [{protocolid, portoverride?}]; X-Import-Mode header preserves legacy timestamps; 409 on duplicate assetnumber or hostname" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "assetnumber": "PC-IMPORT-1", + "createddate": "2026-01-01 00:00:00", + "hostname": "", + "modifieddate": "2026-01-01T00:00:00" } } } @@ -9839,14 +16084,13 @@ } }, "/api/computers/{computer_id}": { - "get": { + "delete": { "tags": [ "plugin-computers" ], - "summary": "Get one computer with full detail: asset fields, computer extension, communications, resolved access links", - "description": "Get one computer with full detail: asset fields, computer extension, communications, resolved access links\n\n**Auth:** jwt-optional\n\n**Params:** computer_id in path\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers/42'\n```", + "summary": "Delete (soft delete) computer.", + "description": "Delete (soft delete) computer.\n\n**Auth:** permission:computers.delete\n\n**Params:** path: computer_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/computers/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -9856,7 +16100,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -9868,6 +16114,125 @@ } } }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "computer_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "Get a single computer with full details.", + "description": "Get a single computer with full details.\n\n**Auth:** permission:computers.view\n\n**Params:** path: computer_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "accessmethods": [], + "assetid": 1, + "assetnumber": "PC-2001", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "computer", + "businessunitid": null, + "communications": [], + "computer": { + "accessprotocolnames": [], + "assetid": 1, + "azuredevicecategory": null, + "azuredeviceid": null, + "azureobjectid": null, + "computerid": 1, + "computertypeid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "imageurl": "/api/models/images/model-1.png", + "isactive": true, + "lastboottime": null, + "lastreporteddate": null, + "loggedinuser": null, + "modelname": "IMG-MODEL-1", + "modelnumberid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "osid": null, + "vendorid": null + }, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, "404": { "description": "No such record." } @@ -9887,8 +16252,8 @@ "tags": [ "plugin-computers" ], - "summary": "Update a computer's asset and extension fields", - "description": "Update a computer's asset and extension fields\n\n**Auth:** permission:computers.edit (jwt_required)\n\n**Params:** body: any asset field (assetnumber, name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, mapx, mapy, notes, isactive) or computer field (computertypeid, hostname, osid, vendorid, modelnumberid, loggedinuser, lastreporteddate, lastboottime); ipaddress upserts/clears the primary IP communication; accessmethods replaces protocol list; 409 on assetnumber/hostname conflict; changes audit-logged\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"locationid\":7,\"ipaddress\":\"10.1.2.4\"}' 'http://localhost:5001/api/computers/42'\n```", + "summary": "Update computer (both Asset and Computer records).", + "description": "Update computer (both Asset and Computer records).\n\n**Auth:** permission:computers.edit\n\n**Params:** path: computer_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/computers/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -9899,7 +16264,83 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "accessmethods": [ + { + "id": 1, + "link": "rdp://auditpc1", + "name": "RDP", + "port": null, + "portoverride": null, + "protocolid": 1, + "scheme": "rdp" + } + ], + "assetid": 1, + "assetnumber": "AUDIT-PC-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "computer", + "businessunitid": null, + "computer": { + "accessprotocolnames": [ + "RDP" + ], + "assetid": 1, + "azuredevicecategory": null, + "azuredeviceid": null, + "azureobjectid": null, + "computerid": 1, + "computertypeid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "isactive": true, + "lastboottime": null, + "lastreporteddate": null, + "loggedinuser": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "osid": null, + "vendorid": null + }, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statuscolor": null, + "statusid": 1, + "statusname": "In Use" + }, + "message": "Computer updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -9938,18 +16379,24 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: any asset field (assetnumber, name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, mapx, mapy, notes, isactive) or computer field (computertypeid, hostname, osid, vendorid, modelnumberid, loggedinuser, lastreporteddate, lastboottime); ipaddress upserts/clears the primary IP communication; accessmethods replaces protocol list; 409 on assetnumber/hostname conflict; changes audit-logged" + "description": "path: computer_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "gaugelabreference": "GL-2", + "maintenancereference": "MNT-2" } } } } - }, - "delete": { + } + }, + "/api/computers/{computer_id}/apps": { + "get": { "tags": [ "plugin-computers" ], - "summary": "Soft-delete a computer (sets asset isactive=false), audit-logged", - "description": "Soft-delete a computer (sets asset isactive=false), audit-logged\n\n**Auth:** permission:computers.delete (jwt_required)\n\n**Params:** computer_id in path\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/computers/42'\n```", + "summary": "Get all installed applications for a computer.", + "description": "Get all installed applications for a computer.\n\n**Auth:** permission:computers.view\n\n**Params:** path: computer_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/1/apps -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -9960,7 +16407,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -9992,151 +16441,13 @@ } } ] - } - }, - "/api/computers/by-asset/{asset_id}": { - "get": { - "tags": [ - "plugin-computers" - ], - "summary": "Get computer record by its core asset ID", - "description": "Get computer record by its core asset ID\n\n**Auth:** jwt-optional\n\n**Params:** asset_id in path\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers/by-asset/1234'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/computers/by-hostname/{hostname}": { - "get": { - "tags": [ - "plugin-computers" - ], - "summary": "Get computer record by hostname", - "description": "Get computer record by hostname\n\n**Auth:** jwt-optional\n\n**Params:** hostname in path (exact match)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers/by-hostname/WKSTN0042'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "hostname", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/computers/{computer_id}/apps": { - "get": { - "tags": [ - "plugin-computers" - ], - "summary": "List installed applications on a computer", - "description": "List installed applications on a computer\n\n**Auth:** jwt-optional\n\n**Params:** computer_id in path; returns active installs only\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers/42/apps'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "computer_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] }, "post": { "tags": [ "plugin-computers" ], - "summary": "Record an application install on a computer", - "description": "Record an application install on a computer\n\n**Auth:** permission:computers.create (jwt_required)\n\n**Params:** body: appid (required, must exist in Applications), appversionid; reactivates a soft-deleted install; 409 if already installed\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"appid\":17,\"appversionid\":3}' 'http://localhost:5001/api/computers/42/apps'\n```", + "summary": "Add an installed application to a computer.", + "description": "Add an installed application to a computer.\n\n**Auth:** permission:computers.create\n\n**Params:** path: computer_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/computers/1/apps -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -10147,7 +16458,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -10186,7 +16499,7 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: appid (required, must exist in Applications), appversionid; reactivates a soft-deleted install; 409 if already installed" + "description": "path: computer_id (int); body: JSON (fields not yet documented)" } } } @@ -10198,8 +16511,8 @@ "tags": [ "plugin-computers" ], - "summary": "Soft-remove an installed application (isactive=false)", - "description": "Soft-remove an installed application (isactive=false)\n\n**Auth:** permission:computers.delete (jwt_required)\n\n**Params:** computer_id and app_id in path\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/computers/42/apps/17'\n```", + "summary": "Remove an installed application from a computer.", + "description": "Remove an installed application from a computer.\n\n**Auth:** permission:computers.delete\n\n**Params:** path: computer_id (int), app_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/computers/1/apps/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -10210,7 +16523,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -10257,8 +16572,8 @@ "tags": [ "plugin-computers" ], - "summary": "Agent status check-in: refresh last-reported timestamp plus logged-in user and boot time", - "description": "Agent status check-in: refresh last-reported timestamp plus logged-in user and boot time\n\n**Auth:** permission:computers.create (jwt_required)\n\n**Params:** body (all optional): loggedinuser, lastboottime; server sets lastreporteddate to now (UTC)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"loggedinuser\":\"jsmith\",\"lastboottime\":\"2026-07-30T06:00:00\"}' 'http://localhost:5001/api/computers/42/report'\n```", + "summary": "Report computer status (for agent-based reporting).", + "description": "Report computer status (for agent-based reporting).\n\n**Auth:** permission:computers.create\n\n**Params:** path: computer_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/computers/1/report -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -10269,7 +16584,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -10308,22 +16625,21 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body (all optional): loggedinuser, lastboottime; server sets lastreporteddate to now (UTC)" + "description": "path: computer_id (int); body: JSON (fields not yet documented)" } } } } } }, - "/api/computers/dashboard/summary": { + "/api/computers/asset/{asset_id}/policypanel": { "get": { "tags": [ "plugin-computers" ], - "summary": "Dashboard counts: total active computers, breakdown by type and OS, shopfloor vs non-shopfloor", - "description": "Dashboard counts: total active computers, breakdown by type and OS, shopfloor vs non-shopfloor\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers/dashboard/summary'\n```", + "summary": "Keyvalue panel: which policy governs this endpoint, and what it allows.", + "description": "Keyvalue panel: which policy governs this endpoint, and what it allows.\n\n**Auth:** permission:computers.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/asset/1/policypanel -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -10333,7 +16649,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -10344,17 +16662,36 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." } - } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] } }, - "/api/computers/dashboard/quiet": { + "/api/computers/by-asset/{asset_id}": { "get": { "tags": [ "plugin-computers" ], - "summary": "Dashboard card: PCs that WERE reporting, have stopped, and are still meant to be in use - a PC that never reported is...", - "description": "Dashboard card: PCs that WERE reporting, have stopped, and are still meant to be in use - a PC that never reported is not evidence of a fault\n\n**Auth:** permission:computers.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/dashboard/quiet\n```", + "summary": "Get computer data by asset ID.", + "description": "Get computer data by asset ID.\n\n**Auth:** permission:computers.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/by-asset/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -10365,7 +16702,176 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/computers/by-hostname/{hostname}": { + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "Get computer by hostname.", + "description": "Get computer by hostname.\n\n**Auth:** permission:computers.view\n\n**Params:** path: hostname (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/by-hostname/x -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "hostname", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/computers/client-script": { + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "The collector reporter, stamped with THIS site's values.", + "description": "The collector reporter, stamped with THIS site's values.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/client-script -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/computers/dashboard/quiet": { + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "PCs that WERE reporting, have stopped, and are still meant to be in use.", + "description": "PCs that WERE reporting, have stopped, and are still meant to be in use.\n\n**Auth:** permission:computers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/dashboard/quiet -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "computerid": 2, + "hostname": "", + "lastreported": "2026-01-01T00:00:00.000000Z", + "quietdays": 9 + }, + { + "computerid": 1, + "hostname": "", + "lastreported": "2026-01-01T00:00:00.000000Z", + "quietdays": 2 + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -10391,8 +16897,8 @@ "tags": [ "plugin-computers" ], - "summary": "Dashboard card: machine numbers claimed by more than one PC, which is either a swap in progress or a mis-typed number", - "description": "Dashboard card: machine numbers claimed by more than one PC, which is either a swap in progress or a mis-typed number\n\n**Auth:** permission:computers.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/dashboard/sharedmachines\n```", + "summary": "Machine numbers claimed by more than one PC, with nothing filed under them.", + "description": "Machine numbers claimed by more than one PC, with nothing filed under them.\n\n**Auth:** permission:computers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/dashboard/sharedmachines -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -10403,7 +16909,24 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "assetid": 1, + "machinenumber": "2026", + "pccount": 2, + "pcs": "PCONE, PCTWO" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -10424,13 +16947,13 @@ } } }, - "/api/computers/client-script": { + "/api/computers/dashboard/summary": { "get": { "tags": [ "plugin-computers" ], - "summary": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the...", - "description": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched\n\n**Auth:** jwt + role:admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script\n```", + "summary": "Get computer dashboard summary data.", + "description": "Get computer dashboard summary data.\n\n**Auth:** permission:computers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/dashboard/summary -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -10441,7 +16964,23 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "byos": [], + "bytype": [], + "nonshopfloor": 0, + "shopfloor": 0, + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -10462,13 +17001,399 @@ } } }, + "/api/computers/display-kiosks": { + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "Reporting display kiosks, for the Dashboard Defaults picker.", + "description": "Reporting display kiosks, for the Dashboard Defaults picker.\n\n**Auth:** permission:computers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/display-kiosks -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "computerid": 1, + "fqdn": "", + "hostname": "", + "serialnumber": "" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/computers/policies/actions": { + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "The allowlisted action names + labels a policy may reference.", + "description": "The allowlisted action names + labels a policy may reference.\n\n**Auth:** permission:computers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/policies/actions -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "action": "service_start", + "description": "Start a Windows service by name on the endpoint.", + "kind": "command", + "label": "Start service", + "mayauthor": true, + "requiredpermission": null + }, + { + "action": "service_stop", + "description": "Stop a running Windows service by name on the endpoint.", + "kind": "command", + "label": "Stop service", + "mayauthor": true, + "requiredpermission": null + }, + { + "action": "service_status", + "description": "Report whether a named Windows service is running.", + "kind": "command", + "label": "Check service", + "mayauthor": true, + "requiredpermission": null + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/computers/policies/business": { + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "UNDOCUMENTED: computers.list_business_policies has no docstring.", + "description": "UNDOCUMENTED: computers.list_business_policies has no docstring.\n\n**Auth:** permission:computers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/policies/business -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + }, + "post": { + "tags": [ + "plugin-computers" + ], + "summary": "UNDOCUMENTED: computers.create_business_policy has no docstring.", + "description": "UNDOCUMENTED: computers.create_business_policy has no docstring.\n\n**Auth:** permission:computers.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/computers/policies/business -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/computers/policies/business/{policy_id}": { + "delete": { + "tags": [ + "plugin-computers" + ], + "summary": "UNDOCUMENTED: computers.delete_business_policy has no docstring.", + "description": "UNDOCUMENTED: computers.delete_business_policy has no docstring.\n\n**Auth:** permission:computers.delete\n\n**Params:** path: policy_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/computers/policies/business/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "policy_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "UNDOCUMENTED: computers.get_business_policy has no docstring.", + "description": "UNDOCUMENTED: computers.get_business_policy has no docstring.\n\n**Auth:** permission:computers.view\n\n**Params:** path: policy_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/policies/business/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "policy_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-computers" + ], + "summary": "UNDOCUMENTED: computers.update_business_policy has no docstring.", + "description": "UNDOCUMENTED: computers.update_business_policy has no docstring.\n\n**Auth:** permission:computers.edit\n\n**Params:** path: policy_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/computers/policies/business/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "policy_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: policy_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, "/api/computers/policies/configuration": { "get": { "tags": [ "plugin-computers" ], - "summary": "List Configuration Policies (scope + the actions applied to matching endpoints); 404 while the...", - "description": "List Configuration Policies (scope + the actions applied to matching endpoints); 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/configuration\n```", + "summary": "UNDOCUMENTED: computers.list_configuration_policies has no docstring.", + "description": "UNDOCUMENTED: computers.list_configuration_policies has no docstring.\n\n**Auth:** permission:computers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/policies/configuration -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -10479,7 +17404,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -10503,8 +17430,8 @@ "tags": [ "plugin-computers" ], - "summary": "Create a Configuration Policy. Scope trees go through validate_scope and every action name is checked against the...", - "description": "Create a Configuration Policy. Scope trees go through validate_scope and every action name is checked against the endpoints command allowlist, so a bad tree or unknown action is rejected before it reaches the database; 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.create\n\n**Params:** body: name (required), description, scopejson (scope AST), actions[] of {action, mode: desired|gate, params}\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"name\":\"Shopfloor MTConnect\",\"actions\":[{\"action\":\"mtconnect_start\",\"mode\":\"desired\"}]}' http://localhost:5001/api/computers/policies/configuration\n```", + "summary": "UNDOCUMENTED: computers.create_configuration_policy has no docstring.", + "description": "UNDOCUMENTED: computers.create_configuration_policy has no docstring.\n\n**Auth:** permission:computers.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/computers/policies/configuration -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -10515,7 +17442,42 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "actions": [ + { + "action": "run_script", + "configurationpolicyactionid": 1, + "configurationpolicyid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "desiredstate": null, + "intervalminutes": null, + "isactive": true, + "mode": "gate", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "params": null, + "sortorder": 0 + } + ], + "configurationpolicyid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Legitimate", + "scopejson": null, + "source": "local" + }, + "message": "Configuration policy created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -10541,7 +17503,15 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: name (required), description, scopejson (scope AST), actions[] of {action, mode: desired|gate, params}" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "actions": [ + { + "action": "run_script" + } + ], + "name": "Legitimate" } } } @@ -10549,12 +17519,12 @@ } }, "/api/computers/policies/configuration/{policy_id}": { - "get": { + "delete": { "tags": [ "plugin-computers" ], - "summary": "One Configuration Policy with its actions expanded; 404 while the computers_configpolicies_enabled setting is off", - "description": "One Configuration Policy with its actions expanded; 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.view\n\n**Params:** path: policy_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/configuration/1policy_id\n```", + "summary": "UNDOCUMENTED: computers.delete_configuration_policy has no docstring.", + "description": "UNDOCUMENTED: computers.delete_configuration_policy has no docstring.\n\n**Auth:** permission:computers.delete\n\n**Params:** path: policy_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/computers/policies/configuration/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -10565,7 +17535,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -10593,7 +17565,58 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "UNDOCUMENTED: computers.get_configuration_policy has no docstring.", + "description": "UNDOCUMENTED: computers.get_configuration_policy has no docstring.\n\n**Auth:** permission:computers.view\n\n**Params:** path: policy_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/policies/configuration/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "policy_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" } } ] @@ -10602,8 +17625,8 @@ "tags": [ "plugin-computers" ], - "summary": "Update a Configuration Policy; same scope and action validation as create; 404 while the...", - "description": "Update a Configuration Policy; same scope and action validation as create; 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.edit\n\n**Params:** path: policy_id; body: any of name, description, scopejson, actions[] (actions replace wholesale)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/configuration/1policy_id\n```", + "summary": "UNDOCUMENTED: computers.update_configuration_policy has no docstring.", + "description": "UNDOCUMENTED: computers.update_configuration_policy has no docstring.\n\n**Auth:** permission:computers.edit\n\n**Params:** path: policy_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/computers/policies/configuration/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -10614,7 +17637,42 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "actions": [ + { + "action": "run_script", + "configurationpolicyactionid": 1, + "configurationpolicyid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "desiredstate": null, + "intervalminutes": null, + "isactive": true, + "mode": "gate", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "params": null, + "sortorder": 0 + } + ], + "configurationpolicyid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "description": "clearer wording", + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "New Name", + "scopejson": null, + "source": "local" + }, + "message": "Configuration policy updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -10642,7 +17700,7 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } ], @@ -10653,345 +17711,15 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "path: policy_id; body: any of name, description, scopejson, actions[] (actions replace wholesale)" + "description": "path: policy_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "description": "clearer wording", + "name": "New Name" } } } } - }, - "delete": { - "tags": [ - "plugin-computers" - ], - "summary": "Delete a Configuration Policy and its actions; 404 while the computers_configpolicies_enabled setting is off", - "description": "Delete a Configuration Policy and its actions; 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.delete\n\n**Params:** path: policy_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/configuration/1policy_id\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "policy_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/computers/policies/business": { - "get": { - "tags": [ - "plugin-computers" - ], - "summary": "List Business Policies (ordered bundles of Configuration Policies); 404 while the computers_configpolicies_enabled...", - "description": "List Business Policies (ordered bundles of Configuration Policies); 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/business\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - }, - "post": { - "tags": [ - "plugin-computers" - ], - "summary": "Create a Business Policy. Every referenced Configuration Policy must exist; priority decides which policy wins when two...", - "description": "Create a Business Policy. Every referenced Configuration Policy must exist; priority decides which policy wins when two match the same endpoint; 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.create\n\n**Params:** body: name (required), description, priority, items[] of {configurationpolicyid, sortorder}\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/business\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: name (required), description, priority, items[] of {configurationpolicyid, sortorder}" - } - } - } - } - } - }, - "/api/computers/policies/business/{policy_id}": { - "get": { - "tags": [ - "plugin-computers" - ], - "summary": "One Business Policy with its member Configuration Policies in sort order; 404 while the...", - "description": "One Business Policy with its member Configuration Policies in sort order; 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.view\n\n**Params:** path: policy_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/business/1policy_id\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "policy_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "plugin-computers" - ], - "summary": "Update a Business Policy or reorder its members; 404 while the computers_configpolicies_enabled setting is off", - "description": "Update a Business Policy or reorder its members; 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.edit\n\n**Params:** path: policy_id; body: any of name, description, priority, items[] (items replace wholesale)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/business/1policy_id\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "policy_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: policy_id; body: any of name, description, priority, items[] (items replace wholesale)" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-computers" - ], - "summary": "Delete a Business Policy and its item rows; 404 while the computers_configpolicies_enabled setting is off", - "description": "Delete a Business Policy and its item rows; 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.delete\n\n**Params:** path: policy_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/business/1policy_id\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "policy_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/computers/policies/actions": { - "get": { - "tags": [ - "plugin-computers" - ], - "summary": "The allowlisted action names and display labels a policy may reference, read from the endpoints command allowlist. The...", - "description": "The allowlisted action names and display labels a policy may reference, read from the endpoints command allowlist. The UI builds its action picker from this rather than hardcoding a list that would drift; 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/actions\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } } }, "/api/computers/policies/effective/{asset_id}": { @@ -10999,8 +17727,8 @@ "tags": [ "plugin-computers" ], - "summary": "Preview the resolved effective policy for one endpoint: which Business Policy won, which Configuration Policies...", - "description": "Preview the resolved effective policy for one endpoint: which Business Policy won, which Configuration Policies matched, and the resulting desired/gate actions. Read-only, for debugging a scope that is not matching what its author expected; 404 while the computers_configpolicies_enabled setting is off\n\n**Auth:** computers.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/effective/1asset_id\n```", + "summary": "Preview the resolved effective policy for one endpoint (debug/UI).", + "description": "Preview the resolved effective policy for one endpoint (debug/UI).\n\n**Auth:** permission:computers.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/policies/effective/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -11011,7 +17739,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -11039,19 +17769,19 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } ] } }, - "/api/computers/asset/{asset_id}/policypanel": { + "/api/computers/protocols": { "get": { "tags": [ "plugin-computers" ], - "summary": "Asset-detail panel: which policy governs this endpoint, how many configuration policies folded into it, and how many...", - "description": "Asset-detail panel: which policy governs this endpoint, how many configuration policies folded into it, and how many actions it allows or drives to a desired state. Provenance rather than a control - the effective policy narrows the command allowlist, so this is the answer to why an action is unavailable on a given PC. Returns an empty field list (not the feature-gate 404) when the policy feature is off, so the card self-hides instead of erroring on every site that has not enabled it\n\n**Auth:** computers.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/asset/1/policypanel\n```", + "summary": "List access protocols. ?active=false includes disabled ones.", + "description": "List access protocols. ?active=false includes disabled ones.\n\n**Auth:** permission:computers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/protocols -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -11062,7 +17792,829 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + } + ] + }, + "post": { + "tags": [ + "plugin-computers" + ], + "summary": "UNDOCUMENTED: computers.create_protocol has no docstring.", + "description": "UNDOCUMENTED: computers.create_protocol has no docstring.\n\n**Auth:** permission:computers.edit\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/computers/protocols -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/computers/protocols/{protocol_id}": { + "delete": { + "tags": [ + "plugin-computers" + ], + "summary": "UNDOCUMENTED: computers.delete_protocol has no docstring.", + "description": "UNDOCUMENTED: computers.delete_protocol has no docstring.\n\n**Auth:** permission:computers.edit\n\n**Params:** path: protocol_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/computers/protocols/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "protocol_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "patch": { + "tags": [ + "plugin-computers" + ], + "summary": "UNDOCUMENTED: computers.update_protocol has no docstring.", + "description": "UNDOCUMENTED: computers.update_protocol has no docstring.\n\n**Auth:** permission:computers.edit\n\n**Params:** path: protocol_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PATCH http://localhost:5001/api/computers/protocols/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "protocol_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: protocol_id (int); body: JSON (fields not yet documented)" + } + } + } + } + }, + "put": { + "tags": [ + "plugin-computers" + ], + "summary": "UNDOCUMENTED: computers.update_protocol has no docstring.", + "description": "UNDOCUMENTED: computers.update_protocol has no docstring.\n\n**Auth:** permission:computers.edit\n\n**Params:** path: protocol_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PATCH http://localhost:5001/api/computers/protocols/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "protocol_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: protocol_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/computers/types": { + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "List all computer types.", + "description": "List all computer types.\n\n**Auth:** permission:computers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/types -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "tags": [ + "plugin-computers" + ], + "summary": "Create a new computer type.", + "description": "Create a new computer type.\n\n**Auth:** permission:computers.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/computers/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/computers/types/{type_id}": { + "delete": { + "tags": [ + "plugin-computers" + ], + "summary": "Delete a computer type. Refused if any PC still uses it.", + "description": "Delete a computer type. Refused if any PC still uses it.\n\n**Auth:** permission:computers.delete\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/computers/types/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "Get a single computer type.", + "description": "Get a single computer type.\n\n**Auth:** permission:computers.view\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/computers/types/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-computers" + ], + "summary": "Update a computer type.", + "description": "Update a computer type.\n\n**Auth:** permission:computers.edit\n\n**Params:** path: type_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/computers/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: type_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/endpoints": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "List reported endpoint states.", + "description": "List reported endpoint states.\n\n**Auth:** permission:endpoints.fleet\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/ -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "agentversion": null, + "alias": "Bay 3 Controller", + "assetid": 1, + "assetnumber": "fleetbox", + "computerid": 1, + "computertype": null, + "computertypeid": null, + "cpupercent": null, + "createdat": "2026-01-01T00:00:00.000000Z", + "credentialstate": "", + "domain": null, + "endpointstateid": 1, + "health": { + "label": "no agent", + "level": "unknown" + }, + "hostname": "", + "idleseconds": null, + "lastboottime": null, + "lastreportedat": null, + "loggedinuser": null, + "machines": [], + "memorypercent": null, + "mtconnectcheckedat": null, + "mtconnectrunning": null, + "pendingreboot": null, + "status": "never", + "trayversion": null, + "uptimedisplay": null, + "uptimeseconds": null + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "businessunitid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "computertypeid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "credentialstate", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "locationid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/{asset_id}": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Get the current state for one endpoint by asset id.", + "description": "Get the current state for one endpoint by asset id.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "actionminimums": { + "collect_log": "1.16.0" + }, + "agentcurrent": "1.6.3", + "agentversion": "1.20.0", + "assetid": 2, + "cpupercent": null, + "createdat": "2026-01-01T00:00:00.000000Z", + "credential": { + "credentialprefix": "", + "enrolledvia": "auto", + "issuedat": "2026-01-01T00:00:00.000000Z", + "lastusedat": "2026-01-01T00:00:00.000000Z", + "reenrollblocked": true, + "reenrollblockedreason": "rollback", + "revokedat": "2026-01-01T00:00:00.000000Z", + "state": "reenrollblocked", + "statelabel": "Re-enroll blocked" + }, + "domain": null, + "endpointstateid": 2, + "hostname": "", + "idleseconds": null, + "lastboottime": null, + "lastreportedat": "2026-01-01T00:00:00.000000Z", + "loggedinuser": null, + "memorypercent": null, + "mtconnectcheckedat": null, + "mtconnectrunning": null, + "pendingreboot": null, + "status": "reporting", + "trayversion": null, + "uptimedisplay": null, + "uptimeseconds": null + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -11090,50 +18642,19 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } ] } }, - "/api/measuringtools/types": { - "get": { - "tags": [ - "plugin-measuringtools" - ], - "summary": "List measuring-tool types (active-only by default), paginated, name-sorted.", - "description": "List measuring-tool types (active-only by default), paginated, name-sorted.\n\n**Auth:** jwt-optional\n\n**Params:** query: active (default true; 'false' includes inactive), search (name ilike), page, perpage\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/measuringtools/types?search=caliper&page=1&perpage=25'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, + "/api/endpoints/{asset_id}/agentupgrade": { "post": { "tags": [ - "plugin-measuringtools" + "plugin-endpoints" ], - "summary": "Create a measuring-tool type; reactivates a soft-deleted same-named one, 409 if an active one exists.", - "description": "Create a measuring-tool type; reactivates a soft-deleted same-named one, 409 if an active one exists.\n\n**Auth:** jwt + permission:measuringtools.create\n\n**Params:** body JSON: name (required), description, color\n\n**Example:**\n```\ncurl -X POST 'http://localhost:5001/api/measuringtools/types' -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"name\":\"Thread Gage\",\"description\":\"Go/no-go thread gages\",\"color\":\"#4caf50\"}'\n```", + "summary": "Push the published agent build to one endpoint.", + "description": "Push the published agent build to one endpoint.\n\n**Auth:** permission:endpoints.agentupdate\n\n**Params:** path: asset_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/1/agentupgrade -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -11144,7 +18665,30 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "action": "run_script", + "assetid": 1, + "claimedat": null, + "completedat": null, + "createdat": "2026-01-01T00:00:00.000000Z", + "endpointcommandid": 1, + "requestedby": "", + "resultmessage": null, + "status": "pending", + "target": "script:1", + "targetkind": "script" + }, + "message": "Command queued", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -11161,444 +18705,6 @@ }, "403": { "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body JSON: name (required), description, color" - } - } - } - } - } - }, - "/api/measuringtools/types/{type_id}": { - "get": { - "tags": [ - "plugin-measuringtools" - ], - "summary": "Get one measuring-tool type by id (404 if missing).", - "description": "Get one measuring-tool type by id (404 if missing).\n\n**Auth:** jwt-optional\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/measuringtools/types/3'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "plugin-measuringtools" - ], - "summary": "Update a measuring-tool type; 409 on rename collision with an existing name.", - "description": "Update a measuring-tool type; 409 on rename collision with an existing name.\n\n**Auth:** jwt + permission:measuringtools.edit\n\n**Params:** path: type_id; body JSON: name, description, color, isactive (only keys present are applied)\n\n**Example:**\n```\ncurl -X PUT 'http://localhost:5001/api/measuringtools/types/3' -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"description\":\"Updated\",\"isactive\":true}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: type_id; body JSON: name, description, color, isactive (only keys present are applied)" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-measuringtools" - ], - "summary": "Hard-delete a measuring-tool type; refused with 409 if any tool still references it.", - "description": "Hard-delete a measuring-tool type; refused with 409 if any tool still references it.\n\n**Auth:** jwt + permission:measuringtools.delete\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl -X DELETE 'http://localhost:5001/api/measuringtools/types/3' -H \"Authorization: Bearer $TOKEN\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/measuringtools": { - "get": { - "tags": [ - "plugin-measuringtools" - ], - "summary": "List measuring tools (Asset core merged with extension), filtered + paginated; derived calibrationstatus filter applied...", - "description": "List measuring tools (Asset core merged with extension), filtered + paginated; derived calibrationstatus filter applied post-pagination.\n\n**Auth:** jwt-optional\n\n**Params:** query: active (default true), assetnumber (exact match, for idempotent import), search (assetnumber/name/serialnumber/gaugelabreference/maintenancereference ilike), typeid, locationid, statusid, calibrationstatus (overdue|duesoon|current|unknown), page, perpage\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/measuringtools?typeid=2&calibrationstatus=overdue&page=1&perpage=50'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-measuringtools" - ], - "summary": "Create a measuring tool: one Asset core row (assettype 'measuring_tool') plus one measuringtools extension row in a...", - "description": "Create a measuring tool: one Asset core row (assettype 'measuring_tool') plus one measuringtools extension row in a single payload; audit-logged.\n\n**Auth:** jwt + permission:measuringtools.create\n\n**Params:** body JSON: assetnumber (required, 409 on duplicate), name, gaugelabreference, maintenancereference, serialnumber, statusid (default 1), locationid, businessunitid, mapx, mapy, notes, measuringtooltypeid, calibrationintervaldays, lastcalibrationdate (YYYY-MM-DD), nextcalibrationdate (YYYY-MM-DD), calibrationprovider; import timestamps honored via X-Import-Mode\n\n**Example:**\n```\ncurl -X POST 'http://localhost:5001/api/measuringtools' -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"assetnumber\":\"MT-0042\",\"name\":\"6in Digital Caliper\",\"measuringtooltypeid\":2,\"calibrationintervaldays\":365,\"lastcalibrationdate\":\"2026-01-15\",\"nextcalibrationdate\":\"2027-01-15\",\"calibrationprovider\":\"Gage Lab\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body JSON: assetnumber (required, 409 on duplicate), name, gaugelabreference, maintenancereference, serialnumber, statusid (default 1), locationid, businessunitid, mapx, mapy, notes, measuringtooltypeid, calibrationintervaldays, lastcalibrationdate (YYYY-MM-DD), nextcalibrationdate (YYYY-MM-DD), calibrationprovider; import timestamps honored via X-Import-Mode" - } - } - } - } - } - }, - "/api/measuringtools/{tool_id}": { - "get": { - "tags": [ - "plugin-measuringtools" - ], - "summary": "Get one measuring tool by measuringtoolid, asset core dict with extension nested under 'measuringtool'.", - "description": "Get one measuring tool by measuringtoolid, asset core dict with extension nested under 'measuringtool'.\n\n**Auth:** jwt-optional\n\n**Params:** path: tool_id (int)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/measuringtools/17'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "tool_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "plugin-measuringtools" - ], - "summary": "Update asset core fields and extension fields in one payload; 409 on assetnumber collision; changes audit-logged.", - "description": "Update asset core fields and extension fields in one payload; 409 on assetnumber collision; changes audit-logged.\n\n**Auth:** jwt + permission:measuringtools.edit\n\n**Params:** path: tool_id; body JSON (only present keys applied): asset fields assetnumber, name, gaugelabreference, maintenancereference, serialnumber, statusid, locationid, businessunitid, mapx, mapy, notes, isactive; extension fields measuringtooltypeid, calibrationintervaldays, calibrationprovider, notes, lastcalibrationdate, nextcalibrationdate (YYYY-MM-DD)\n\n**Example:**\n```\ncurl -X PUT 'http://localhost:5001/api/measuringtools/17' -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"locationid\":5,\"nextcalibrationdate\":\"2026-12-01\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "tool_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: tool_id; body JSON (only present keys applied): asset fields assetnumber, name, gaugelabreference, maintenancereference, serialnumber, statusid, locationid, businessunitid, mapx, mapy, notes, isactive; extension fields measuringtooltypeid, calibrationintervaldays, calibrationprovider, notes, lastcalibrationdate, nextcalibrationdate (YYYY-MM-DD)" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-measuringtools" - ], - "summary": "Soft-delete a measuring tool by setting its asset isactive=false; audit-logged.", - "description": "Soft-delete a measuring tool by setting its asset isactive=false; audit-logged.\n\n**Auth:** jwt + permission:measuringtools.delete\n\n**Params:** path: tool_id (int)\n\n**Example:**\n```\ncurl -X DELETE 'http://localhost:5001/api/measuringtools/17' -H \"Authorization: Bearer $TOKEN\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "tool_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/measuringtools/by-asset/{asset_id}": { - "get": { - "tags": [ - "plugin-measuringtools" - ], - "summary": "Get a measuring tool by its core assetid (404 if the asset has no extension row).", - "description": "Get a measuring tool by its core assetid (404 if the asset has no extension row).\n\n**Auth:** jwt-optional\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/measuringtools/by-asset/1042'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } }, "404": { "description": "No such record." @@ -11610,54 +18716,36 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } - ] - } - }, - "/api/measuringtools/map-overlay": { - "get": { - "tags": [ - "plugin-measuringtools" ], - "summary": "ADR-010 map overlay: per-asset calibration badges [{assetid, color, label}] for active tools that are overdue or due...", - "description": "ADR-010 map overlay: per-asset calibration badges [{assetid, color, label}] for active tools that are overdue or due soon only; no coordinates returned.\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/measuringtools/map-overlay'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: asset_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "action": "run_script", + "target": "script:1" } } } } } }, - "/api/measuringtools/report/calibration": { + "/api/endpoints/{asset_id}/commands": { "get": { "tags": [ - "plugin-measuringtools" + "plugin-endpoints" ], - "summary": "Calibration report for the Reports hub: counts and full tool lists bucketed by derived status...", - "description": "Calibration report for the Reports hub: counts and full tool lists bucketed by derived status (overdue/duesoon/current/unknown) plus statuscolors map.\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/measuringtools/report/calibration'\n```", + "summary": "Recent commands for one endpoint, newest first (operator view).", + "description": "Recent commands for one endpoint, newest first (operator view).\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/1/commands -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -11667,7 +18755,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -11678,6 +18768,7649 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Queue an allowlisted command for one endpoint.", + "description": "Queue an allowlisted command for one endpoint.\n\n**Auth:** permission:endpoints.command\n\n**Params:** path: asset_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/1/commands -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "action": "collect_log", + "assetid": 1, + "claimedat": null, + "completedat": null, + "createdat": "2026-01-01T00:00:00.000000Z", + "endpointcommandid": 1, + "requestedby": "", + "resultmessage": null, + "status": "pending", + "target": "C:\\logs\\app.log", + "targetkind": "logpath" + }, + "message": "Command queued", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: asset_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "action": "service_status", + "overridereason": "line down, need status", + "policyoverride": true, + "target": "spooler" + } + } + } + } + } + }, + "/api/endpoints/{asset_id}/events": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "List curated Windows events for one endpoint, newest first.", + "description": "List curated Windows events for one endpoint, newest first.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/1/events -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "eventid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/{asset_id}/holdableactions": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Desired-mode actions on this endpoint, with current hold state.", + "description": "Desired-mode actions on this endpoint, with current hold state.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/1/holdableactions -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "action": "service_start", + "held": false + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/{asset_id}/lock": { + "delete": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Lift the quarantine on this endpoint.", + "description": "Lift the quarantine on this endpoint.\n\n**Auth:** permission:endpoints.command\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/endpoints/1/lock -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "The endpoint lock standing on this endpoint, or null.", + "description": "The endpoint lock standing on this endpoint, or null.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/1/lock -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "endpointlockid": 1, + "lockedat": "2026-01-01T00:00:00.000000Z", + "lockedbyuserid": 1, + "lockedbyusername": "", + "reason": "visible to all", + "releasedat": null, + "releasedbyusername": "" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Quarantine this endpoint against non-admin commands.", + "description": "Quarantine this endpoint against non-admin commands.\n\n**Auth:** permission:endpoints.command\n\n**Params:** path: asset_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/1/lock -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "endpointlockid": 1, + "lockedat": "2026-01-01T00:00:00.000000Z", + "lockedbyuserid": 1, + "lockedbyusername": "", + "reason": "locked but still reconciled", + "releasedat": null, + "releasedbyusername": "" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: asset_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "reason": "locked but still reconciled" + } + } + } + } + } + }, + "/api/endpoints/{asset_id}/logcollections": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Collected-log history for one endpoint, newest first.", + "description": "Collected-log history for one endpoint, newest first.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/1/logcollections -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/{asset_id}/policyholds": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Active break-glass holds for one endpoint.", + "description": "Active break-glass holds for one endpoint.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/1/policyholds -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "action": "service_stop", + "createdbyusername": "", + "expiresat": "2026-01-01T00:00:00.000000", + "reason": "vendor on site" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Deliberately suspend desired-state reconciliation for one or more actions.", + "description": "Deliberately suspend desired-state reconciliation for one or more actions.\n\n**Auth:** permission:endpoints.command\n\n**Params:** path: asset_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/1/policyholds -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "action": "service_stop", + "createdbyusername": "", + "expiresat": "2026-01-01T00:00:00.000000", + "reason": "stripping the enclosure" + }, + { + "action": "service_start", + "createdbyusername": "", + "expiresat": "2026-01-01T00:00:00.000000", + "reason": "stripping the enclosure" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: asset_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "actions": [ + "service_start", + "service_stop" + ], + "minutes": 60, + "reason": "stripping the enclosure" + } + } + } + } + } + }, + "/api/endpoints/{asset_id}/policyholds/{action}": { + "delete": { + "tags": [ + "plugin-endpoints" + ], + "summary": "End a hold early, resuming reconciliation of one action.", + "description": "End a hold early, resuming reconciliation of one action.\n\n**Auth:** permission:endpoints.command\n\n**Params:** path: asset_id (int), action (string)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/endpoints/1/policyholds/x -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "action", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/{asset_id}/scriptruns": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "The last few run_script commands for one endpoint, newest first.", + "description": "The last few run_script commands for one endpoint, newest first.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/1/scriptruns -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "action": "run_script", + "assetid": 1, + "claimedat": null, + "completedat": null, + "createdat": "2026-01-01T00:00:00Z", + "endpointcommandid": 6, + "requestedby": "", + "resultdetail": null, + "resultmessage": null, + "scriptname": "test-open-notepad", + "status": "succeeded", + "target": "script:1", + "targetkind": null + }, + { + "action": "run_script", + "assetid": 1, + "claimedat": null, + "completedat": null, + "createdat": "2026-01-01T00:00:00Z", + "endpointcommandid": 5, + "requestedby": "", + "resultdetail": null, + "resultmessage": null, + "scriptname": "test-open-notepad", + "status": "succeeded", + "target": "script:1", + "targetkind": null + }, + { + "action": "run_script", + "assetid": 1, + "claimedat": null, + "completedat": null, + "createdat": "2026-01-01T00:00:00Z", + "endpointcommandid": 4, + "requestedby": "", + "resultdetail": null, + "resultmessage": null, + "scriptname": "test-open-notepad", + "status": "succeeded", + "target": "script:1", + "targetkind": null + }, + "" + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 5 + } + } + ] + } + }, + "/api/endpoints/adoptiontool/package": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Bundle the adoption tool as a downloadable ZIP.", + "description": "Bundle the adoption tool as a downloadable ZIP.\n\n**Auth:** permission:endpoints.agentpackage\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/adoptiontool/package -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/endpoints/agentkey": { + "delete": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Revoke the endpoint agent key: stop all managed-token endpoint traffic.", + "description": "Revoke the endpoint agent key: stop all managed-token endpoint traffic.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/endpoints/agentkey -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "envkeyfallback": true, + "key": null, + "provisioned": false, + "revoked": 0 + }, + "message": "Endpoint agent key revoked", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + }, + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Report the endpoint agent key state (no secret).", + "description": "Report the endpoint agent key state (no secret).\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/agentkey -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/agentkey/regenerate": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Mint a fresh endpoint agent key, revoking any previous one.", + "description": "Mint a fresh endpoint agent key, revoking any previous one.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/agentkey/regenerate -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "envkeyfallback": false, + "key": { + "createddate": "2026-01-01T00:00:00.000000Z", + "displayprefix": "", + "expiresat": null, + "isactive": true, + "isexpired": false, + "lastusedat": null, + "name": "Endpoint agent key", + "resourcescopes": null, + "scopes": [ + "collector.ingest.endpoints" + ], + "tokenid": 1, + "tokenprefix": "", + "userid": 1 + }, + "provisioned": true, + "secret": "", + "warning": "Save this key now. It will not be shown again. Configure it as the agent X-API-Key." + }, + "message": "Endpoint agent key generated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": {} + } + } + } + } + }, + "/api/endpoints/alerts": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Fleet alerts for one endpoint by hostname (collector-token auth).", + "description": "Fleet alerts for one endpoint by hostname (collector-token auth).\n\n**Auth:** api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/alerts -H \"X-API-Key: $KEY\"\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "message": "Scheduled maintenance at 3:30 PM", + "notificationId": 1, + "notificationid": 1, + "timeout": 0, + "title": "Scheduled maintenance at 3:30 PM", + "type": "Fleet Alert", + "typeColor": "#ffc107", + "typecolor": "#ffc107" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "hostname", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/alerts/{notification_id}/acknowledge": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent acknowledges a fleet alert (collector-token auth).", + "description": "Agent acknowledges a fleet alert (collector-token auth).\n\n**Auth:** api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)\n\n**Params:** path: notification_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/alerts/1/acknowledge -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "acknowledged": true + }, + "message": "Alert already acknowledged", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "notification_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: notification_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "assetid": 1, + "hostname": "" + } + } + } + } + } + }, + "/api/endpoints/apppackage/{appversionid}": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent-facing installer download (collector-token auth).", + "description": "Agent-facing installer download (collector-token auth).\n\n**Auth:** api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)\n\n**Params:** path: appversionid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/1 -H \"X-API-Key: $KEY\"\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "appversionid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/apppackage/app/{app_id}": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent-facing installer download by APP (collector-token auth).", + "description": "Agent-facing installer download by APP (collector-token auth).\n\n**Auth:** api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/1 -H \"X-API-Key: $KEY\"\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/apppackage/app/{app_id}/{filename}": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent-facing installer download by APP (collector-token auth).", + "description": "Agent-facing installer download by APP (collector-token auth).\n\n**Auth:** api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)\n\n**Params:** path: app_id (int), filename (path)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/1/example.png -H \"X-API-Key: $KEY\"\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "may contain slashes" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/activity": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "List panel: one chronological feed of what happened to this endpoint.", + "description": "List panel: one chronological feed of what happened to this endpoint.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/activity -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/applications": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Table panel: the installed-software (SBOM) snapshot for this endpoint.", + "description": "Table panel: the installed-software (SBOM) snapshot for this endpoint.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/applications -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/diskhealth": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Physical drive health rows.", + "description": "Physical drive health rows.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/diskhealth -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/hardware": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Keyvalue panel: processor, memory and system chassis inventory.", + "description": "Keyvalue panel: processor, memory and system chassis inventory.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/hardware -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/interfaces": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Table panel: every hardware NIC the agent last reported.", + "description": "Table panel: every hardware NIC the agent last reported.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/interfaces -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/iphistory": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Table panel: every address this asset has held, newest change first.", + "description": "Table panel: every address this asset has held, newest change first.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/iphistory -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/listeners": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Table panel: TCP sockets in LISTEN, with the owning process.", + "description": "Table panel: TCP sockets in LISTEN, with the owning process.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/listeners -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/mappeddrives": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "List panel: mapped network drives with a health badge each.", + "description": "List panel: mapped network drives with a health badge each.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/mappeddrives -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/osinfo": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Keyvalue panel: operating system, firmware and domain membership.", + "description": "Keyvalue panel: operating system, firmware and domain membership.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/osinfo -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/recentevents": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "List panel: the endpoint's most recent curated events, newest first.", + "description": "List panel: the endpoint's most recent curated events, newest first.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/recentevents -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/services": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "List panel: monitored services/processes with a run-state badge per row.", + "description": "List panel: monitored services/processes with a run-state badge per row.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/services -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "action": { + "running": false, + "start": "process_start", + "status": "process_status", + "stop": "process_stop", + "target": "dncMain.exe", + "targetkind": "process" + }, + "drift": true, + "kind": "process", + "name": "eDNC", + "statecolor": "gray", + "statelabel": "Unknown" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/status": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Keyvalue panel: the endpoint's current status snapshot.", + "description": "Keyvalue panel: the endpoint's current status snapshot.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/status -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "agentbehind": false, + "agentcurrent": "1.6.3", + "agentversion": "1.20.0", + "credential": { + "credentialprefix": "", + "enrolledvia": "auto", + "issuedat": "2026-01-01T00:00:00.000000Z", + "lastusedat": "2026-01-01T00:00:00.000000Z", + "reenrollblocked": true, + "reenrollblockedreason": "rollback", + "revokedat": "2026-01-01T00:00:00.000000Z", + "state": "reenrollblocked", + "statelabel": "Re-enroll blocked" + }, + "fields": [ + { + "label": "Agent", + "mono": true, + "value": "1.20.0" + }, + { + "format": "datetime", + "label": "Last reported", + "mono": false, + "value": "2026-01-01T00:00:00.000000Z" + }, + { + "label": "Credential", + "mono": false, + "value": "Re-enroll blocked" + }, + "" + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/storage": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Tabs panel: volume capacity and physical drive health as two sections.", + "description": "Tabs panel: volume capacity and physical drive health as two sections.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/storage -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/telemetrymeta": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Lightweight freshness probe for the panels.", + "description": "Lightweight freshness probe for the panels.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/telemetrymeta -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/usercontext": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Keyvalue panel: session-scoped data from the interactive user probe.", + "description": "Keyvalue panel: session-scoped data from the interactive user probe.\n\n**Auth:** permission:endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/1/usercontext -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/collect": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent telemetry ingest. Same handler as POST /api/collector/endpoints.", + "description": "Agent telemetry ingest. Same handler as POST /api/collector/endpoints.\n\n**Auth:** api-key (endpoints collector token)\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/collect -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "action": "created", + "assetid": 1, + "identityvalue": "BAY-01", + "status": "ok", + "warnings": [] + }, + "message": "endpoints collector created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "agentversion": "1.20.0", + "hostname": "" + } + } + } + } + } + }, + "/api/endpoints/commands/{command_id}/result": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent reports a claimed command's outcome (collector-token auth).", + "description": "Agent reports a claimed command's outcome (collector-token auth).\n\n**Auth:** api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)\n\n**Params:** path: command_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/commands/1/result -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "action": "mtconnect_status", + "assetid": 1, + "claimedat": null, + "completedat": "2026-01-01T00:00:00.000000Z", + "createdat": "2026-01-01T00:00:00.000000Z", + "endpointcommandid": 1, + "requestedby": "", + "resultmessage": null, + "status": "succeeded", + "target": null, + "targetkind": null + }, + "message": "Result recorded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "command_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "hostname", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: command_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "succeeded": true + } + } + } + } + } + }, + "/api/endpoints/commands/next": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent long-poll: claim the next pending command for a hostname.", + "description": "Agent long-poll: claim the next pending command for a hostname.\n\n**Auth:** api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/commands/next -H \"X-API-Key: $KEY\"\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "action": "mtconnect_status", + "endpointcommandid": 1, + "target": null, + "targetkind": null + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "hostname", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/dashboard/enrollmentpending": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "One queue row while enroll requests wait for an admin, else nothing.", + "description": "One queue row while enroll requests wait for an admin, else nothing.\n\n**Auth:** permission:endpoints.fleet\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/dashboard/enrollmentpending -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "pendingcount": 2, + "summary": "2 PCs waiting for enrollment approval" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/dashboard/quiet": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Endpoints that reported before, then went quiet past the window.", + "description": "Endpoints that reported before, then went quiet past the window.\n\n**Auth:** permission:endpoints.fleet\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/dashboard/quiet -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/dashboard/reenrollblocked": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "PCs kept from re-enrolling for more than 7 days.", + "description": "PCs kept from re-enrolling for more than 7 days.\n\n**Auth:** permission:endpoints.fleet\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/dashboard/reenrollblocked -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "assetid": 2, + "blockeddays": 9, + "computerid": 2, + "hostname": "", + "reason": "rollback" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/dashboard/stability": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Endpoints with recent crash/shutdown/corruption events.", + "description": "Endpoints with recent crash/shutdown/corruption events.\n\n**Auth:** permission:endpoints.fleet\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/dashboard/stability -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/dashboard/summary": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "High-level fleet counts for the endpoints dashboard.", + "description": "High-level fleet counts for the endpoints dashboard.\n\n**Auth:** permission:endpoints.fleet\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/dashboard/summary -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/enroll": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "A PC asks for its own credential.", + "description": "A PC asks for its own credential.\n\n**Auth:** none (per-IP rate limited and capped; a valid fleet key only decides auto-enroll)\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/enroll -H 'Content-Type: application/json' -d '{}'\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "credential": "", + "status": "enrolled" + }, + "message": "Endpoint enrolled", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "agentversion": "1.20.0", + "deviceid": null, + "enrollnonce": "mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm", + "hostname": "", + "serialnumber": "" + } + } + } + } + } + }, + "/api/endpoints/enrollment": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Credential state of every computer, plus pending and recent requests.", + "description": "Credential state of every computer, plus pending and recent requests.\n\n**Auth:** permission:endpoints.fleet\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/enrollment -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "computers": [ + { + "assetid": 1, + "credentialprefix": "", + "enrolledvia": "auto", + "hostname": "", + "issuedat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "reenrollblocked": false, + "reenrollblockedreason": null, + "serialnumber": "", + "state": "enrolled" + }, + { + "assetid": 2, + "credentialprefix": "", + "enrolledvia": null, + "hostname": "", + "issuedat": null, + "lastusedat": null, + "reenrollblocked": false, + "reenrollblockedreason": null, + "serialnumber": "", + "state": "notenrolled" + }, + { + "assetid": 3, + "credentialprefix": "", + "enrolledvia": null, + "hostname": "", + "issuedat": null, + "lastusedat": null, + "reenrollblocked": false, + "reenrollblockedreason": null, + "serialnumber": "", + "state": "pending" + } + ], + "counts": { + "enrolled": 1, + "notenrolled": 1, + "pending": 1, + "pendingcomputers": 1 + }, + "requests": [ + { + "agentversion": "1.20.0", + "decidedat": null, + "decidedby": null, + "deviceid": null, + "hostname": "", + "lastseenat": "2026-01-01T00:00:00.000000Z", + "matchedassetid": 3, + "reason": "no serial on record", + "requestedat": "2026-01-01T00:00:00.000000Z", + "requestid": "00000000", + "serialnumber": "", + "sourceip": "127.0.0.1", + "status": "pending" + }, + { + "agentversion": "1.20.0", + "decidedat": null, + "decidedby": null, + "deviceid": null, + "hostname": "", + "lastseenat": "2026-01-01T00:00:00.000000Z", + "matchedassetid": 1, + "reason": null, + "requestedat": "2026-01-01T00:00:00.000000Z", + "requestid": "00000000", + "serialnumber": "", + "sourceip": "127.0.0.1", + "status": "issued" + } + ], + "settings": { + "autoenroll": true, + "maxpendingenrollments": 500, + "revokeddeniessharedkey": false, + "sharedkeyallowed": true + } + }, + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 3, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/enrollment/assets/{assetid}/allow-reenroll": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Lift a re-enrollment block. The PC enrolls on its next attempt.", + "description": "Lift a re-enrollment block. The PC enrolls on its next attempt.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: assetid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/enrollment/assets/1/allow-reenroll -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "credentialid": 1, + "credentialprefix": "", + "enrolledvia": "auto", + "issuedat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "reenrollblocked": false, + "reenrollblockedreason": null, + "revokedat": "2026-01-01T00:00:00.000000Z", + "revokedby": "testadmin" + }, + "message": "Re-enrollment allowed", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "assetid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: assetid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/endpoints/enrollment/assets/{assetid}/revoke": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Un-enroll a PC. Its agent gets CREDENTIAL_INVALID and enrolls again.", + "description": "Un-enroll a PC. Its agent gets CREDENTIAL_INVALID and enrolls again.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: assetid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/enrollment/assets/1/revoke -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "credentialid": 1, + "credentialprefix": "", + "enrolledvia": "auto", + "issuedat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "reenrollblocked": true, + "reenrollblockedreason": "rollback", + "revokedat": "2026-01-01T00:00:00.000000Z", + "revokedby": "testadmin" + }, + "message": "Endpoint credential revoked", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "assetid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: assetid (int); body: JSON (fields not yet documented)" + }, + "example": { + "blockreenroll": true, + "reason": "rollback" + } + } + } + } + } + }, + "/api/endpoints/enrollment/migration": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Per-computer readiness to move onto per-PC credentials, plus waves.", + "description": "Per-computer readiness to move onto per-PC credentials, plus waves.\n\n**Auth:** permission:endpoints.fleet\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/enrollment/migration -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "computers": [ + { + "agentversion": "1.19.0", + "assetid": 2, + "blockers": [], + "hostname": "", + "lastreportedat": "2026-01-01T00:00:00.000000Z", + "state": "ready" + }, + { + "agentversion": "1.19.0", + "assetid": 3, + "blockers": [], + "hostname": "", + "lastreportedat": "2026-01-01T00:00:00.000000Z", + "state": "ready" + }, + { + "agentversion": "1.20.0", + "assetid": 1, + "blockers": [ + "awaiting enrollment" + ], + "hostname": "", + "lastreportedat": "2026-01-01T00:00:00.000000Z", + "state": "blocked" + } + ], + "counts": { + "blocked": 1, + "enrolled": 0, + "notreporting": 0, + "pending": 0, + "ready": 2, + "upgrading": 0 + }, + "target": "1.20.0", + "waves": [ + { + "createdat": "2026-01-01T00:00:00Z", + "createdby": "", + "done": false, + "enrolled": 0, + "failed": 1, + "queued": 3, + "size": 3, + "waveid": 1 + }, + { + "createdat": "2026-01-01T00:00:00Z", + "createdby": "", + "done": false, + "enrolled": 0, + "failed": 0, + "queued": 2, + "size": 2, + "waveid": 2 + } + ] + }, + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 3, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/enrollment/migration/wave": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Push the agent upgrade to a batch of READY PCs.", + "description": "Push the agent upgrade to a batch of READY PCs.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/enrollment/migration/wave -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "queued": [ + 1 + ], + "retried": [], + "skipped": [ + { + "assetid": 2, + "reason": "offline" + }, + { + "assetid": 3, + "reason": "notreporting" + }, + { + "assetid": 999999, + "reason": "not a computer" + } + ], + "waveid": 1 + }, + "message": "1 PC(s) queued", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "action": "run_script", + "target": "script:1" + } + } + } + } + } + }, + "/api/endpoints/enrollment/requests/{requestid}/approve": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Bind a pending request to the asset the admin picked.", + "description": "Bind a pending request to the asset the admin picked.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: requestid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/enrollment/requests/1/approve -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "agentversion": "1.20.0", + "decidedat": "2026-01-01T00:00:00.000000Z", + "decidedby": "testadmin", + "deviceid": null, + "hostname": "", + "hostnameresult": "", + "lastseenat": "2026-01-01T00:00:00.000000Z", + "matchedassetid": 1, + "reason": null, + "requestedat": "2026-01-01T00:00:00.000000Z", + "requestid": "00000000", + "serialnumber": "", + "sourceip": "127.0.0.1", + "status": "approved" + }, + "message": "Enroll request approved; that computer was renamed to the hostname the PC reports", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "requestid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: requestid (int); body: JSON (fields not yet documented)" + }, + "example": { + "assetid": 1, + "sethostname": "" + } + } + } + } + } + }, + "/api/endpoints/enrollment/requests/{requestid}/reject": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "UNDOCUMENTED: endpoints.reject_enroll_request has no docstring.", + "description": "UNDOCUMENTED: endpoints.reject_enroll_request has no docstring.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: requestid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/enrollment/requests/1/reject -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "agentversion": "1.20.0", + "decidedat": "2026-01-01T00:00:00.000000Z", + "decidedby": "testadmin", + "deviceid": null, + "hostname": "", + "lastseenat": "2026-01-01T00:00:00.000000Z", + "matchedassetid": 1, + "reason": "awaiting approval", + "requestedat": "2026-01-01T00:00:00.000000Z", + "requestid": "00000000", + "serialnumber": "", + "sourceip": "127.0.0.1", + "status": "rejected" + }, + "message": "Enroll request rejected", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "requestid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: requestid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/endpoints/enrollment/settings": { + "put": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Change the enrollment settings.", + "description": "Change the enrollment settings.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/endpoints/enrollment/settings -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "autoenroll": true, + "maxpendingenrollments": 500, + "revokeddeniessharedkey": false, + "sharedkeyallowed": false + }, + "message": "Enrollment settings saved", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "revokeddeniessharedkey": true + } + } + } + } + } + }, + "/api/endpoints/logcollections/{collection_id}": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "One collected log including its content, for the scrollable pane.", + "description": "One collected log including its content, for the scrollable pane.\n\n**Auth:** permission:endpoints.updateapp\n\n**Params:** path: collection_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/logcollections/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/logcollections/{collection_id}/download": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Serve a collected log as a file attachment.", + "description": "Serve a collected log as a file attachment.\n\n**Auth:** permission:endpoints.updateapp\n\n**Params:** path: collection_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/logcollections/1/download -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/monitors": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "List monitor definitions (shipped and local).", + "description": "List monitor definitions (shipped and local).\n\n**Auth:** permission:endpoints.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + }, + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Create a user-defined (local) monitor definition.", + "description": "Create a user-defined (local) monitor definition.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/endpoints/monitors/{monitor_id}": { + "delete": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Delete a local monitor definition. Shipped rows are protected.", + "description": "Delete a local monitor definition. Shipped rows are protected.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: monitor_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/endpoints/monitors/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "monitor_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Update a local monitor definition. Shipped rows are read-only.", + "description": "Update a local monitor definition. Shipped rows are read-only.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: monitor_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/endpoints/monitors/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createdat": "2026-01-01T00:00:00.000000Z", + "datacollectorpath": null, + "displayname": "", + "endpointmonitorid": 1, + "isactive": false, + "kind": "process", + "launchpath": "C:\\Program Files\\Dnc\\bin\\DncMain.exe", + "logcollectorpath": null, + "processname": "UDC", + "servicename": null, + "source": "shipped" + }, + "message": "Monitor updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "monitor_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: monitor_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "launchpath": "D:\\Dnc\\bin\\DncMain.exe" + } + } + } + } + } + }, + "/api/endpoints/monitors/resolve": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Resolve the monitor set for a PC type (agent runtime fetch).", + "description": "Resolve the monitor set for a PC type (agent runtime fetch).\n\n**Auth:** permission:endpoints.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/monitors/resolve -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "pctype", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/monitors/resolved": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent watch list: the monitors this endpoint should report on.", + "description": "Agent watch list: the monitors this endpoint should report on.\n\n**Auth:** api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/monitors/resolved -H \"X-API-Key: $KEY\"\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "processes": [ + { + "app": "Edge", + "launchpath": "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + "name": "msedge.exe" + } + ], + "services": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "hostname", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/operatorreports": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent uploads one operator issue report (collector-token auth).", + "description": "Agent uploads one operator issue report (collector-token auth).\n\n**Auth:** api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/operatorreports -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "createdat": "2026-01-01T00:00:00.000000Z", + "details": "the CMM app will not start", + "hasimage": true, + "imagebytesize": 70, + "imagemimetype": "image/png", + "operatorreportid": 1, + "reason": "Other (free text)", + "reportedhostname": "" + }, + "message": "Operator report received", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/endpoints/operatorreports/{operatorreportid}/image": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Serve one operator report's screenshot.", + "description": "Serve one operator report's screenshot.\n\n**Auth:** permission:endpoints.operatorreports\n\n**Params:** path: operatorreportid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/operatorreports/1/image -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "operatorreportid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/panel/{asset_id}/operatorreports": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Operator Reports panel: this PC's recent operator issue reports.", + "description": "Operator Reports panel: this PC's recent operator issue reports.\n\n**Auth:** permission:endpoints.operatorreports\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/panel/1/operatorreports -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "reports": [ + { + "assetid": 1, + "createdat": "2026-01-01T00:00:00.000000Z", + "details": "second", + "hasimage": false, + "imagebytesize": null, + "imagemimetype": null, + "operatorreportid": 2, + "reason": "Other (free text)", + "reportedhostname": "" + }, + { + "assetid": 1, + "createdat": "2026-01-01T00:00:00.000000Z", + "details": "first", + "hasimage": false, + "imagebytesize": null, + "imagemimetype": null, + "operatorreportid": 1, + "reason": "Other (free text)", + "reportedhostname": "" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/scriptbody/{script_id}": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent-facing script body download (collector-token auth).", + "description": "Agent-facing script body download (collector-token auth).\n\n**Auth:** api-key (endpoint agent credential, or the fleet key while sharedkeyallowed is on)\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/scriptbody/1 -H \"X-API-Key: $KEY\"\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "script_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/endpoints/settings/server-cert": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Get details about the current server certificate.", + "description": "Get details about the current server certificate.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/settings/server-cert -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "expires_in_days": 29, + "is_self_signed": true, + "issuer": "CN = pin.example.test", + "not_after": "Oct 18 18:57:47 2026 GMT", + "not_before": "Sep 17 18:57:47 2026 GMT", + "san_list": [], + "sha1thumbprint": "", + "spkisha256": "", + "subject_cn": "pin.example.test", + "thumbprint": "99:F1:E1:1D:5A:66:EE:44:8C:CA:5C:D8:BF:EA:C3:F3:8F:82:40:D3:5E:C9:4D:F0:AC:0B:3C:AD:80:D7:C1:DF" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/settings/server-cert/download": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "The PUBLIC server certificate as a DER .cer file, never the key.", + "description": "The PUBLIC server certificate as a DER .cer file, never the key.\n\n**Auth:** permission:endpoints.fleet\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/settings/server-cert/download -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/settings/server-cert/generate": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Generate a new self-signed server certificate.", + "description": "Generate a new self-signed server certificate.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/settings/server-cert/generate -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "message": "Certificate generated successfully. Nginx is restarting." + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "cn": "shopdb.example.test", + "days": 30, + "san": "" + } + } + } + } + } + }, + "/api/endpoints/settings/server-cert/live": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "The certificate PCs actually receive from this site's public address.", + "description": "The certificate PCs actually receive from this site's public address.\n\n**Auth:** permission:endpoints.fleet\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/settings/server-cert/live -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "configured": true, + "error": "Could not read the certificate from shopdb.example.test:443 (Connection refused)", + "matchesfile": false, + "presentedsha1thumbprint": null, + "presentedspkisha256": null, + "url": "https://shopdb.example.test" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/settings/server-cert/upload": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Upload a new server certificate and private key.", + "description": "Upload a new server certificate and private key.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/settings/server-cert/upload -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "message": "Certificate uploaded successfully. Nginx is restarting." + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/endpoints/siteagent": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Report whether the site agent build has a prebuilt binary staged.", + "description": "Report whether the site agent build has a prebuilt binary staged.\n\n**Auth:** permission:endpoints.agentpackage\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "agentcurrent": null, + "agentlegacy": null, + "agentstaged": false, + "agentversions": [], + "certavailable": true, + "serverpin": "", + "sharedkeyallowed": false, + "traycurrent": null, + "traylegacy": null, + "traystaged": false, + "trayversions": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/siteagent/binaries/{binkey}": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "List staged versions for a binary (agent or tray), newest first.", + "description": "List staged versions for a binary (agent or tray), newest first.\n\n**Auth:** permission:endpoints.agentpackage\n\n**Params:** path: binkey (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent/binaries/x -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "binkey", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Stage an uploaded binary as a new version.", + "description": "Stage an uploaded binary as a new version.\n\n**Auth:** permission:endpoints.agentpackage\n\n**Params:** path: binkey (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/x -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "binary": "agent", + "current": "", + "registration": { + "agentversion": "", + "appid": 1, + "appname": "ShopDB Endpoint Agent", + "contents": { + "agent": { + "filename": "shopdbagent.exe", + "sha256": "", + "version": "" + } + }, + "created": false, + "installpath": "/api/applications/package/1/shopdb-endpoint-agent.zip", + "ok": true, + "sha256": "", + "size": 135 + }, + "versions": [ + { + "iscurrent": true, + "size": 130, + "uploadedat": "2026-01-01T00:00:00.000000Z", + "version": "" + }, + { + "iscurrent": false, + "size": 130, + "uploadedat": "2026-01-01T00:00:00.000000Z", + "version": "" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "binkey", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: binkey (string); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/endpoints/siteagent/binaries/{binkey}/adopt": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Adopt the unmanaged legacy flat file into the version store.", + "description": "Adopt the unmanaged legacy flat file into the version store.\n\n**Auth:** permission:endpoints.agentpackage\n\n**Params:** path: binkey (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/x/adopt -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "binkey", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: binkey (string); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/endpoints/siteagent/binaries/{binkey}/current": { + "put": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Point 'current' at an already-staged version (roll back or forward).", + "description": "Point 'current' at an already-staged version (roll back or forward).\n\n**Auth:** permission:endpoints.agentpackage\n\n**Params:** path: binkey (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/endpoints/siteagent/binaries/x/current -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "binary": "agent", + "current": "", + "registration": { + "agentversion": "", + "appid": 1, + "appname": "ShopDB Endpoint Agent", + "contents": { + "agent": { + "filename": "shopdbagent.exe", + "sha256": "", + "version": "" + } + }, + "created": false, + "installpath": "/api/applications/package/1/shopdb-endpoint-agent.zip", + "ok": true, + "sha256": "", + "size": 135 + }, + "versions": [ + { + "iscurrent": false, + "size": 258, + "uploadedat": "2026-01-01T00:00:00.000000Z", + "version": "" + }, + { + "iscurrent": true, + "size": 258, + "uploadedat": "2026-01-01T00:00:00.000000Z", + "version": "" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "binkey", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: binkey (string); body: JSON (fields not yet documented)" + }, + "example": { + "version": "" + } + } + } + } + } + }, + "/api/endpoints/siteagent/binaries/{binkey}/detect": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Read the PE file version of an uploaded EXE without staging it.", + "description": "Read the PE file version of an uploaded EXE without staging it.\n\n**Auth:** permission:endpoints.agentpackage\n\n**Params:** path: binkey (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/x/detect -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "binkey", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: binkey (string); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/endpoints/siteagent/bootstrap.ps1": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "The PowerShell the one-line command downloads, runs and deletes.", + "description": "The PowerShell the one-line command downloads, runs and deletes.\n\n**Auth:** api-key (single-use bootstrap token in the X-Endpoint-Bootstrap-Token header, or ?t= while that is enabled; per-IP rate limited, 404 when not usable)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent/bootstrap.ps1\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/siteagent/bootstrap/package": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "The keyless agent package the bootstrap script installs.", + "description": "The keyless agent package the bootstrap script installs.\n\n**Auth:** api-key (single-use bootstrap token in the X-Endpoint-Bootstrap-Token header, or ?t= while that is enabled; per-IP rate limited, 404 when not usable)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent/bootstrap/package\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/siteagent/bootstrap/tokens": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Every bootstrap token, newest first. Never a secret.", + "description": "Every bootstrap token, newest first. Never a secret.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent/bootstrap/tokens -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "tokens": [ + { + "createdat": "2026-01-01T00:00:00.000000Z", + "createdby": "", + "expiresat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "lastusedip": null, + "maxuses": 25, + "remaining": 25, + "revokedat": null, + "state": "active", + "tokenid": 1, + "tokenprefix": "", + "usecount": 0 + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + }, + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Mint one short-lived bootstrap token. The secret is returned ONCE.", + "description": "Mint one short-lived bootstrap token. The secret is returned ONCE.\n\n**Auth:** jwt + role:admin\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/bootstrap/tokens -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "command": "powershell -ExecutionPolicy Bypass -c \"try { New-Item -ItemType Directory -Force -Path 'C:\\Program Files\\ShopDB\\bootstrap' | Out-Null; icacls 'C:\\Program Files\\ShopDB\\bootstrap' /inheritance:r /grant ...", + "scripturl": "https://shopdb.test/shopdb/api/endpoints/siteagent/bootstrap.ps1?t=&via=download", + "secret": "", + "token": { + "createdat": "2026-01-01T00:00:00.000000Z", + "createdby": "", + "expiresat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "lastusedip": null, + "maxuses": 500, + "remaining": 500, + "revokedat": null, + "state": "active", + "tokenid": 1, + "tokenprefix": "", + "usecount": 0 + } + }, + "message": "Bootstrap token created. The command is shown once.", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "hours": 6, + "maxuses": 3 + } + } + } + } + } + }, + "/api/endpoints/siteagent/bootstrap/tokens/{tokenid}/revoke": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Kill a bootstrap token now. Already-revoked is a no-op, not an error.", + "description": "Kill a bootstrap token now. Already-revoked is a no-op, not an error.\n\n**Auth:** jwt + role:admin\n\n**Params:** path: tokenid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/bootstrap/tokens/1/revoke -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "token": { + "createdat": "2026-01-01T00:00:00.000000Z", + "createdby": "", + "expiresat": "2026-01-01T00:00:00.000000Z", + "lastusedat": null, + "lastusedip": null, + "maxuses": 25, + "remaining": 25, + "revokedat": "2026-01-01T00:00:00.000000Z", + "state": "revoked", + "tokenid": 1, + "tokenprefix": "", + "usecount": 0 + } + }, + "message": "Bootstrap token revoked", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "tokenid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: tokenid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/endpoints/siteagent/package": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Bundle the site agent as a downloadable ZIP.", + "description": "Bundle the site agent as a downloadable ZIP.\n\n**Auth:** permission:endpoints.agentpackage\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/package -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "baseurl": "https://shopdb.test", + "bundlecert": true, + "cadenceseconds": 900, + "collectorkey": "", + "nextserverpin": "" + } + } + } + } + } + }, + "/api/guides": { + "get": { + "tags": [ + "plugin-guides" + ], + "summary": "Published guides, plus the caller's own drafts if they can author.", + "description": "Published guides, plus the caller's own drafts if they can author.\n\n**Auth:** permission:guides.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "canedit": false, + "checklistitems": 0, + "createdat": "2026-01-01T00:00:00.000000Z", + "guideid": 1, + "ispublished": true, + "publishedat": "2026-01-01T00:00:00.000000Z", + "revision": 2, + "slug": "published-procedure", + "status": "published", + "summary": null, + "tags": [], + "title": "Published procedure", + "updatedat": "2026-01-01T00:00:00.000000Z" + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "tags": [ + "plugin-guides" + ], + "summary": "UNDOCUMENTED: guides.create_guide has no docstring.", + "description": "UNDOCUMENTED: guides.create_guide has no docstring.\n\n**Auth:** permission:guides.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "blocks": [], + "createdat": "2026-01-01T00:00:00.000000Z", + "guideid": 1, + "publishedat": null, + "revision": 1, + "slug": "replacing-a-marker-controler", + "status": "draft", + "summary": null, + "tags": [], + "title": "Replacing a marker controler", + "updatedat": "2026-01-01T00:00:00.000000Z" + }, + "message": "Guide created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "blocks": [], + "title": "Replacing a marker controler" + } + } + } + } + } + }, + "/api/guides/{guideid}": { + "delete": { + "tags": [ + "plugin-guides" + ], + "summary": "UNDOCUMENTED: guides.delete_guide has no docstring.", + "description": "UNDOCUMENTED: guides.delete_guide has no docstring.\n\n**Auth:** permission:guides.delete\n\n**Params:** path: guideid (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/guides/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "guideid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-guides" + ], + "summary": "UNDOCUMENTED: guides.update_guide has no docstring.", + "description": "UNDOCUMENTED: guides.update_guide has no docstring.\n\n**Auth:** permission:guides.view\n\n**Params:** path: guideid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/guides/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "blocks": [], + "createdat": "2026-01-01T00:00:00.000000Z", + "guideid": 1, + "publishedat": null, + "revision": 2, + "saved": true, + "slug": "replacing-a-marker-controler", + "status": "draft", + "summary": null, + "tags": [], + "title": "Replacing a marker controller", + "updatedat": "2026-01-01T00:00:00.000000Z" + }, + "message": "Saved", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "guideid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: guideid (int); body: JSON (fields not yet documented)" + }, + "example": { + "blocks": [], + "title": "Replacing a marker controller" + } + } + } + } + } + }, + "/api/guides/{guideid}/publish": { + "post": { + "tags": [ + "plugin-guides" + ], + "summary": "UNDOCUMENTED: guides.publish_guide has no docstring.", + "description": "UNDOCUMENTED: guides.publish_guide has no docstring.\n\n**Auth:** permission:guides.publish\n\n**Params:** path: guideid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/1/publish -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "guideid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: guideid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/guides/{guideid}/restore/{revisionid}": { + "post": { + "tags": [ + "plugin-guides" + ], + "summary": "UNDOCUMENTED: guides.restore_revision has no docstring.", + "description": "UNDOCUMENTED: guides.restore_revision has no docstring.\n\n**Auth:** permission:guides.view\n\n**Params:** path: guideid (int), revisionid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/1/restore/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "guideid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "revisionid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: guideid (int), revisionid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/guides/{guideid}/revisions": { + "get": { + "tags": [ + "plugin-guides" + ], + "summary": "UNDOCUMENTED: guides.list_revisions has no docstring.", + "description": "UNDOCUMENTED: guides.list_revisions has no docstring.\n\n**Auth:** permission:guides.view\n\n**Params:** path: guideid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/1/revisions -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "guideid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/guides/{guideid}/unpublish": { + "post": { + "tags": [ + "plugin-guides" + ], + "summary": "UNDOCUMENTED: guides.unpublish_guide has no docstring.", + "description": "UNDOCUMENTED: guides.unpublish_guide has no docstring.\n\n**Auth:** permission:guides.publish\n\n**Params:** path: guideid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/1/unpublish -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "guideid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: guideid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/guides/{guideid}/working": { + "get": { + "tags": [ + "plugin-guides" + ], + "summary": "The working copy, for the editor.", + "description": "The working copy, for the editor.\n\n**Auth:** permission:guides.view\n\n**Params:** path: guideid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/1/working -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "guideid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/guides/{slug}": { + "get": { + "tags": [ + "plugin-guides" + ], + "summary": "What a reader gets: the published revision.", + "description": "What a reader gets: the published revision.\n\n**Auth:** permission:guides.view\n\n**Params:** path: slug (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/x -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/guides/images": { + "post": { + "tags": [ + "plugin-guides" + ], + "summary": "Take a pasted or dropped image.", + "description": "Take a pasted or dropped image.\n\n**Auth:** permission:guides.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/images -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "bytesize": 40, + "createdat": "2026-01-01T00:00:00.000000Z", + "filename": "screenshot.png", + "guideid": null, + "guideimageid": 1, + "mimetype": "image/png", + "url": "/api/guides/images/1" + }, + "message": "Image uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/guides/images/{imageid}": { + "get": { + "tags": [ + "plugin-guides" + ], + "summary": "The image itself, under the same rule as the guide that holds it.", + "description": "The image itself, under the same rule as the guide that holds it.\n\n**Auth:** permission:guides.view\n\n**Params:** path: imageid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/images/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "imageid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/guides/revisions/{revisionid}": { + "get": { + "tags": [ + "plugin-guides" + ], + "summary": "One revision by id.", + "description": "One revision by id.\n\n**Auth:** permission:guides.view\n\n**Params:** path: revisionid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/revisions/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "blocks": [ + { + "id": "b1", + "text": "secret draft wording", + "type": "paragraph" + } + ], + "createdat": "2026-01-01T00:00:00.000000Z", + "createdby": "", + "guideid": 1, + "guiderevisionid": 1, + "note": null, + "revision": 1, + "shorthash": "", + "summary": null, + "tags": [], + "title": "Published procedure" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "revisionid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/guides/tags": { + "get": { + "tags": [ + "plugin-guides" + ], + "summary": "Every tag in use, with a count, for the filter chips.", + "description": "Every tag in use, with a count, for the filter chips.\n\n**Auth:** permission:guides.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/tags -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/knowledgebase": { + "get": { + "tags": [ + "plugin-knowledgebase" + ], + "summary": "List all knowledge base articles.", + "description": "List all knowledge base articles.\n\n**Auth:** permission:kb.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/knowledgebase -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "appid": 1, + "application": { + "appid": 1, + "appname": "Spotfire" + }, + "clicks": 0, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "keywords": "install", + "lastupdated": "2026-01-01T00:00:00.000000Z", + "linkid": 3, + "linkurl": "https://kb.example/c", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "shortdescription": "Spotfire install guide" + }, + { + "appid": 1, + "application": { + "appid": 1, + "appname": "Spotfire" + }, + "clicks": 0, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "keywords": "export", + "lastupdated": "2026-01-01T00:00:00.000000Z", + "linkid": 2, + "linkurl": "https://kb.example/b", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "shortdescription": "Export a visualization" + }, + { + "appid": 1, + "application": { + "appid": 1, + "appname": "Spotfire" + }, + "clicks": 0, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "keywords": "login", + "lastupdated": "2026-01-01T00:00:00.000000Z", + "linkid": 1, + "linkurl": "https://kb.example/a", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "shortdescription": "Dashboard login steps" + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 3, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "appid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "linkurl", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "order", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "desc" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "shortdescription", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "clicks" + } + } + ] + }, + "post": { + "tags": [ + "plugin-knowledgebase" + ], + "summary": "Create a new knowledge base article.", + "description": "Create a new knowledge base article.\n\n**Auth:** permission:kb.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/knowledgebase -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "appid": null, + "clicks": 0, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "keywords": "printer reset", + "lastupdated": "2026-01-01T00:00:00.000000Z", + "linkid": 1, + "linkurl": "https://kb.example/printer-reset", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "shortdescription": "How to reset a printer" + }, + "message": "Article created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "keywords": "printer reset", + "linkurl": "https://kb.example/printer-reset", + "shortdescription": "How to reset a printer" + } + } + } + } + } + }, + "/api/knowledgebase/{link_id}": { + "delete": { + "tags": [ + "plugin-knowledgebase" + ], + "summary": "Delete (deactivate) a knowledge base article.", + "description": "Delete (deactivate) a knowledge base article.\n\n**Auth:** permission:kb.delete\n\n**Params:** path: link_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/knowledgebase/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "link_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-knowledgebase" + ], + "summary": "Get a single knowledge base article.", + "description": "Get a single knowledge base article.\n\n**Auth:** permission:kb.view\n\n**Params:** path: link_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/knowledgebase/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "appid": null, + "application": null, + "clicks": 0, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "keywords": "printer reset", + "lastupdated": "2026-01-01T00:00:00.000000Z", + "linkid": 1, + "linkurl": "https://kb.example/printer-reset", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "shortdescription": "How to reset a printer" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "link_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-knowledgebase" + ], + "summary": "Update a knowledge base article.", + "description": "Update a knowledge base article.\n\n**Auth:** permission:kb.edit\n\n**Params:** path: link_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/knowledgebase/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "link_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: link_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/knowledgebase/{link_id}/click": { + "post": { + "tags": [ + "plugin-knowledgebase" + ], + "summary": "Increment the click counter for an article the caller is allowed to read.", + "description": "Increment the click counter for an article the caller is allowed to read.\n\n**Auth:** permission:kb.view\n\n**Params:** path: link_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/knowledgebase/1/click -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "link_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: link_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/knowledgebase/stats": { + "get": { + "tags": [ + "plugin-knowledgebase" + ], + "summary": "Get knowledge base statistics.", + "description": "Get knowledge base statistics.\n\n**Auth:** permission:kb.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/knowledgebase/stats -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "totalarticles": 0, + "totalclicks": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/machines": { + "get": { + "tags": [ + "plugin-machines" + ], + "summary": "List all machines with filtering and pagination.", + "description": "List all machines with filtering and pagination.\n\n**Auth:** permission:machines.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/machines -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "assetid": 1, + "assetnumber": "2007", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "machine", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "dualpathpartner": { + "assetid": 2, + "assetnumber": "2008", + "machineid": 2 + }, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "machine": { + "assetid": 1, + "controllermodelid": null, + "controllervendorid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "islocationonly": false, + "lastmaintenancedate": null, + "machineid": 1, + "machinetypeid": null, + "maintenanceintervaldays": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "nextmaintenancedate": null, + "requiresmanualconfig": false, + "vendorid": null + }, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": 100, + "mapy": 100, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": 100, + "ownmapy": 100, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "assetnumber", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "businessunit_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "businessunitid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "dir", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "asc" + } + }, + { + "name": "location_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "locationid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "assetnumber" + } + }, + { + "name": "type_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "typeid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "vendor_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "vendorid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "plugin-machines" + ], + "summary": "Create new machine (creates both Asset and Machine records).", + "description": "Create new machine (creates both Asset and Machine records).\n\n**Auth:** permission:machines.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/machines -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "assetnumber": "MAINT-DATE-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "machine", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "machine": { + "assetid": 1, + "controllermodelid": null, + "controllervendorid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "islocationonly": false, + "lastmaintenancedate": "2026-01-01", + "machineid": 1, + "machinetypeid": null, + "maintenanceintervaldays": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "nextmaintenancedate": "2026-01-01", + "requiresmanualconfig": false, + "vendorid": null + }, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Maintenance date probe", + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "message": "Machine created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "assetnumber": "MAINT-DATE-1", + "lastmaintenancedate": "2026-01-01", + "name": "Maintenance date probe", + "nextmaintenancedate": "2026-01-01" + } + } + } + } + } + }, + "/api/machines/{machine_id}": { + "delete": { + "tags": [ + "plugin-machines" + ], + "summary": "Delete (soft delete) machine.", + "description": "Delete (soft delete) machine.\n\n**Auth:** permission:machines.delete\n\n**Params:** path: machine_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/machines/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "machine_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-machines" + ], + "summary": "Get a single machine item with full details.", + "description": "Get a single machine item with full details.\n\n**Auth:** permission:machines.view\n\n**Params:** path: machine_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/machines/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "assetnumber": "2007", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "machine", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "dualpathpartner": { + "assetid": 2, + "assetnumber": "2008", + "machineid": 2 + }, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "machine": { + "assetid": 1, + "controllermodelid": null, + "controllervendorid": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "islocationonly": false, + "lastmaintenancedate": null, + "machineid": 1, + "machinetypeid": null, + "maintenanceintervaldays": null, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "nextmaintenancedate": null, + "requiresmanualconfig": false, + "vendorid": null + }, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": 100, + "mapy": 100, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": 100, + "ownmapy": 100, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "machine_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-machines" + ], + "summary": "Update machine (both Asset and Machine records).", + "description": "Update machine (both Asset and Machine records).\n\n**Auth:** permission:machines.edit\n\n**Params:** path: machine_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/machines/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "machine_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: machine_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/machines/by-asset/{asset_id}": { + "get": { + "tags": [ + "plugin-machines" + ], + "summary": "Get machine data by asset ID.", + "description": "Get machine data by asset ID.\n\n**Auth:** permission:machines.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/machines/by-asset/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/machines/dashboard/outofservice": { + "get": { + "tags": [ + "plugin-machines" + ], + "summary": "Machines whose status says they are not running.", + "description": "Machines whose status says they are not running.\n\n**Auth:** permission:machines.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/machines/dashboard/outofservice -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/machines/dashboard/summary": { + "get": { + "tags": [ + "plugin-machines" + ], + "summary": "Get machine dashboard summary data.", + "description": "Get machine dashboard summary data.\n\n**Auth:** permission:machines.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/machines/dashboard/summary -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "bystatus": [ + { + "count": 1, + "status": "In Use" + } + ], + "bytype": [], + "total": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } } } @@ -11687,10 +26420,9 @@ "tags": [ "plugin-machines" ], - "summary": "List machine types (active by default) with pagination and name search", - "description": "List machine types (active by default) with pagination and name search\n\n**Auth:** jwt-optional\n\n**Params:** query: page, per_page, active (pass 'false' to include inactive), search (ilike on machinetype)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/machines/types?search=cnc&page=1&per_page=25'\n```", + "summary": "List all machine types.", + "description": "List all machine types.\n\n**Auth:** permission:machines.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/machines/types -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -11700,7 +26432,25 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -11711,15 +26461,65 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } - } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] }, "post": { "tags": [ "plugin-machines" ], - "summary": "Create a machine type; reactivates a soft-deleted type of the same name instead of 409ing", - "description": "Create a machine type; reactivates a soft-deleted type of the same name instead of 409ing\n\n**Auth:** jwt + permission:machines.create\n\n**Params:** body: machinetype (required), description, icon, color\n\n**Example:**\n```\ncurl -X POST 'http://localhost:5001/api/machines/types' -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"machinetype\":\"Lathe\",\"description\":\"Turning machines\",\"icon\":\"mdi-rotate-3d\",\"color\":\"#1976d2\"}'\n```", + "summary": "Create a new machine type.", + "description": "Create a new machine type.\n\n**Auth:** permission:machines.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/machines/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -11730,7 +26530,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -11756,7 +26558,7 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: machinetype (required), description, icon, color" + "description": "body: JSON (fields not yet documented)" } } } @@ -11764,56 +26566,12 @@ } }, "/api/machines/types/{type_id}": { - "get": { + "delete": { "tags": [ "plugin-machines" ], - "summary": "Get a single machine type by ID", - "description": "Get a single machine type by ID\n\n**Auth:** jwt-optional\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/machines/types/3'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "plugin-machines" - ], - "summary": "Update a machine type (rename guarded by 409 on duplicate name)", - "description": "Update a machine type (rename guarded by 409 on duplicate name)\n\n**Auth:** jwt + permission:machines.edit\n\n**Params:** path: type_id; body: any of machinetype, description, icon, color, isactive\n\n**Example:**\n```\ncurl -X PUT 'http://localhost:5001/api/machines/types/3' -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"description\":\"5-axis mills\",\"isactive\":true}'\n```", + "summary": "Delete a machine type. Refused if any asset still uses it.", + "description": "Delete a machine type. Refused if any asset still uses it.\n\n**Auth:** permission:machines.delete\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/machines/types/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -11824,7 +26582,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -11852,7 +26612,109 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-machines" + ], + "summary": "Get a single machine type.", + "description": "Get a single machine type.\n\n**Auth:** permission:machines.view\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/machines/types/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-machines" + ], + "summary": "Update a machine type.", + "description": "Update a machine type.\n\n**Auth:** permission:machines.edit\n\n**Params:** path: type_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/machines/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" } } ], @@ -11863,18 +26725,20 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "path: type_id; body: any of machinetype, description, icon, color, isactive" + "description": "path: type_id (int); body: JSON (fields not yet documented)" } } } } - }, - "delete": { + } + }, + "/api/network": { + "get": { "tags": [ - "plugin-machines" + "plugin-network" ], - "summary": "Hard-delete a machine type; refused with 409 if any machine still references it", - "description": "Hard-delete a machine type; refused with 409 if any machine still references it\n\n**Auth:** jwt + permission:machines.delete\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl -X DELETE 'http://localhost:5001/api/machines/types/3' -H \"Authorization: Bearer $TOKEN\"\n```", + "summary": "List all network devices with filtering and pagination.", + "description": "List all network devices with filtering and pagination.\n\n**Auth:** permission:network.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/network -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -11885,7 +26749,77 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "assetid": 1, + "assetnumber": "NET-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "network_device", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "ipaddress": "", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "networkdevice": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "firmwareversion": null, + "hostname": "", + "isactive": true, + "ismanaged": false, + "ispoe": false, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdeviceid": 1, + "networkdevicetypeid": 1, + "networkdevicetypename": "Switch", + "portcount": null, + "rackunit": null, + "vendorid": 1, + "vendorname": "Cisco" + }, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -11902,61 +26836,165 @@ }, "403": { "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." } }, "parameters": [ { - "name": "type_id", - "in": "path", - "required": true, + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "assetnumber", + "in": "query", + "required": false, "schema": { "type": "string" } - } - ] - } - }, - "/api/machines": { - "get": { - "tags": [ - "plugin-machines" - ], - "summary": "List machines (Asset+Machine join) with filters, sorting, pagination; collapses Dualpath dual-bay pairs to one row...", - "description": "List machines (Asset+Machine join) with filters, sorting, pagination; collapses Dualpath dual-bay pairs to one row (annotated with dualpathpartner) when site setting enabled\n\n**Auth:** jwt-optional\n\n**Params:** query: page, per_page, active ('false' includes inactive), assetnumber (exact-match for idempotent import), search (assetnumber/name/serialnumber/gaugelabreference/maintenancereference ilike), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (assetnumber|name), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/machines?search=2007&typeid=2&sort=name&dir=desc&page=1&per_page=50'\n```", - "security": [ - {}, + }, { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } + "name": "businessunit_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" } }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } + { + "name": "businessunitid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "dir", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "asc" + } + }, + { + "name": "location_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "locationid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "managed", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "poe", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "hostname" + } + }, + { + "name": "type_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "typeid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "vendor_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "vendorid", + "in": "query", + "required": false, + "schema": { + "type": "integer" } } - } + ] }, "post": { "tags": [ - "plugin-machines" + "plugin-network" ], - "summary": "Create a machine (creates both core Asset row and Machine extension row); audit-logged; honors X-Import-Mode legacy...", - "description": "Create a machine (creates both core Asset row and Machine extension row); audit-logged; honors X-Import-Mode legacy timestamps via apply_import_timestamps\n\n**Auth:** jwt + permission:machines.create\n\n**Params:** body: assetnumber (required, 409 on duplicate); asset fields: name, gaugelabreference, maintenancereference, serialnumber, statusid (default 1), locationid, businessunitid, mapx, mapy, notes; machine fields: machinetypeid, vendorid, modelnumberid, requiresmanualconfig, islocationonly, lastmaintenancedate, nextmaintenancedate, maintenanceintervaldays, controllervendorid, controllermodelid\n\n**Example:**\n```\ncurl -X POST 'http://localhost:5001/api/machines' -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"assetnumber\":\"2007\",\"name\":\"Makino a51\",\"machinetypeid\":2,\"vendorid\":5,\"locationid\":3,\"statusid\":1}'\n```", + "summary": "Create new network device (creates both Asset and NetworkDevice records).", + "description": "Create new network device (creates both Asset and NetworkDevice records).\n\n**Auth:** permission:network.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/network -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -11967,7 +27005,67 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "assetnumber": "APNL-Dock-Door-4", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "network_device", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "ipaddress": "", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Dock Door 4", + "networkdevice": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "firmwareversion": null, + "hostname": "", + "isactive": true, + "ismanaged": false, + "ispoe": false, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdeviceid": 1, + "networkdevicetypeid": 1, + "networkdevicetypename": "Access Panel", + "portcount": null, + "rackunit": null, + "vendorid": null + }, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "message": "Network device created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -11993,22 +27091,27 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: assetnumber (required, 409 on duplicate); asset fields: name, gaugelabreference, maintenancereference, serialnumber, statusid (default 1), locationid, businessunitid, mapx, mapy, notes; machine fields: machinetypeid, vendorid, modelnumberid, requiresmanualconfig, islocationonly, lastmaintenancedate, nextmaintenancedate, maintenanceintervaldays, controllervendorid, controllermodelid" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "assetnumber": "SW-CORE-01", + "hostname": "", + "ipaddress": "", + "name": "Core switch" } } } } } }, - "/api/machines/{machine_id}": { - "get": { + "/api/network/{device_id}": { + "delete": { "tags": [ - "plugin-machines" + "plugin-network" ], - "summary": "Get one machine with full asset details, nested machine dict, and dualpathpartner info", - "description": "Get one machine with full asset details, nested machine dict, and dualpathpartner info\n\n**Auth:** jwt-optional\n\n**Params:** path: machine_id (int)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/machines/42'\n```", + "summary": "Delete (soft delete) network device.", + "description": "Delete (soft delete) network device.\n\n**Auth:** permission:network.delete\n\n**Params:** path: device_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/network/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ - {}, { "bearerAuth": [] } @@ -12018,7 +27121,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -12030,27 +27135,140 @@ } } }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, "404": { "description": "No such record." } }, "parameters": [ { - "name": "machine_id", + "name": "device_id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-network" + ], + "summary": "Get a single network device with full details.", + "description": "Get a single network device with full details.\n\n**Auth:** permission:network.view\n\n**Params:** path: device_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "assetnumber": "AST-networkdevice", + "assettypecolor": null, + "assettypeid": 3, + "assettypename": "network_device", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": "GL-2", + "ipaddress": "", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": "MNT-2", + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "networkdevice": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "firmwareversion": null, + "hostname": "", + "isactive": true, + "ismanaged": false, + "ispoe": false, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdeviceid": 1, + "networkdevicetypeid": null, + "portcount": null, + "rackunit": null, + "vendorid": null + }, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" } } ] }, "put": { "tags": [ - "plugin-machines" + "plugin-network" ], - "summary": "Update machine (asset + machine fields), with per-field change tracking to AuditLog and 409 on assetnumber conflict", - "description": "Update machine (asset + machine fields), with per-field change tracking to AuditLog and 409 on assetnumber conflict\n\n**Auth:** jwt + permission:machines.edit\n\n**Params:** path: machine_id; body: any of assetnumber, name, gaugelabreference, maintenancereference, serialnumber, statusid, locationid, businessunitid, mapx, mapy, notes, isactive, machinetypeid, vendorid, modelnumberid, requiresmanualconfig, islocationonly, lastmaintenancedate, nextmaintenancedate, maintenanceintervaldays, controllervendorid, controllermodelid\n\n**Example:**\n```\ncurl -X PUT 'http://localhost:5001/api/machines/42' -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"locationid\":7,\"nextmaintenancedate\":\"2026-09-01\"}'\n```", + "summary": "Update network device (both Asset and NetworkDevice records).", + "description": "Update network device (both Asset and NetworkDevice records).\n\n**Auth:** permission:network.edit\n\n**Params:** path: device_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/network/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -12061,7 +27279,66 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "assetnumber": "AST-networkdevice", + "assettypecolor": null, + "assettypeid": 3, + "assettypename": "network_device", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": "GL-2", + "ipaddress": "", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": "MNT-2", + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "networkdevice": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "firmwareversion": null, + "hostname": "", + "isactive": true, + "ismanaged": false, + "ispoe": false, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdeviceid": 1, + "networkdevicetypeid": null, + "portcount": null, + "rackunit": null, + "vendorid": null + }, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "serialnumber": "", + "statusid": 1 + }, + "message": "Network device updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -12085,11 +27362,11 @@ }, "parameters": [ { - "name": "machine_id", + "name": "device_id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } ], @@ -12100,18 +27377,24 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "path: machine_id; body: any of assetnumber, name, gaugelabreference, maintenancereference, serialnumber, statusid, locationid, businessunitid, mapx, mapy, notes, isactive, machinetypeid, vendorid, modelnumberid, requiresmanualconfig, islocationonly, lastmaintenancedate, nextmaintenancedate, maintenanceintervaldays, controllervendorid, controllermodelid" + "description": "path: device_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "gaugelabreference": "GL-2", + "maintenancereference": "MNT-2" } } } } - }, - "delete": { + } + }, + "/api/network/by-asset/{asset_id}": { + "get": { "tags": [ - "plugin-machines" + "plugin-network" ], - "summary": "Soft-delete a machine (sets asset.isactive=False, keeps Machine row linked); audit-logged", - "description": "Soft-delete a machine (sets asset.isactive=False, keeps Machine row linked); audit-logged\n\n**Auth:** jwt + permission:machines.delete\n\n**Params:** path: machine_id (int)\n\n**Example:**\n```\ncurl -X DELETE 'http://localhost:5001/api/machines/42' -H \"Authorization: Bearer $TOKEN\"\n```", + "summary": "Get network device data by asset ID.", + "description": "Get network device data by asset ID.\n\n**Auth:** permission:network.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/by-asset/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -12122,7 +27405,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -12144,104 +27429,25 @@ "description": "No such record." } }, - "parameters": [ - { - "name": "machine_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/machines/by-asset/{asset_id}": { - "get": { - "tags": [ - "plugin-machines" - ], - "summary": "Get machine data looked up by core asset ID instead of machine ID", - "description": "Get machine data looked up by core asset ID instead of machine ID\n\n**Auth:** jwt-optional\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/machines/by-asset/1001'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, "parameters": [ { "name": "asset_id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer" } } ] } }, - "/api/machines/dashboard/summary": { + "/api/network/by-hostname/{hostname}": { "get": { "tags": [ - "plugin-machines" + "plugin-network" ], - "summary": "Dashboard summary: total active machine count plus counts grouped by machine type and by asset status (total and...", - "description": "Dashboard summary: total active machine count plus counts grouped by machine type and by asset status (total and by-type exclude Dualpath secondary bays when collapse setting enabled; by-status does not)\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/machines/dashboard/summary'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/machines/dashboard/outofservice": { - "get": { - "tags": [ - "plugin-machines" - ], - "summary": "Dashboard card: machines whose status says they are not running", - "description": "Dashboard card: machines whose status says they are not running\n\n**Auth:** permission:machines.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/machines/dashboard/outofservice\n```", + "summary": "Get network device by hostname.", + "description": "Get network device by hostname.\n\n**Auth:** permission:network.view\n\n**Params:** path: hostname (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/by-hostname/x -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -12252,83 +27458,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/geenforce/manifest": { - "get": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Serve the current PUBLISHED manifest JSON snapshot for a scope (never the live draft) to the GE-Enforce client, with...", - "description": "Serve the current PUBLISHED manifest JSON snapshot for a scope (never the live draft) to the GE-Enforce client, with ETag/304 support and X-Manifest-Version header.\n\n**Auth:** api-key (managed service token with geenforce.fetch scope via X-API-Key or Bearer PAT) OR source IP in geenforce_allowed_cidrs setting; resource-bound tokens restricted to their listed scopes (403 otherwise)\n\n**Params:** query: pctype (required, =scopename), phase (default 'runtime'); header: If-None-Match for 304\n\n**Example:**\n```\ncurl -H \"X-API-Key: $TOKEN\" 'http://localhost:5001/api/geenforce/manifest?pctype=cmm&phase=runtime'\n```", - "security": [ - { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/geenforce/payload/{sha256}": { - "get": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Download a payload blob by content hash (blob store first, then inline DB payload) so share-less PCs can pull...", - "description": "Download a payload blob by content hash (blob store first, then inline DB payload) so share-less PCs can pull installers over HTTPS; per-IP rate limited (120/60s default) and size-capped (512MB default, 413 above).\n\n**Auth:** api-key (geenforce.fetch service token) OR IP allowlist; resource-bound tokens get 404 for blobs not referenced by their scopes\n\n**Params:** path: sha256 (64 lowercase hex chars, 400 otherwise); header: If-None-Match (ETag = the hash)\n\n**Example:**\n```\ncurl -H \"X-API-Key: $TOKEN\" -o installer.exe 'http://localhost:5001/api/geenforce/payload/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'\n```", - "security": [ - { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -12352,7 +27484,7 @@ }, "parameters": [ { - "name": "sha256", + "name": "hostname", "in": "path", "required": true, "schema": { @@ -12362,16 +27494,16 @@ ] } }, - "/api/geenforce/report": { - "post": { + "/api/network/dashboard/summary": { + "get": { "tags": [ - "plugin-geenforce" + "plugin-network" ], - "summary": "Record one PC's enforcement cycle: applied manifest version plus per-entry self-heal outcomes...", - "description": "Record one PC's enforcement cycle: applied manifest version plus per-entry self-heal outcomes (installed/skipped/failed); returns reportid + status.\n\n**Auth:** api-key (managed service token with geenforce.report scope) OR IP allowlist\n\n**Params:** JSON body: hostname (required); remainder parsed by service.record_enforcement_report (scopename, phase, appliedversion, enforcerversion, status, per-entry results, counts); 400 on ValueError\n\n**Example:**\n```\ncurl -X POST -H \"X-API-Key: $TOKEN\" -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"scopename\":\"cmm\",\"appliedversion\":4,\"results\":[{\"entryname\":\"7zip\",\"action\":\"installed\"}]}' http://localhost:5001/api/geenforce/report\n```", + "summary": "Get network device dashboard summary data.", + "description": "Get network device dashboard summary data.\n\n**Auth:** permission:network.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/dashboard/summary -H \"Authorization: Bearer $TOK\"\n```", "security": [ { - "apiKeyAuth": [] + "bearerAuth": [] } ], "responses": { @@ -12379,7 +27511,23 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "bytype": [], + "byvendor": [], + "nonpoe": 0, + "poe": 0, + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -12397,28 +27545,16 @@ "403": { "description": "Authenticated, but not permitted." } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "JSON body: hostname (required); remainder parsed by service.record_enforcement_report (scopename, phase, appliedversion, enforcerversion, status, per-entry results, counts); 400 on ValueError" - } - } - } } } }, - "/api/geenforce/scopes": { + "/api/network/subnets": { "get": { "tags": [ - "plugin-geenforce" + "plugin-network" ], - "summary": "List all imaging PC-type scopes with entry counts and current published version numbers, ordered by phase then...", - "description": "List all imaging PC-type scopes with entry counts and current published version numbers, ordered by phase then scopename.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/scopes\n```", + "summary": "List all subnets with filtering and pagination.", + "description": "List all subnets with filtering and pagination.\n\n**Auth:** permission:network.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/subnets -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -12429,7 +27565,25 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -12447,113 +27601,82 @@ "403": { "description": "Authenticated, but not permitted." } - } - }, - "post": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Create a new manifest scope; 400 if scopename missing, phase invalid, or scope already exists for that scopename+phase...", - "description": "Create a new manifest scope; 400 if scopename missing, phase invalid, or scope already exists for that scopename+phase; returns 201 with scope summary.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** JSON body: scopename (required), phase (default 'runtime', must be in PHASES), manifestversion (default '1.0'), description, computertypeid, measuringtooltypeid, iscommon (defaults true when scopename=='common')\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"scopename\":\"cmm\",\"phase\":\"runtime\",\"description\":\"CMM bays\"}' http://localhost:5001/api/geenforce/scopes\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "JSON body: scopename (required), phase (default 'runtime', must be in PHASES), manifestversion (default '1.0'), description, computertypeid, measuringtooltypeid, iscommon (defaults true when scopename=='common')" - } - } - } - } - } - }, - "/api/geenforce/scopes/{scopeid}": { - "get": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Get one scope's summary plus its full draft entry list (each entry includes entryid, sortorder, curated appid/appname...", - "description": "Get one scope's summary plus its full draft entry list (each entry includes entryid, sortorder, curated appid/appname link, and inline-payload metadata).\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: scopeid\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/scopes/3\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } }, "parameters": [ { - "name": "scopeid", - "in": "path", - "required": true, + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "locationid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "vlanid", + "in": "query", + "required": false, "schema": { "type": "integer" } } ] }, - "put": { + "post": { "tags": [ - "plugin-geenforce" + "plugin-network" ], - "summary": "Update scope metadata fields (only keys present in the body are changed); scopename and phase are immutable here.", - "description": "Update scope metadata fields (only keys present in the body are changed); scopename and phase are immutable here.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** JSON body (all optional): description, computertypeid, measuringtooltypeid, manifestversion (stringified), iscommon (bool-coerced)\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"description\":\"updated\",\"manifestversion\":\"1.1\"}' http://localhost:5001/api/geenforce/scopes/3\n```", + "summary": "Create a new subnet.", + "description": "Create a new subnet.\n\n**Auth:** permission:network.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/network/subnets -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -12564,7 +27687,40 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "broadcastaddress": null, + "cidr": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "dhcpenabled": true, + "dhcprangeend": null, + "dhcprangestart": null, + "dns1": null, + "dns2": null, + "gatewayip": null, + "isactive": true, + "locationid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "Attached", + "networkaddress": null, + "subnetid": 1, + "subnetmask": null, + "subnettype": null, + "vlanid": 1, + "vlanname": "In use", + "vlannumber": 77 + }, + "message": "Subnet created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -12581,21 +27737,8 @@ }, "403": { "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." } }, - "parameters": [ - { - "name": "scopeid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], "requestBody": { "required": true, "content": { @@ -12603,18 +27746,25 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "JSON body (all optional): description, computertypeid, measuringtooltypeid, manifestversion (stringified), iscommon (bool-coerced)" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "cidr": "", + "name": "Attached", + "vlanid": 1 } } } } - }, + } + }, + "/api/network/subnets/{subnet_id}": { "delete": { "tags": [ - "plugin-geenforce" + "plugin-network" ], - "summary": "Delete a scope (and via cascade its entries); returns {deleted: scopeid}.", - "description": "Delete a scope (and via cascade its entries); returns {deleted: scopeid}.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: scopeid\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/scopes/3\n```", + "summary": "Delete (soft delete) a subnet.", + "description": "Delete (soft delete) a subnet.\n\n**Auth:** permission:network.delete\n\n**Params:** path: subnet_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/network/subnets/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -12625,7 +27775,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -12649,7 +27801,7 @@ }, "parameters": [ { - "name": "scopeid", + "name": "subnet_id", "in": "path", "required": true, "schema": { @@ -12657,15 +27809,13 @@ } } ] - } - }, - "/api/geenforce/scopes/{scopeid}/preview": { + }, "get": { "tags": [ - "plugin-geenforce" + "plugin-network" ], - "summary": "Render the DRAFT manifest JSON that a publish would freeze, for admin review before shipping.", - "description": "Render the DRAFT manifest JSON that a publish would freeze, for admin review before shipping.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: scopeid\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/scopes/3/preview\n```", + "summary": "Get a single subnet plus the network devices whose primary IP falls in its CIDR range.", + "description": "Get a single subnet plus the network devices whose primary IP falls in its CIDR range.\n\n**Auth:** permission:network.view\n\n**Params:** path: subnet_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/subnets/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -12676,7 +27826,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -12700,7 +27852,7 @@ }, "parameters": [ { - "name": "scopeid", + "name": "subnet_id", "in": "path", "required": true, "schema": { @@ -12708,116 +27860,13 @@ } } ] - } - }, - "/api/geenforce/applications": { - "get": { - "tags": [ - "plugin-geenforce" - ], - "summary": "List the core active Applications catalog (appid + appname) for the curated entry-to-app link picker in the entry...", - "description": "List the core active Applications catalog (appid + appname) for the curated entry-to-app link picker in the entry editor.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/applications\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/geenforce/scopes/{scopeid}/entries": { - "post": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Create a manifest entry in a scope at the next sortorder; validates Name (required) and Type (must be in ENTRY_TYPES)...", - "description": "Create a manifest entry in a scope at the next sortorder; validates Name (required) and Type (must be in ENTRY_TYPES); 400 on duplicate Name in scope; returns 201 with entry payload.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: scopeid; JSON body: Name (required), Type (required, one of ENTRY_TYPES), optional appid (curated app link, unknown/non-numeric ids ignored), plus manifest fields consumed by build_entry (PCTypes, TargetHostnames, DetectionValue, etc.)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"Name\":\"7-Zip\",\"Type\":\"App\",\"appid\":12}' http://localhost:5001/api/geenforce/scopes/3/entries\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "scopeid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: scopeid; JSON body: Name (required), Type (required, one of ENTRY_TYPES), optional appid (curated app link, unknown/non-numeric ids ignored), plus manifest fields consumed by build_entry (PCTypes, TargetHostnames, DetectionValue, etc.)" - } - } - } - } - } - }, - "/api/geenforce/entries/{entryid}": { + }, "put": { "tags": [ - "plugin-geenforce" + "plugin-network" ], - "summary": "Replace an entry's fields from the payload (re-populates via populate_entry, re-creating the one-to-one InUseCheck)...", - "description": "Replace an entry's fields from the payload (re-populates via populate_entry, re-creating the one-to-one InUseCheck); same Name/Type validation and duplicate-Name 400 as create.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: entryid; JSON body: Name (required), Type (required), optional appid, plus manifest fields\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"Name\":\"7-Zip\",\"Type\":\"App\",\"DetectionValue\":\"24.08\"}' http://localhost:5001/api/geenforce/entries/17\n```", + "summary": "Update a subnet.", + "description": "Update a subnet.\n\n**Auth:** permission:network.edit\n\n**Params:** path: subnet_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/network/subnets/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -12828,7 +27877,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -12852,7 +27903,7 @@ }, "parameters": [ { - "name": "entryid", + "name": "subnet_id", "in": "path", "required": true, "schema": { @@ -12867,18 +27918,193 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "path: entryid; JSON body: Name (required), Type (required), optional appid, plus manifest fields" + "description": "path: subnet_id (int); body: JSON (fields not yet documented)" } } } } + } + }, + "/api/network/types": { + "get": { + "tags": [ + "plugin-network" + ], + "summary": "List all network device types.", + "description": "List all network device types.\n\n**Auth:** permission:network.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/types -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] }, + "post": { + "tags": [ + "plugin-network" + ], + "summary": "Create a new network device type.", + "description": "Create a new network device type.\n\n**Auth:** permission:network.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/network/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "color": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "icon": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdevicetype": "Door Controller", + "networkdevicetypeid": 1, + "prefix": "DOOR" + }, + "message": "Network device type created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "networkdevicetype": "Door Controller", + "prefix": "DOOR" + } + } + } + } + } + }, + "/api/network/types/{type_id}": { "delete": { "tags": [ - "plugin-geenforce" + "plugin-network" ], - "summary": "Delete a manifest entry; returns {deleted: entryid}.", - "description": "Delete a manifest entry; returns {deleted: entryid}.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: entryid\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/entries/17\n```", + "summary": "Delete a network device type. Refused if any device still uses it.", + "description": "Delete a network device type. Refused if any device still uses it.\n\n**Auth:** permission:network.delete\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/network/types/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -12889,7 +28115,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -12913,7 +28141,7 @@ }, "parameters": [ { - "name": "entryid", + "name": "type_id", "in": "path", "required": true, "schema": { @@ -12921,241 +28149,13 @@ } } ] - } - }, - "/api/geenforce/scopes/{scopeid}/entries/reorder": { - "put": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Set entry ordering from an entryid list; 400 unless the list is exactly the set of this scope's entry ids.", - "description": "Set entry ordering from an entryid list; 400 unless the list is exactly the set of this scope's entry ids.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: scopeid; JSON body: order = [entryid, ...] (must match the scope's entry ids exactly)\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"order\":[17,15,16]}' http://localhost:5001/api/geenforce/scopes/3/entries/reorder\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "scopeid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: scopeid; JSON body: order = [entryid, ...] (must match the scope's entry ids exactly)" - } - } - } - } - } - }, - "/api/geenforce/scopes/{scopeid}/simulate": { - "get": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Simulate which draft entries would apply to a given machine profile and why the rest are filtered out...", - "description": "Simulate which draft entries would apply to a given machine profile and why the rest are filtered out (PCTypes/TargetHostnames/TargetMachineNumbers/_CmmVersion), using the engine-mirror filters.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: scopeid; query (all optional): pctype (defaults to scopename), subtype, hostname, machinenumber, cmmversion; phase comes from the scope\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" 'http://localhost:5001/api/geenforce/scopes/3/simulate?hostname=WKSTN0042&cmmversion=2023.2'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "scopeid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/geenforce/scopes/{scopeid}/compliance": { - "get": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Fleet install coverage per app-linked entry (installed/version-match counts from the computers plugin's...", - "description": "Fleet install coverage per app-linked entry (installed/version-match counts from the computers plugin's ComputerInstalledApp; null counts with computersplugin:false when that plugin is absent).\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: scopeid\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/scopes/3/compliance\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "scopeid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/geenforce/entries/{entryid}/payload": { - "post": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Upload an inline payload file (max 1 MB, rejects empty) for an entry and point the entry at it (stores sha256...", - "description": "Upload an inline payload file (max 1 MB, rejects empty) for an entry and point the entry at it (stores sha256, filename, mimetype); returns 201 with entry payload.\n\n**Auth:** jwt + permission:geenforce.publish\n\n**Params:** path: entryid; multipart/form-data: file (required)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $JWT\" -F 'file=@fix.ps1' http://localhost:5001/api/geenforce/entries/17/payload\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "entryid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: entryid; multipart/form-data: file (required)" - } - } - } - } }, "get": { "tags": [ - "plugin-geenforce" + "plugin-network" ], - "summary": "Download the stored inline payload bytes for an entry as an attachment (404 if the entry has no payload).", - "description": "Download the stored inline payload bytes for an entry as an attachment (404 if the entry has no payload).\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: entryid\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" -o fix.ps1 http://localhost:5001/api/geenforce/entries/17/payload\n```", + "summary": "Get a single network device type.", + "description": "Get a single network device type.\n\n**Auth:** permission:network.view\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/types/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -13166,7 +28166,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -13190,7 +28192,7 @@ }, "parameters": [ { - "name": "entryid", + "name": "type_id", "in": "path", "required": true, "schema": { @@ -13198,350 +28200,13 @@ } } ] - } - }, - "/api/geenforce/scopes/{scopeid}/publish": { - "post": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Freeze the scope's draft into a new published version (service.publish_scope), recording the publishing user from the...", - "description": "Freeze the scope's draft into a new published version (service.publish_scope), recording the publishing user from the JWT identity and optional notes; returns 201 with the new versionnumber.\n\n**Auth:** jwt + permission:geenforce.publish\n\n**Params:** path: scopeid; JSON body (optional): notes\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"notes\":\"add 7zip 24.08\"}' http://localhost:5001/api/geenforce/scopes/3/publish\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "scopeid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: scopeid; JSON body (optional): notes" - } - } - } - } - } - }, - "/api/geenforce/scopes/{scopeid}/versions": { - "get": { - "tags": [ - "plugin-geenforce" - ], - "summary": "List published versions for a scope, newest first (versionnumber, iscurrent, publishedat, publishedby, notes).", - "description": "List published versions for a scope, newest first (versionnumber, iscurrent, publishedat, publishedby, notes).\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: scopeid\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/scopes/3/versions\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "scopeid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/geenforce/scopes/{scopeid}/versions/{versionnumber}": { - "get": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Fetch one published version's frozen manifest JSON (parsed and returned in the success envelope).", - "description": "Fetch one published version's frozen manifest JSON (parsed and returned in the success envelope).\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: scopeid, versionnumber\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/scopes/3/versions/4\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "scopeid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - }, - { - "name": "versionnumber", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/geenforce/scopes/{scopeid}/rollback": { - "post": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Roll the scope's current published pointer back to an earlier versionnumber (service.rollback_scope); 400 with the...", - "description": "Roll the scope's current published pointer back to an earlier versionnumber (service.rollback_scope); 400 with the error message if the version is invalid.\n\n**Auth:** jwt + permission:geenforce.publish\n\n**Params:** path: scopeid; JSON body: versionnumber (required, int)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"versionnumber\":3}' http://localhost:5001/api/geenforce/scopes/3/rollback\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "scopeid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: scopeid; JSON body: versionnumber (required, int)" - } - } - } - } - } - }, - "/api/geenforce/scopes/{scopeid}/export-share": { - "post": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Write the scope's current published JSON to the configured share root (geenforce_share_root setting), backing up the...", - "description": "Write the scope's current published JSON to the configured share root (geenforce_share_root setting), backing up the old file to _meta/history; 400 if the share root is unconfigured or the export fails.\n\n**Auth:** jwt + permission:geenforce.publish\n\n**Params:** path: scopeid; no body\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/scopes/3/export-share\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "scopeid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: scopeid; no body" - } - } - } - } - } - }, - "/api/geenforce/config": { - "get": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Read plugin config: the on-share export root (geenforce_share_root) and the client IP allowlist CIDRs...", - "description": "Read plugin config: the on-share export root (geenforce_share_root) and the client IP allowlist CIDRs (geenforce_allowed_cidrs).\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/config\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } }, "put": { "tags": [ - "plugin-geenforce" + "plugin-network" ], - "summary": "Update plugin config settings; allowedcidrs is validated/normalized (comma/newline-separated CIDRs or bare IPs, 400...", - "description": "Update plugin config settings; allowedcidrs is validated/normalized (comma/newline-separated CIDRs or bare IPs, 400 listing any bad entries) and only keys present in the body are written.\n\n**Auth:** jwt + permission:geenforce.publish\n\n**Params:** JSON body (both optional): shareroot (string path), allowedcidrs (CSV/newline CIDR list)\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"allowedcidrs\":\"10.20.30.0/24, 192.168.5.7\"}' http://localhost:5001/api/geenforce/config\n```", + "summary": "Update a network device type.", + "description": "Update a network device type.\n\n**Auth:** permission:network.edit\n\n**Params:** path: type_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/network/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -13552,7 +28217,221 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "color": null, + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "icon": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "networkdevicetype": "Door Controller", + "networkdevicetypeid": 1, + "prefix": "DR" + }, + "message": "Network device type updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: type_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "prefix": "DR" + } + } + } + } + } + }, + "/api/network/vlans": { + "get": { + "tags": [ + "plugin-network" + ], + "summary": "List all VLANs with filtering and pagination.", + "description": "List all VLANs with filtering and pagination.\n\n**Auth:** permission:network.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/vlans -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "tags": [ + "plugin-network" + ], + "summary": "Create a new VLAN.", + "description": "Create a new VLAN.\n\n**Auth:** permission:network.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/network/vlans -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createddate": "2026-01-01T00:00:00.000000Z", + "description": null, + "isactive": true, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": "VLAN 4094", + "subnetcount": 0, + "vlanid": 1, + "vlannumber": 4094, + "vlantype": null + }, + "message": "VLAN created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -13578,20 +28457,24 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "JSON body (both optional): shareroot (string path), allowedcidrs (CSV/newline CIDR list)" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "name": "VLAN 4094", + "vlannumber": 4094 } } } } } }, - "/api/geenforce/reports": { - "get": { + "/api/network/vlans/{vlan_id}": { + "delete": { "tags": [ - "plugin-geenforce" + "plugin-network" ], - "summary": "Latest enforcement report per PC (iscurrent rows) for the fleet compliance view: applied vs latest published version...", - "description": "Latest enforcement report per PC (iscurrent rows) for the fleet compliance view: applied vs latest published version (receivedlatest flag), install/skip/fail/filtered counts, status, check-in times.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** query (optional): hostname (ILIKE match), scopename (exact)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" 'http://localhost:5001/api/geenforce/reports?scopename=cmm'\n```", + "summary": "Delete (soft delete) a VLAN.", + "description": "Delete (soft delete) a VLAN.\n\n**Auth:** permission:network.delete\n\n**Params:** path: vlan_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/network/vlans/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -13602,45 +28485,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/geenforce/reports/{reportid}": { - "get": { - "tags": [ - "plugin-geenforce" - ], - "summary": "One enforcement report in detail with per-entry outcomes (entryname, action, selfhealed, exitcode, message) plus...", - "description": "One enforcement report in detail with per-entry outcomes (entryname, action, selfhealed, exitcode, message) plus applied-vs-latest version comparison.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: reportid\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $JWT\" http://localhost:5001/api/geenforce/reports/42\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -13664,7 +28511,7 @@ }, "parameters": [ { - "name": "reportid", + "name": "vlan_id", "in": "path", "required": true, "schema": { @@ -13672,15 +28519,13 @@ } } ] - } - }, - "/api/geenforce/dashboard/failures": { + }, "get": { "tags": [ - "plugin-geenforce" + "plugin-network" ], - "summary": "Dashboard card: entries that FAILED on their PC's most recent enforcement cycle, host and entry named", - "description": "Dashboard card: entries that FAILED on their PC's most recent enforcement cycle, host and entry named\n\n**Auth:** permission:geenforce.manage\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/geenforce/dashboard/failures\n```", + "summary": "Get a single VLAN with its subnets.", + "description": "Get a single VLAN with its subnets.\n\n**Auth:** permission:network.view\n\n**Params:** path: vlan_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/vlans/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -13691,45 +28536,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/geenforce/scopes/{scopeid}/publish-preflight": { - "get": { - "tags": [ - "plugin-geenforce" - ], - "summary": "Would publishing this scope outrun the fleet's enforcer library? Returns the required version, the lowest version...", - "description": "Would publishing this scope outrun the fleet's enforcer library? Returns the required version, the lowest version reporting, the hosts behind it, and whether publish would be refused. The publish itself enforces the same gate; this is how the UI warns first\n\n**Auth:** permission:geenforce.manage\n\n**Params:** scopeid in path\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/geenforce/scopes/3/publish-preflight\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -13753,9 +28562,863 @@ }, "parameters": [ { - "name": "scopeid", + "name": "vlan_id", "in": "path", "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-network" + ], + "summary": "Update a VLAN.", + "description": "Update a VLAN.\n\n**Auth:** permission:network.edit\n\n**Params:** path: vlan_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/network/vlans/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "vlan_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: vlan_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/notifications": { + "get": { + "tags": [ + "plugin-notifications" + ], + "summary": "List all notifications with filtering and pagination.", + "description": "List all notifications with filtering and pagination.\n\n**Auth:** permission:notifications.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "current", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "false" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ticketnumber", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "type_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "typeid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "plugin-notifications" + ], + "summary": "Create a new notification.", + "description": "Create a new notification.\n\n**Auth:** permission:notifications.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/notifications -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "alertfleet": true, + "alertscope": { + "computertype": "CMM PC", + "hostname": [ + "", + "" + ] + }, + "alerttimeout": 0, + "appid": null, + "businessunitid": null, + "employeename": "", + "employeesso": "", + "enddate": null, + "endtime": null, + "isactive": true, + "iscurrent": true, + "isshopfloor": false, + "link": null, + "linkurl": null, + "message": "Line 3 is down", + "notification": "Line 3 is down", + "notificationid": 1, + "notificationtypeid": 1, + "startdate": "2026-01-01T00:00:00.000000+00:00", + "starttime": "2026-01-01T00:00:00.000000+00:00", + "ticketnumber": null, + "title": "Line 3 is down", + "typecolor": "#d93f3f", + "typename": "Fleet Alert" + }, + "message": "Notification created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "alertfleet": true, + "alertscope": { + "computertype": "CMM PC", + "hostname": [ + "", + "" + ] + }, + "notification": "Line 3 is down", + "notificationtypeid": 1 + } + } + } + } + } + }, + "/api/notifications/{notification_id}": { + "delete": { + "tags": [ + "plugin-notifications" + ], + "summary": "Delete (soft delete) a notification.", + "description": "Delete (soft delete) a notification.\n\n**Auth:** permission:notifications.delete\n\n**Params:** path: notification_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/notifications/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "notification_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-notifications" + ], + "summary": "Get a single notification.", + "description": "Get a single notification.\n\n**Auth:** permission:notifications.view\n\n**Params:** path: notification_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "notification_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-notifications" + ], + "summary": "Update a notification.", + "description": "Update a notification.\n\n**Auth:** permission:notifications.edit\n\n**Params:** path: notification_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/notifications/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "alertfleet": false, + "alertscope": null, + "alerttimeout": 0, + "appid": null, + "businessunitid": null, + "employeename": "", + "employeesso": "", + "enddate": null, + "endtime": null, + "isactive": true, + "iscurrent": true, + "isshopfloor": false, + "link": null, + "linkurl": null, + "message": "Line 3 is down", + "notification": "Line 3 is down", + "notificationid": 1, + "notificationtypeid": 1, + "startdate": "2026-01-01T00:00:00.000000+00:00", + "starttime": "2026-01-01T00:00:00.000000+00:00", + "ticketnumber": null, + "title": "Line 3 is down", + "typecolor": "#d93f3f", + "typename": "Fleet Alert" + }, + "message": "Notification updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "notification_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: notification_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "alertfleet": null, + "alerttimeout": null + } + } + } + } + } + }, + "/api/notifications/active": { + "get": { + "tags": [ + "plugin-notifications" + ], + "summary": "Get currently active notifications for display.", + "description": "Get currently active notifications for display.\n\n**Auth:** permission:notifications.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/active -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "notifications": [], + "total": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/notifications/calendar": { + "get": { + "tags": [ + "plugin-notifications" + ], + "summary": "Get notifications in FullCalendar event format.", + "description": "Get notifications in FullCalendar event format.\n\n**Auth:** permission:notifications.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/calendar -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "end", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "start", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/notifications/dashboard/active": { + "get": { + "tags": [ + "plugin-notifications" + ], + "summary": "Notifications currently showing on the floor.", + "description": "Notifications currently showing on the floor.\n\n**Auth:** permission:notifications.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/dashboard/active -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/notifications/dashboard/summary": { + "get": { + "tags": [ + "plugin-notifications" + ], + "summary": "Get notifications dashboard summary.", + "description": "Get notifications dashboard summary.\n\n**Auth:** permission:notifications.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/dashboard/summary -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "active": 0, + "bytype": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/notifications/employee/{sso}": { + "get": { + "tags": [ + "plugin-notifications" + ], + "summary": "Get recognitions for a specific employee by SSO.", + "description": "Get recognitions for a specific employee by SSO.\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** path: sso (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/employee/x\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "sso", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/notifications/shopfloor": { + "get": { + "tags": [ + "plugin-notifications" + ], + "summary": "Get notifications for shopfloor TV dashboard.", + "description": "Get notifications for shopfloor TV dashboard.\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/shopfloor\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "configversion": "2b7c2d2f543f", + "current": [ + { + "boardcategory": "Current Events", + "boardorder": 50, + "displaystyle": "grid", + "employeename": "", + "employeepicture": "", + "employeesso": "", + "endtime": null, + "isactive": true, + "isshopfloor": true, + "link": null, + "notification": "press down", + "notificationid": 2, + "resolved": false, + "starttime": null, + "ticketnumber": null, + "typecolor": "#17a2b8", + "typename": "Incident" + }, + { + "boardcategory": "Current Events", + "boardorder": 30, + "displaystyle": "grid", + "employeename": "", + "employeepicture": "", + "employeesso": "", + "endtime": null, + "isactive": true, + "isshopfloor": true, + "link": null, + "notification": "retooling", + "notificationid": 1, + "resolved": false, + "starttime": null, + "ticketnumber": null, + "typecolor": "#17a2b8", + "typename": "Change" + } + ], + "timestamp": "2026-01-01T00:00:00.000000", + "upcoming": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "parameters": [ + { + "name": "businessunit", + "in": "query", + "required": false, "schema": { "type": "string" } @@ -13768,34 +29431,8 @@ "tags": [ "plugin-notifications" ], - "summary": "List notification types, paginated, active-only by default", - "description": "List notification types, paginated, active-only by default\n\n**Auth:** none\n\n**Params:** page, per_page; active=false to include inactive types\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/notifications/types?active=false&page=1&per_page=50'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-notifications" - ], - "summary": "Create a notification type incl. expiry rule and shopfloor display config", - "description": "Create a notification type incl. expiry rule and shopfloor display config\n\n**Auth:** jwt + permission:notifications.create\n\n**Params:** body: typename (required, unique), typedescription/description, typecolor/color, expirymode (none|duration|dailytime), expirydays, expiryhour, expiryminute, splitperemployee, showemployeephoto, displaystyle (standard|carousel|grid|banner)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/notifications/types -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"typename\":\"Recognition\",\"typecolor\":\"recognition\",\"expirymode\":\"dailytime\",\"expiryhour\":8,\"splitperemployee\":true,\"showemployeephoto\":true}'\n```", + "summary": "List all notification types.", + "description": "List all notification types.\n\n**Auth:** permission:notifications.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/types -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -13806,7 +29443,142 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "boardcategory": "Alerts", + "boardorder": 100, + "displaystyle": "grid", + "expirydays": null, + "expiryhour": null, + "expiryminute": 0, + "expirymode": "none", + "gracewindowminutes": 0, + "isactive": true, + "notificationtypeid": 1, + "showemployeephoto": "", + "splitperemployee": "", + "typecolor": "#17a2b8", + "typedescription": null, + "typename": "Awareness" + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "plugin-notifications" + ], + "summary": "Create a new notification type.", + "description": "Create a new notification type.\n\n**Auth:** permission:notifications.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/notifications/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "boardcategory": "Alerts", + "boardorder": 100, + "displaystyle": "grid", + "expirydays": null, + "expiryhour": null, + "expiryminute": 0, + "expirymode": "none", + "gracewindowminutes": 0, + "isactive": true, + "notificationtypeid": 1, + "showemployeephoto": "", + "splitperemployee": "", + "typecolor": "#17a2b8", + "typedescription": null, + "typename": "Awareness" + }, + "message": "Notification type created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -13832,7 +29604,12 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: typename (required, unique), typedescription/description, typecolor/color, expirymode (none|duration|dailytime), expirydays, expiryhour, expiryminute, splitperemployee, showemployeephoto, displaystyle (standard|carousel|grid|banner)" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "boardcategory": "Alerts", + "displaystyle": "grid", + "typename": "Awareness" } } } @@ -13840,73 +29617,12 @@ } }, "/api/notifications/types/{type_id}": { - "put": { - "tags": [ - "plugin-notifications" - ], - "summary": "Update a notification type (name/desc/color/isactive plus expiry and display fields)", - "description": "Update a notification type (name/desc/color/isactive plus expiry and display fields)\n\n**Auth:** jwt + permission:notifications.create\n\n**Params:** body: any of typename (unique-checked), typedescription/description, typecolor/color, isactive, expirymode, expirydays, expiryhour, expiryminute, splitperemployee, showemployeephoto, displaystyle\n\n**Example:**\n```\ncurl -X PATCH http://localhost:5001/api/notifications/types/3 -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"expirymode\":\"duration\",\"expirydays\":14}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: any of typename (unique-checked), typedescription/description, typecolor/color, isactive, expirymode, expirydays, expiryhour, expiryminute, splitperemployee, showemployeephoto, displaystyle" - } - } - } - } - }, "patch": { "tags": [ "plugin-notifications" ], - "summary": "Update a notification type (name/desc/color/isactive plus expiry and display fields)", - "description": "Update a notification type (name/desc/color/isactive plus expiry and display fields)\n\n**Auth:** jwt + permission:notifications.create\n\n**Params:** body: any of typename (unique-checked), typedescription/description, typecolor/color, isactive, expirymode, expirydays, expiryhour, expiryminute, splitperemployee, showemployeephoto, displaystyle\n\n**Example:**\n```\ncurl -X PATCH http://localhost:5001/api/notifications/types/3 -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"expirymode\":\"duration\",\"expirydays\":14}'\n```", + "summary": "Update a notification type, including its auto-expiry rule.", + "description": "Update a notification type, including its auto-expiry rule.\n\n**Auth:** permission:notifications.create\n\n**Params:** path: type_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PATCH http://localhost:5001/api/notifications/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -13917,7 +29633,34 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "boardcategory": "Alerts", + "boardorder": 100, + "displaystyle": "grid", + "expirydays": null, + "expiryhour": null, + "expiryminute": 0, + "expirymode": "none", + "gracewindowminutes": 0, + "isactive": true, + "notificationtypeid": 1, + "showemployeephoto": "", + "splitperemployee": "", + "typecolor": "#17a2b8", + "typedescription": null, + "typename": "Awareness" + }, + "message": "Notification type updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -13956,46 +29699,21 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: any of typename (unique-checked), typedescription/description, typecolor/color, isactive, expirymode, expirydays, expiryhour, expiryminute, splitperemployee, showemployeephoto, displaystyle" - } - } - } - } - } - }, - "/api/notifications": { - "get": { - "tags": [ - "plugin-notifications" - ], - "summary": "List notifications with filters, newest-first, paginated", - "description": "List notifications with filters, newest-first, paginated\n\n**Auth:** none\n\n**Params:** page, per_page; active=false to include inactive; typeid/type_id; ticketnumber (exact match, for idempotent import); current=true (within start/end window now); search (ILIKE on text)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/notifications?current=true&typeid=2&search=outage&page=1'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" + "description": "path: type_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "boardcategory": "Alerts" } } } } }, - "post": { + "put": { "tags": [ "plugin-notifications" ], - "summary": "Create a notification; endtime auto-derived from type expiry rule when omitted", - "description": "Create a notification; endtime auto-derived from type expiry rule when omitted\n\n**Auth:** jwt + permission:notifications.create\n\n**Params:** body: notification/message (required), notificationtypeid, businessunitid, appid, starttime/startdate (ISO, default now), endtime/enddate (ISO), ticketnumber, link/linkurl, isshopfloor (default false), employeesso (comma-list allowed), employeename\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/notifications -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"notification\":\"Line 2 press down\",\"notificationtypeid\":1,\"isshopfloor\":true,\"ticketnumber\":\"INC0012345\"}'\n```", + "summary": "Update a notification type, including its auto-expiry rule.", + "description": "Update a notification type, including its auto-expiry rule.\n\n**Auth:** permission:notifications.create\n\n**Params:** path: type_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PATCH http://localhost:5001/api/notifications/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -14006,7 +29724,407 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "boardcategory": "Alerts", + "boardorder": 100, + "displaystyle": "grid", + "expirydays": null, + "expiryhour": null, + "expiryminute": 0, + "expirymode": "none", + "gracewindowminutes": 0, + "isactive": true, + "notificationtypeid": 1, + "showemployeephoto": "", + "splitperemployee": "", + "typecolor": "#17a2b8", + "typedescription": null, + "typename": "Awareness" + }, + "message": "Notification type updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: type_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "boardcategory": "Alerts" + } + } + } + } + } + }, + "/api/printers": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "List all printers with filtering and pagination.", + "description": "List all printers with filtering and pagination.\n\n**Auth:** permission:printers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "assetid": 1, + "assetnumber": "PR-1", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "printer", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "ipaddress": "", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "printer": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "installpath": null, + "isactive": true, + "iscolor": false, + "iscsf": false, + "isduplex": false, + "isnetwork": true, + "modelname": "ZT411", + "modelnumberid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "pin": null, + "printerid": 1, + "printertypeid": 1, + "printertypename": "Thermal", + "sharename": null, + "vendorid": null, + "windowsname": null + }, + "serialnumber": "", + "statusid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "assetnumber", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "businessunit_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "businessunitid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "dir", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "asc" + } + }, + { + "name": "location_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "locationid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "hostname" + } + }, + { + "name": "type_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "typeid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "vendor_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "vendorid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "plugin-printers" + ], + "summary": "Create new printer (creates both Asset and Printer records).", + "description": "Create new printer (creates both Asset and Printer records).\n\n**Auth:** permission:printers.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/printers -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "assetnumber": "SpoolsInspection-Xerox", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "printer", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "printer": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "installpath": null, + "isactive": true, + "iscolor": false, + "iscsf": false, + "isduplex": false, + "isnetwork": true, + "modelname": "VersaLink C7125", + "modelnumberid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "pin": null, + "printerid": 1, + "printertypeid": null, + "sharename": null, + "vendorfrommodel": true, + "vendorid": null, + "vendorname": "Xerox", + "windowsname": null + }, + "serialnumber": "", + "statusid": 1 + }, + "message": "Printer created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -14032,27 +30150,40 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: notification/message (required), notificationtypeid, businessunitid, appid, starttime/startdate (ISO, default now), endtime/enddate (ISO), ticketnumber, link/linkurl, isshopfloor (default false), employeesso (comma-list allowed), employeename" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "assetnumber": "CSF01-Materials-HP", + "hostname": "", + "installpath": "", + "mapx": 120, + "mapy": 240 } } } } } }, - "/api/notifications/{notification_id}": { - "get": { + "/api/printers/{printer_id}": { + "delete": { "tags": [ - "plugin-notifications" + "plugin-printers" + ], + "summary": "Delete (soft delete) printer.", + "description": "Delete (soft delete) printer.\n\n**Auth:** permission:printers.delete\n\n**Params:** path: printer_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/printers/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } ], - "summary": "Get a single notification by ID", - "description": "Get a single notification by ID\n\n**Auth:** none\n\n**Params:** path: notification_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/42\n```", - "security": [], "responses": { "200": { "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -14064,13 +30195,132 @@ } } }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, "404": { "description": "No such record." } }, "parameters": [ { - "name": "notification_id", + "name": "printer_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Get a single printer with full details.", + "description": "Get a single printer with full details.\n\n**Auth:** permission:printers.view\n\n**Params:** path: printer_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "assetnumber": "PR-2001", + "assettypecolor": null, + "assettypeid": 1, + "assettypename": "printer", + "businessunitid": null, + "communications": [], + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "drivers": [], + "gaugelabreference": null, + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": null, + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "printer": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "imageurl": "/api/models/images/model-1.png", + "installpath": null, + "isactive": true, + "iscolor": false, + "iscsf": false, + "isduplex": false, + "isnetwork": true, + "modelname": "IMG-MODEL-1", + "modelnumberid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "pin": null, + "printerid": 1, + "printertypeid": null, + "sharename": null, + "vendorid": null, + "windowsname": null + }, + "serialnumber": "", + "statusid": 1 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "printer_id", "in": "path", "required": true, "schema": { @@ -14081,10 +30331,10 @@ }, "put": { "tags": [ - "plugin-notifications" + "plugin-printers" ], - "summary": "Update any notification field; empty starttime resets to now, empty endtime clears it", - "description": "Update any notification field; empty starttime resets to now, empty endtime clears it\n\n**Auth:** jwt + permission:notifications.edit\n\n**Params:** body: notification/message, notificationtypeid, businessunitid, appid, ticketnumber, link/linkurl, isactive, isshopfloor, employeesso, employeename, starttime/startdate, endtime/enddate\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/notifications/42 -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"endtime\":\"2026-08-01T12:00:00Z\",\"isactive\":true}'\n```", + "summary": "Update printer (both Asset and Printer records).", + "description": "Update printer (both Asset and Printer records).\n\n**Auth:** permission:printers.edit\n\n**Params:** path: printer_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/printers/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -14095,7 +30345,68 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "assetnumber": "AST-printer", + "assettypecolor": null, + "assettypeid": 2, + "assettypename": "printer", + "businessunitid": null, + "createdby": "", + "createddate": "2026-01-01T00:00:00.000000Z", + "deletedby": null, + "deleteddate": null, + "gaugelabreference": "GL-2", + "isactive": true, + "levelid": null, + "locationcode": null, + "locationid": null, + "maintenancereference": "MNT-2", + "mapverifiedat": null, + "mapx": null, + "mapy": null, + "modifiedby": "", + "modifieddate": "2026-01-01T00:00:00.000000Z", + "name": null, + "notes": null, + "ownlevelid": null, + "ownlocationid": null, + "ownmapx": null, + "ownmapy": null, + "pluginid": 1, + "printer": { + "assetid": 1, + "createddate": "2026-01-01T00:00:00.000000Z", + "hostname": "", + "installpath": null, + "isactive": true, + "iscolor": false, + "iscsf": false, + "isduplex": false, + "isnetwork": true, + "modelnumberid": null, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "pin": null, + "printerid": 1, + "printertypeid": null, + "sharename": null, + "vendorid": null, + "windowsname": null + }, + "serialnumber": "", + "statusid": 1 + }, + "message": "Printer updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -14119,7 +30430,7 @@ }, "parameters": [ { - "name": "notification_id", + "name": "printer_id", "in": "path", "required": true, "schema": { @@ -14134,18 +30445,24 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "body: notification/message, notificationtypeid, businessunitid, appid, ticketnumber, link/linkurl, isactive, isshopfloor, employeesso, employeename, starttime/startdate, endtime/enddate" + "description": "path: printer_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "gaugelabreference": "GL-2", + "maintenancereference": "MNT-2" } } } } - }, - "delete": { + } + }, + "/api/printers/{printer_id}/supplies": { + "get": { "tags": [ - "plugin-notifications" + "plugin-printers" ], - "summary": "Soft-delete a notification (sets isactive=false, row kept)", - "description": "Soft-delete a notification (sets isactive=false, row kept)\n\n**Auth:** jwt + permission:notifications.delete\n\n**Params:** path: notification_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/notifications/42 -H \"Authorization: Bearer $TOKEN\"\n```", + "summary": "Get supply levels from Zabbix (real-time lookup).", + "description": "Get supply levels from Zabbix (real-time lookup).\n\n**Auth:** permission:printers.view\n\n**Params:** path: printer_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/1/supplies -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -14156,7 +30473,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -14180,7 +30499,7 @@ }, "parameters": [ { - "name": "notification_id", + "name": "printer_id", "in": "path", "required": true, "schema": { @@ -14190,166 +30509,13 @@ ] } }, - "/api/notifications/active": { + "/api/printers/assignments/for-asset/{asset_id}": { "get": { "tags": [ - "plugin-notifications" + "plugin-printers" ], - "summary": "Currently active notifications for display, incl. ones starting within a 10-day lookahead", - "description": "Currently active notifications for display, incl. ones starting within a 10-day lookahead\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/active\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/notifications/calendar": { - "get": { - "tags": [ - "plugin-notifications" - ], - "summary": "Active notifications as FullCalendar event objects for a date range", - "description": "Active notifications as FullCalendar event objects for a date range\n\n**Auth:** none\n\n**Params:** start (ISO), end (ISO); invalid dates silently ignored\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/notifications/calendar?start=2026-07-01T00:00:00Z&end=2026-07-31T23:59:59Z'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/notifications/dashboard/summary": { - "get": { - "tags": [ - "plugin-notifications" - ], - "summary": "Dashboard counts: total currently-active notifications plus active counts grouped by type/color", - "description": "Dashboard counts: total currently-active notifications plus active counts grouped by type/color\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/dashboard/summary\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/notifications/employee/{sso}": { - "get": { - "tags": [ - "plugin-notifications" - ], - "summary": "All active recognition-type notifications mentioning an employee SSO (exact or within comma-separated employeesso list)", - "description": "All active recognition-type notifications mentioning an employee SSO (exact or within comma-separated employeesso list)\n\n**Auth:** none\n\n**Params:** path: sso (digits only, 400 otherwise)\n\n**Example:**\n```\ncurl http://localhost:5001/api/notifications/employee/212345678\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "sso", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/notifications/shopfloor": { - "get": { - "tags": [ - "plugin-notifications" - ], - "summary": "Shopfloor TV feed: current cards (active now, or ended <30 min ago flagged resolved) + upcoming (starts within 5 days)...", - "description": "Shopfloor TV feed: current cards (active now, or ended <30 min ago flagged resolved) + upcoming (starts within 5 days); splits multi-employee cards per type config, resolves employee names/photos via employees plugin, returns configversion hash so kiosks reload on layout changes\n\n**Auth:** none\n\n**Params:** businessunit (numeric BU id: returns that BU's plus null-BU notifications; omitted: null-BU only)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/notifications/shopfloor?businessunit=3'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/notifications/dashboard/active": { - "get": { - "tags": [ - "plugin-notifications" - ], - "summary": "Dashboard card: the notifications currently showing on the shop-floor board, so the office can see what the floor sees", - "description": "Dashboard card: the notifications currently showing on the shop-floor board, so the office can see what the floor sees\n\n**Auth:** jwt\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/notifications/dashboard/active\n```", + "summary": "What THIS asset is assigned, without inheritance.", + "description": "What THIS asset is assigned, without inheritance.\n\n**Auth:** permission:printers.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/assignments/for-asset/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -14360,7 +30526,294 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 4, + "defaultprinterassetid": null, + "printerassetids": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-printers" + ], + "summary": "Reconcile one asset's whole printer assignment in a single call.", + "description": "Reconcile one asset's whole printer assignment in a single call.\n\n**Auth:** permission:printers.edit\n\n**Params:** path: asset_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/printers/assignments/for-asset/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 3, + "defaultprinterassetid": 4, + "printerassetids": [ + 4, + 5 + ] + }, + "message": "Printer assignment updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: asset_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "defaultprinterassetid": 4, + "printerassetids": [ + 4, + 5 + ] + } + } + } + } + } + }, + "/api/printers/assignments/seed-from-observed/{asset_id}": { + "post": { + "tags": [ + "plugin-printers" + ], + "summary": "Write what a PC observed as the assignment of the asset in the path.", + "description": "Write what a PC observed as the assignment of the asset in the path.\n\n**Auth:** permission:printers.edit\n\n**Params:** path: asset_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/printers/assignments/seed-from-observed/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: asset_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/printers/by-asset/{asset_id}": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Get printer data by asset ID.", + "description": "Get printer data by asset ID.\n\n**Auth:** permission:printers.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/by-asset/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/printers/dashboard/summary": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Get printer dashboard summary data.", + "description": "Get printer dashboard summary data.\n\n**Auth:** permission:printers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/dashboard/summary -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "bytype": [], + "byvendor": [], + "criticalsupplies": 0, + "lowsupplies": 0, + "online": 0, + "total": 0, + "totalprinters": 0 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -14381,20 +30834,90 @@ } } }, - "/api/slides/feed": { + "/api/printers/dashboard/supplies": { "get": { "tags": [ - "plugin-slides" + "plugin-printers" + ], + "summary": "Printers needing a cartridge, flattened to one row per printer.", + "description": "Printers needing a cartridge, flattened to one row per printer.\n\n**Auth:** permission:printers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/dashboard/supplies -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } ], - "summary": "Public flat playlist for a surface (lobby/shopfloor); raw jsonify, not the success_response envelope, so the...", - "description": "Public flat playlist for a surface (lobby/shopfloor); raw jsonify, not the success_response envelope, so the screensaver parser works unchanged\n\n**Auth:** none\n\n**Params:** query: surface=lobby|shopfloor (default lobby; invalid values fall back to lobby). Returns {success, surface, basepath, interval, slides:[{filename, seconds}]}; only slides whose file exists on disk are listed\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/slides/feed?surface=shopfloor'\n```", - "security": [], "responses": { "200": { "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "rows": [ + { + "iscritical": true, + "levelid": null, + "location": "Shop floor", + "mapx": null, + "mapy": null, + "printerid": 2, + "printername": "Bravo", + "supplies": [ + { + "level": "critical", + "text": "Black Toner 0%", + "title": "No part number on file for this model" + } + ], + "worstremaining": 0 + }, + { + "iscritical": true, + "levelid": null, + "location": "Shop floor", + "mapx": null, + "mapy": null, + "printerid": 3, + "printername": "Charlie", + "supplies": [ + { + "level": "critical", + "text": "Black Toner 2%", + "title": "No part number on file for this model" + } + ], + "worstremaining": 2 + }, + { + "iscritical": true, + "levelid": null, + "location": "Shop floor", + "mapx": null, + "mapy": null, + "printerid": 1, + "printername": "Alpha", + "supplies": [ + { + "level": "critical", + "text": "Black Toner 4%", + "title": "No part number on file for this model" + } + ], + "worstremaining": 4 + }, + "" + ], + "unavailable": null + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -14405,24 +30928,374 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } } } }, - "/api/slides/img/{surface}/{filename}": { + "/api/printers/drivers": { "get": { "tags": [ - "plugin-slides" + "plugin-printers" + ], + "summary": "List printer drivers. ?active=false includes inactive ones.", + "description": "List printer drivers. ?active=false includes inactive ones.\n\n**Auth:** permission:printers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/drivers -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } ], - "summary": "Public serving of a single slide image from instance/slides//, with basename path-traversal guard; 404 on...", - "description": "Public serving of a single slide image from instance/slides//, with basename path-traversal guard; 404 on unknown surface, traversal attempt, or missing file\n\n**Auth:** none\n\n**Params:** path: surface (lobby|shopfloor), filename (must equal its basename)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/slides/img/lobby/Slide1.png' -o Slide1.png\n```", - "security": [], "responses": { "200": { "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + } + ] + }, + "post": { + "tags": [ + "plugin-printers" + ], + "summary": "UNDOCUMENTED: printers_asset.create_driver has no docstring.", + "description": "UNDOCUMENTED: printers_asset.create_driver has no docstring.\n\n**Auth:** permission:printers.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/printers/drivers -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "description": null, + "driverid": 1, + "drivername": "HP Universal Printing PCL 6", + "isactive": true, + "location": "", + "modelname": null, + "modelnumberid": null, + "name": "HP universal", + "vendorid": 1 + }, + "message": "Driver created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "drivername": "HP Universal Printing PCL 6", + "location": "", + "name": "HP universal", + "vendorid": 1 + } + } + } + } + } + }, + "/api/printers/drivers/{driver_id}": { + "delete": { + "tags": [ + "plugin-printers" + ], + "summary": "UNDOCUMENTED: printers_asset.delete_driver has no docstring.", + "description": "UNDOCUMENTED: printers_asset.delete_driver has no docstring.\n\n**Auth:** permission:printers.delete\n\n**Params:** path: driver_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/printers/drivers/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "driver_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-printers" + ], + "summary": "UNDOCUMENTED: printers_asset.update_driver has no docstring.", + "description": "UNDOCUMENTED: printers_asset.update_driver has no docstring.\n\n**Auth:** permission:printers.edit\n\n**Params:** path: driver_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/printers/drivers/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "description": null, + "driverid": 1, + "drivername": "Xerox Global Print Driver PCL6", + "isactive": true, + "location": "", + "modelname": null, + "modelnumberid": null, + "name": "Xerox universal", + "vendorid": 2 + }, + "message": "Driver updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "driver_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: driver_id (int); body: JSON (fields not yet documented)" + }, + "example": { + "drivername": "Xerox Global Print Driver PCL6", + "vendorid": 2 + } + } + } + } + } + }, + "/api/printers/for-host/{hostname}": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Printers assigned to a PC, by hostname, with what it takes to install one.", + "description": "Printers assigned to a PC, by hostname, with what it takes to install one.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** path: hostname (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/for-host/x -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "assetnumber": "1001", + "defaultprinterid": null, + "hostname": "", + "printers": [ + { + "assetid": 4, + "driverid": null, + "driverlocation": null, + "drivername": null, + "hostname": "", + "inheritedfromassetid": 3, + "installpath": null, + "ipaddress": "", + "isdefault": false, + "port": null, + "printerid": 1, + "queuename": "PRINTER-A", + "sharename": null, + "windowsname": "PRINTER-A" + }, + { + "assetid": 5, + "driverid": null, + "driverlocation": null, + "drivername": null, + "hostname": "", + "inheritedfromassetid": 3, + "installpath": null, + "ipaddress": "", + "isdefault": false, + "port": null, + "printerid": 2, + "queuename": "PRINTER-B", + "sharename": null, + "windowsname": "PRINTER-B" + }, + { + "assetid": 6, + "driverid": null, + "driverlocation": null, + "drivername": null, + "hostname": "", + "inheritedfromassetid": 3, + "installpath": null, + "ipaddress": "", + "isdefault": false, + "port": null, + "printerid": 3, + "queuename": "PRINTER-C", + "sharename": null, + "windowsname": "PRINTER-C" + } + ], + "source": "inherited" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -14440,32 +31313,1483 @@ }, "parameters": [ { - "name": "surface", + "name": "hostname", "in": "path", "required": true, "schema": { "type": "string" } - }, + } + ] + } + }, + "/api/printers/install-batch": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Generate a Windows .bat that installs the selected printers.", + "description": "Generate a Windows .bat that installs the selected printers.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/install-batch -H \"Authorization: Bearer $TOK\"\n```", + "security": [ { - "name": "filename", - "in": "path", - "required": true, + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "parameters": [ + { + "name": "printerids", + "in": "query", + "required": false, "schema": { - "type": "string", - "description": "may contain slashes" + "type": "string" } } ] } }, + "/api/printers/install-list": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Flat, unpaginated list of network printers for the printer installer.", + "description": "Flat, unpaginated list of network printers for the printer installer.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/install-list -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "hostname": "", + "installpath": "", + "ipaddress": "", + "iscsf": false, + "levelid": null, + "locationname": null, + "machinenumber": "CSF01-Materials-HP", + "mapx": 120, + "mapy": 240, + "modelnumber": null, + "name": "CSF01-Materials-HP", + "printerid": 1, + "sharename": null, + "vendorname": "", + "windowsname": null + } + ], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/printers/lookup": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Find a printer by IP or FQDN. Parity with the classic printerlookup.asp.", + "description": "Find a printer by IP or FQDN. Parity with the classic printerlookup.asp.\n\n**Auth:** permission:printers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/lookup -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "fqdn", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ip", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/printers/lowsupplies": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Get printers with low or critical supply levels.", + "description": "Get printers with low or critical supply levels.\n\n**Auth:** permission:printers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/lowsupplies -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "printers": [], + "summary": { + "critical": 0, + "low": 0, + "total_checked": 0 + } + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/printers/models": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "List models with a supply count, for the supply-management picker.", + "description": "List models with a supply count, for the supply-management picker.\n\n**Auth:** permission:printers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/models -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [ + { + "modelnumber": "TestModel C999", + "modelnumberid": 1, + "supplycount": 1, + "vendor": "TestVendor", + "vendorid": 1 + } + ], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 1, + "totalpages": 1 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "vendor_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "vendorid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "withsupplies", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "" + } + } + ] + } + }, + "/api/printers/models/{modelnumberid}/supplies": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "List all supplies mapped to a model.", + "description": "List all supplies mapped to a model.\n\n**Auth:** permission:printers.view\n\n**Params:** path: modelnumberid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/models/1/supplies -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "modelnumber": "TestModel C999", + "modelnumberid": 1, + "supplies": [ + { + "capacitytier": "standard", + "color": "black", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "marketingname": "414A Black", + "modelnumber": "TestModel C999", + "modelnumberid": 1, + "modelsupplyid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "pageyield": 2400, + "partnumber": "W2020A", + "supplytype": "toner" + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "modelnumberid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "tags": [ + "plugin-printers" + ], + "summary": "Add a supply to a model.", + "description": "Add a supply to a model.\n\n**Auth:** permission:printers.create\n\n**Params:** path: modelnumberid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/printers/models/1/supplies -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "capacitytier": "standard", + "color": "black", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "marketingname": "414A Black", + "modelnumber": "TestModel C999", + "modelnumberid": 1, + "modelsupplyid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "pageyield": 2400, + "partnumber": "W2020A", + "supplytype": "toner" + }, + "message": "Supply added", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "modelnumberid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: modelnumberid (int); body: JSON (fields not yet documented)" + }, + "example": { + "capacitytier": "standard", + "color": "black", + "marketingname": "414A Black", + "pageyield": 2400, + "partnumber": "W2020A", + "supplytype": "toner" + } + } + } + } + } + }, + "/api/printers/observed/{hostname}": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "What a host last reported, each queue judged against what it is assigned.", + "description": "What a host last reported, each queue judged against what it is assigned.\n\n**Auth:** permission:printers.view\n\n**Params:** path: hostname (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/observed/x -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "hostname", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/printers/observed/for-asset/{asset_id}": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "The same comparison, reached from an asset page: one block per host.", + "description": "The same comparison, reached from an asset page: one block per host.\n\n**Auth:** permission:printers.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/observed/for-asset/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assetid": 1, + "assetnumber": "1001", + "hosts": [ + { + "assetid": 1, + "assetnumber": "1001", + "hostname": "", + "observedat": "2026-01-01T00:00:00.000000", + "queues": [ + { + "classification": "extra", + "driftfields": [], + "drivername": "HP Universal Printing PS", + "expecteddrivername": null, + "expectedportaddress": null, + "expectedqueuename": null, + "inheritedfromassetid": null, + "isassigneddefault": false, + "isdefault": true, + "isshared": false, + "portaddress": "", + "portname": "IP_10.20.0.11", + "printerassetid": 5, + "printerid": 1, + "printername": "Bay label printer", + "queuename": "PRINTER-A" + }, + { + "classification": "extra", + "driftfields": [], + "drivername": "HP Universal Printing PS", + "expecteddrivername": null, + "expectedportaddress": null, + "expectedqueuename": null, + "inheritedfromassetid": null, + "isassigneddefault": false, + "isdefault": false, + "isshared": false, + "portaddress": "", + "portname": "IP_10.20.0.12", + "printerassetid": 6, + "printerid": 2, + "printername": "Bay laser printer", + "queuename": "PRINTER-B" + }, + { + "classification": "unknown", + "driftfields": [], + "drivername": "HP Universal Printing PS", + "expecteddrivername": null, + "expectedportaddress": null, + "expectedqueuename": null, + "inheritedfromassetid": null, + "isassigneddefault": false, + "isdefault": false, + "isshared": false, + "portaddress": "", + "portname": "IP_10.20.0.99", + "printerassetid": null, + "printerid": null, + "printername": null, + "queuename": "Reception Copier" + } + ], + "seedcandidate": { + "defaultprinterassetid": 5, + "printerassetids": [ + 5, + 6 + ], + "skipped": [ + { + "drivername": "HP Universal Printing PS", + "isdefault": false, + "portaddress": "", + "portname": "IP_10.20.0.99", + "queuename": "Reception Copier" + } + ] + }, + "source": "none", + "summary": { + "drifted": 0, + "extra": 2, + "matching": 0, + "missing": 0, + "unknown": 1 + } + } + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/printers/pc-default": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Default printer for a PC, by machine (asset) number.", + "description": "Default printer for a PC, by machine (asset) number.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/pc-default -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "printerid": 1, + "windowsname": "HP-CSF04-Materials" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "machine", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/printers/supplies/{modelsupplyid}": { + "delete": { + "tags": [ + "plugin-printers" + ], + "summary": "Delete a model supply.", + "description": "Delete a model supply.\n\n**Auth:** permission:printers.delete\n\n**Params:** path: modelsupplyid (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/printers/supplies/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "message": "Supply deleted", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "modelsupplyid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-printers" + ], + "summary": "Update a model supply.", + "description": "Update a model supply.\n\n**Auth:** permission:printers.edit\n\n**Params:** path: modelsupplyid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/printers/supplies/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "capacitytier": "high", + "color": "black", + "createddate": "2026-01-01T00:00:00.000000Z", + "isactive": true, + "marketingname": "414X Black", + "modelnumber": "TestModel C999", + "modelnumberid": 1, + "modelsupplyid": 1, + "modifieddate": "2026-01-01T00:00:00.000000Z", + "notes": null, + "pageyield": null, + "partnumber": "W2020A", + "supplytype": "toner" + }, + "message": "Supply updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "modelsupplyid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: modelsupplyid (int); body: JSON (fields not yet documented)" + }, + "example": { + "capacitytier": "high", + "marketingname": "414X Black" + } + } + } + } + } + }, + "/api/printers/supplies/forecast": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Days-to-empty and replacement counts per printer.", + "description": "Days-to-empty and replacement counts per printer.\n\n**Auth:** permission:printers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/supplies/forecast -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "available": false, + "cartridges": [], + "days": 90, + "horizondays": 14, + "orderlist": [], + "reason": "Zabbix is not configured or not reachable", + "unestimated": [] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "days", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 90 + } + } + ] + } + }, + "/api/printers/supplies/meta": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Allowed values for supply type, color, and capacity tier (for the UI).", + "description": "Allowed values for supply type, color, and capacity tier (for the UI).\n\n**Auth:** permission:printers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/supplies/meta -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "capacitytiers": [ + "standard", + "high", + "extrahigh", + "" + ], + "colors": [ + "black", + "cyan", + "magenta", + "" + ], + "supplytypes": [ + "toner", + "drum", + "waste", + "" + ] + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/printers/supplies/refresh": { + "post": { + "tags": [ + "plugin-printers" + ], + "summary": "Clear cached Zabbix supply data so the next read pulls fresh values.", + "description": "Clear cached Zabbix supply data so the next read pulls fresh values.\n\n**Auth:** permission:printers.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/printers/supplies/refresh -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "message": "Supply cache cleared", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": {} + } + } + } + } + }, + "/api/printers/types": { + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "List all printer types.", + "description": "List all printer types.\n\n**Auth:** permission:printers.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/types -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "pagination": { + "hasnext": false, + "hasprev": false, + "page": 1, + "perpage": 20, + "total": 0, + "totalpages": 0 + }, + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "perpage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "tags": [ + "plugin-printers" + ], + "summary": "Create a new printer type.", + "description": "Create a new printer type.\n\n**Auth:** permission:printers.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/printers/types -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/printers/types/{type_id}": { + "delete": { + "tags": [ + "plugin-printers" + ], + "summary": "Delete a printer type. Refused if any printer still uses it.", + "description": "Delete a printer type. Refused if any printer still uses it.\n\n**Auth:** permission:printers.delete\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/printers/types/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-printers" + ], + "summary": "Get a single printer type.", + "description": "Get a single printer type.\n\n**Auth:** permission:printers.view\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/types/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "put": { + "tags": [ + "plugin-printers" + ], + "summary": "Update a printer type.", + "description": "Update a printer type.\n\n**Auth:** permission:printers.edit\n\n**Params:** path: type_id (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/printers/types/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "type_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: type_id (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, "/api/slides/{surface}": { "get": { "tags": [ "plugin-slides" ], - "summary": "Admin list of a surface's slides (TvSlide rows ordered by sortorder,slideid, filtered to files present on disk), each...", - "description": "Admin list of a surface's slides (TvSlide rows ordered by sortorder,slideid, filtered to files present on disk), each with a url field for the img route\n\n**Auth:** jwt + permission:slides.manage\n\n**Params:** path: surface (lobby|shopfloor; else VALIDATION_ERROR)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/slides/lobby'\n```", + "summary": "UNDOCUMENTED: slides.list_slides has no docstring.", + "description": "UNDOCUMENTED: slides.list_slides has no docstring.\n\n**Auth:** permission:slides.view or slides.manage\n\n**Params:** path: surface (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/slides/x -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -14476,7 +32800,17 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -14510,202 +32844,13 @@ ] } }, - "/api/slides/{surface}/upload": { - "post": { - "tags": [ - "plugin-slides" - ], - "summary": "Upload one or more slide images; non-image extensions skipped, names secure_filename'd and unique-renamed (stem_N.ext)...", - "description": "Upload one or more slide images; non-image extensions skipped, names secure_filename'd and unique-renamed (stem_N.ext) on collision, appended after current max sortorder in natural filename order, seconds=0 (surface default)\n\n**Auth:** jwt + permission:slides.manage\n\n**Params:** path: surface. multipart/form-data body: files (repeatable) or single file. Allowed extensions: .jpg .jpeg .png .gif .bmp .webp. Returns {added:[names]}\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -F 'files=@Slide1.png' -F 'files=@Slide2.png' 'http://localhost:5001/api/slides/lobby/upload'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "surface", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: surface. multipart/form-data body: files (repeatable) or single file. Allowed extensions: .jpg .jpeg .png .gif .bmp .webp. Returns {added:[names]}" - } - } - } - } - } - }, - "/api/slides/{surface}/order": { - "post": { - "tags": [ - "plugin-slides" - ], - "summary": "Save play order: each filename in the order array gets sortorder set to its index; unknown filenames silently ignored", - "description": "Save play order: each filename in the order array gets sortorder set to its index; unknown filenames silently ignored\n\n**Auth:** jwt + permission:slides.manage\n\n**Params:** path: surface. JSON body: {order: [filename, ...]}\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"order\":[\"Slide2.png\",\"Slide1.png\"]}' 'http://localhost:5001/api/slides/lobby/order'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "surface", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: surface. JSON body: {order: [filename, ...]}" - } - } - } - } - } - }, - "/api/slides/{surface}/delete": { - "post": { - "tags": [ - "plugin-slides" - ], - "summary": "Delete named slides: removes file from disk (OSError swallowed) and the TvSlide row; filenames reduced to basename...", - "description": "Delete named slides: removes file from disk (OSError swallowed) and the TvSlide row; filenames reduced to basename first; returns count of DB rows removed\n\n**Auth:** jwt + permission:slides.manage\n\n**Params:** path: surface. JSON body: {files: [filename, ...]}\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"files\":[\"Slide1.png\"]}' 'http://localhost:5001/api/slides/shopfloor/delete'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "surface", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: surface. JSON body: {files: [filename, ...]}" - } - } - } - } - } - }, "/api/slides/{surface}/{slideid}": { "patch": { "tags": [ "plugin-slides" ], - "summary": "Update a single slide's per-slide duration; seconds clamped to >=0 (0 means use the 10s surface default); 404 if slide...", - "description": "Update a single slide's per-slide duration; seconds clamped to >=0 (0 means use the 10s surface default); 404 if slide missing or belongs to a different surface\n\n**Auth:** jwt + permission:slides.manage\n\n**Params:** path: surface, slideid (int). JSON body: {seconds: int} (non-int -> VALIDATION_ERROR)\n\n**Example:**\n```\ncurl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"seconds\":15}' 'http://localhost:5001/api/slides/lobby/12'\n```", + "summary": "UNDOCUMENTED: slides.update_slide has no docstring.", + "description": "UNDOCUMENTED: slides.update_slide has no docstring.\n\n**Auth:** permission:slides.manage\n\n**Params:** path: surface (string), slideid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PATCH http://localhost:5001/api/slides/x/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -14716,7 +32861,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -14763,20 +32910,20 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "path: surface, slideid (int). JSON body: {seconds: int} (non-int -> VALIDATION_ERROR)" + "description": "path: surface (string), slideid (int); body: JSON (fields not yet documented)" } } } } } }, - "/api/printedparts/items": { - "get": { + "/api/slides/{surface}/delete": { + "post": { "tags": [ - "plugin-printedparts" + "plugin-slides" ], - "summary": "List printed items, paginated, with search and low-stock filter", - "description": "List printed items, paginated, with search and low-stock filter\n\n**Auth:** jwt + permission:printedparts.view\n\n**Params:** query: page, per_page, search (matches itemcode/gagelabtag/itemname/itemdescription/binlocation), active (default true; 'false' includes retired), lowstock=true\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOK\" 'http://localhost:5001/api/printedparts/items?search=bracket&lowstock=true&page=1&per_page=25'\n```", + "summary": "UNDOCUMENTED: slides.delete_slides has no docstring.", + "description": "UNDOCUMENTED: slides.delete_slides has no docstring.\n\n**Auth:** permission:slides.manage\n\n**Params:** path: surface (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/slides/x/delete -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -14787,7 +32934,492 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "surface", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: surface (string); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/slides/{surface}/order": { + "post": { + "tags": [ + "plugin-slides" + ], + "summary": "UNDOCUMENTED: slides.reorder_slides has no docstring.", + "description": "UNDOCUMENTED: slides.reorder_slides has no docstring.\n\n**Auth:** permission:slides.manage\n\n**Params:** path: surface (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/slides/x/order -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "surface", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: surface (string); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/slides/{surface}/upload": { + "post": { + "tags": [ + "plugin-slides" + ], + "summary": "UNDOCUMENTED: slides.upload_slides has no docstring.", + "description": "UNDOCUMENTED: slides.upload_slides has no docstring.\n\n**Auth:** permission:slides.manage\n\n**Params:** path: surface (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/slides/x/upload -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "surface", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: surface (string); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/slides/feed": { + "get": { + "tags": [ + "plugin-slides" + ], + "summary": "Flat playlist for a surface. NOT wrapped in the success_response envelope so the screensaver's existing parser needs no...", + "description": "Flat playlist for a surface. NOT wrapped in the success_response envelope so the screensaver's existing parser needs no change.\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/slides/feed\n```", + "security": [], + "responses": { + "200": { + "description": "Success. This route does NOT return the standard envelope; the example is a transcript of a real response.", + "content": { + "application/json": { + "example": { + "basepath": "/api/slides/img/lobby/", + "interval": 10, + "slides": [], + "success": true, + "surface": "lobby" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "parameters": [ + { + "name": "surface", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "lobby" + } + } + ] + } + }, + "/api/slides/img/{surface}/{filename}": { + "get": { + "tags": [ + "plugin-slides" + ], + "summary": "Serve a slide image with a path-traversal guard.", + "description": "Serve a slide image with a path-traversal guard.\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** path: surface (string), filename (path)\n\n**Example:**\n```\ncurl http://localhost:5001/api/slides/img/x/example.png\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "surface", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "may contain slashes" + } + } + ] + } + }, + "/api/tools/secrets": { + "post": { + "tags": [ + "plugin-tools" + ], + "summary": "Store an already-encrypted secret and return its token.", + "description": "Store an already-encrypted secret and return its token.\n\n**Auth:** jwt-optional (works signed out; the handler decides what shows)\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/tools/secrets -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + }, + {} + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "createddate": "2026-01-01T00:00:00.000000", + "expiresat": "2026-01-01T00:00:00.000000", + "token": "", + "viewcount": 0, + "viewsremaining": 100 + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "ciphertext": "", + "iv": "MTIzNDU2Nzg5MDEy", + "views": 100 + } + } + } + } + } + }, + "/api/tools/secrets/{token}": { + "get": { + "tags": [ + "plugin-tools" + ], + "summary": "Whether a link is still live, WITHOUT spending it.", + "description": "Whether a link is still live, WITHOUT spending it.\n\n**Auth:** none (the unguessable token in the path is the authorisation)\n\n**Params:** path: token (string)\n\n**Example:**\n```\ncurl http://localhost:5001/api/tools/secrets/x\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "available": false + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "tags": [ + "plugin-tools" + ], + "summary": "Hand over the ciphertext once, then spend the view.", + "description": "Hand over the ciphertext once, then spend the view.\n\n**Auth:** none (the unguessable token in the path is the authorisation; redeeming spends the secret)\n\n**Params:** path: token (string); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/tools/secrets/x -H 'Content-Type: application/json' -d '{}'\n```", + "security": [], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "ciphertext": "", + "iv": "MTIzNDU2Nzg5MDEy" + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: token (string); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/warranty": { + "get": { + "tags": [ + "plugin-warranty" + ], + "summary": "List warranties. Filters: ?status=, ?assetid=, ?active=false.", + "description": "List warranties. Filters: ?status=, ?assetid=, ?active=false.\n\n**Auth:** permission:warranty.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/warranty -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -14805,14 +33437,57 @@ "403": { "description": "Authenticated, but not permitted." } - } + }, + "parameters": [ + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "true" + } + }, + { + "name": "assetid", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "servicetag", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "vendor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] }, "post": { "tags": [ - "plugin-printedparts" + "plugin-warranty" ], - "summary": "Create a printed item; itemcode auto-minted from printedparts_code_prefix setting + row id; optional gagelabtag...", - "description": "Create a printed item; itemcode auto-minted from printedparts_code_prefix setting + row id; optional gagelabtag unique-checked (409 on clash)\n\n**Auth:** jwt + permission:printedparts.create\n\n**Params:** json body: itemname (required), gagelabtag, itemdescription, lowstockthreshold (default from printedparts_default_threshold setting, fallback 5), binlocation, printnotes; quantityonhand starts at 0\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"itemname\":\"Fixture clip\",\"gagelabtag\":\"WJRP0042\",\"binlocation\":\"B3\",\"lowstockthreshold\":10}' http://localhost:5001/api/printedparts/items\n```", + "summary": "UNDOCUMENTED: warranty.create_warranty has no docstring.", + "description": "UNDOCUMENTED: warranty.create_warranty has no docstring.\n\n**Auth:** permission:warranty.create\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/warranty -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -14823,7 +33498,34 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assets": [], + "enddate": "2026-01-01", + "isactive": true, + "lastcheckeddate": null, + "notes": null, + "prooffilename": null, + "proofurl": null, + "provider": "manual", + "servicelevel": null, + "servicetag": "", + "startdate": null, + "status": "active", + "statuscolor": "#4CAF50", + "vendor": "Acme", + "warrantyid": 1 + }, + "message": "Warranty created", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -14849,20 +33551,25 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "json body: itemname (required), gagelabtag, itemdescription, lowstockthreshold (default from printedparts_default_threshold setting, fallback 5), binlocation, printnotes; quantityonhand starts at 0" + "description": "body: JSON (fields not yet documented)" + }, + "example": { + "enddate": "2026-01-01", + "servicetag": "", + "vendor": "Acme" } } } } } }, - "/api/printedparts/items/{item_id}": { - "get": { + "/api/warranty/{warrantyid}": { + "delete": { "tags": [ - "plugin-printedparts" + "plugin-warranty" ], - "summary": "Get one printed item plus its 25 most recent ledger transactions", - "description": "Get one printed item plus its 25 most recent ledger transactions\n\n**Auth:** jwt + permission:printedparts.view\n\n**Params:** path: item_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOK\" http://localhost:5001/api/printedparts/items/42\n```", + "summary": "UNDOCUMENTED: warranty.delete_warranty has no docstring.", + "description": "UNDOCUMENTED: warranty.delete_warranty has no docstring.\n\n**Auth:** permission:warranty.delete\n\n**Params:** path: warrantyid (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/warranty/1 -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -14873,7 +33580,9 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -14897,7 +33606,58 @@ }, "parameters": [ { - "name": "item_id", + "name": "warrantyid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "get": { + "tags": [ + "plugin-warranty" + ], + "summary": "UNDOCUMENTED: warranty.get_warranty has no docstring.", + "description": "UNDOCUMENTED: warranty.get_warranty has no docstring.\n\n**Auth:** permission:warranty.view\n\n**Params:** path: warrantyid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/warranty/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "warrantyid", "in": "path", "required": true, "schema": { @@ -14908,10 +33668,10 @@ }, "put": { "tags": [ - "plugin-printedparts" + "plugin-warranty" ], - "summary": "Update catalog fields (itemname, itemdescription, lowstockthreshold, binlocation, printnotes, gagelabtag); rejects...", - "description": "Update catalog fields (itemname, itemdescription, lowstockthreshold, binlocation, printnotes, gagelabtag); rejects quantityonhand (ledger-managed) and duplicate gagelabtag (409)\n\n**Auth:** jwt + permission:printedparts.edit\n\n**Params:** path: item_id; json body: any of the editable fields; gagelabtag uppercased, empty string clears it\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"binlocation\":\"C1\",\"lowstockthreshold\":8}' http://localhost:5001/api/printedparts/items/42\n```", + "summary": "UNDOCUMENTED: warranty.update_warranty has no docstring.", + "description": "UNDOCUMENTED: warranty.update_warranty has no docstring.\n\n**Auth:** permission:warranty.edit\n\n**Params:** path: warrantyid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/warranty/1 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -14922,7 +33682,34 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "assets": [], + "enddate": null, + "isactive": true, + "lastcheckeddate": null, + "notes": null, + "prooffilename": null, + "proofurl": null, + "provider": "manual", + "servicelevel": null, + "servicetag": "", + "startdate": null, + "status": "unknown", + "statuscolor": "#9E9E9E", + "vendor": "Acme", + "warrantyid": 1 + }, + "message": "Warranty updated", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -14946,7 +33733,7 @@ }, "parameters": [ { - "name": "item_id", + "name": "warrantyid", "in": "path", "required": true, "schema": { @@ -14961,18 +33748,23 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "path: item_id; json body: any of the editable fields; gagelabtag uppercased, empty string clears it" + "description": "path: warrantyid (int); body: JSON (fields not yet documented)" + }, + "example": { + "enddate": "" } } } } - }, + } + }, + "/api/warranty/{warrantyid}/proof": { "delete": { "tags": [ - "plugin-printedparts" + "plugin-warranty" ], - "summary": "Soft-retire an item (isactive=false); ledger history preserved", - "description": "Soft-retire an item (isactive=false); ledger history preserved\n\n**Auth:** jwt + permission:printedparts.delete\n\n**Params:** path: item_id\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOK\" http://localhost:5001/api/printedparts/items/42\n```", + "summary": "Remove a warranty's proof document.", + "description": "Remove a warranty's proof document.\n\n**Auth:** permission:warranty.edit\n\n**Params:** path: warrantyid (int)\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/warranty/1/proof -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -14983,7 +33775,33 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "enddate": null, + "isactive": true, + "lastcheckeddate": null, + "notes": null, + "prooffilename": null, + "proofurl": null, + "provider": "manual", + "servicelevel": null, + "servicetag": "", + "startdate": null, + "status": "unknown", + "statuscolor": "#9E9E9E", + "vendor": "Dell", + "warrantyid": 1 + }, + "message": "Proof removed", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -15007,7 +33825,7 @@ }, "parameters": [ { - "name": "item_id", + "name": "warrantyid", "in": "path", "required": true, "schema": { @@ -15015,139 +33833,13 @@ } } ] - } - }, - "/api/printedparts/items/{item_id}/restore": { - "post": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Un-retire a soft-deleted item (isactive=true); code, photo, history intact", - "description": "Un-retire a soft-deleted item (isactive=true); code, photo, history intact\n\n**Auth:** jwt + permission:printedparts.delete\n\n**Params:** path: item_id\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOK\" http://localhost:5001/api/printedparts/items/42/restore\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "item_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: item_id" - } - } - } - } - } - }, - "/api/printedparts/items/{item_id}/image": { - "post": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Upload or replace the item's photo (png/jpg/jpeg/gif/webp); old image files for the item are deleted first, imageurl...", - "description": "Upload or replace the item's photo (png/jpg/jpeg/gif/webp); old image files for the item are deleted first, imageurl updated\n\n**Auth:** jwt + permission:printedparts.edit\n\n**Params:** path: item_id; multipart/form-data: file=\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOK\" -F 'file=@clip.jpg' http://localhost:5001/api/printedparts/items/42/image\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "item_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: item_id; multipart/form-data: file=" - } - } - } - } }, - "delete": { + "post": { "tags": [ - "plugin-printedparts" + "plugin-warranty" ], - "summary": "Clear the item's imageurl; deletes the file on disk only if the URL is plugin-owned (starts with...", - "description": "Clear the item's imageurl; deletes the file on disk only if the URL is plugin-owned (starts with /api/printedparts/image/)\n\n**Auth:** jwt + permission:printedparts.delete\n\n**Params:** path: item_id\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOK\" http://localhost:5001/api/printedparts/items/42/image\n```", + "summary": "Upload (or replace) the proof-of-cover document for a warranty.", + "description": "Upload (or replace) the proof-of-cover document for a warranty.\n\n**Auth:** permission:warranty.edit\n\n**Params:** path: warrantyid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/warranty/1/proof -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -15158,7 +33850,33 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "enddate": null, + "isactive": true, + "lastcheckeddate": null, + "notes": null, + "prooffilename": "Dell invoice 4471.pdf", + "proofurl": "/api/warranty/proof/warranty-1.pdf", + "provider": "manual", + "servicelevel": null, + "servicetag": "", + "startdate": null, + "status": "unknown", + "statuscolor": "#9E9E9E", + "vendor": "Dell", + "warrantyid": 1 + }, + "message": "Proof uploaded", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -15182,30 +33900,113 @@ }, "parameters": [ { - "name": "item_id", + "name": "warrantyid", "in": "path", "required": true, "schema": { "type": "integer" } } - ] + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: warrantyid (int); body: JSON (fields not yet documented)" + } + } + } + } } }, - "/api/printedparts/image/{filename}": { + "/api/warranty/{warrantyid}/refresh": { + "post": { + "tags": [ + "plugin-warranty" + ], + "summary": "UNDOCUMENTED: warranty.refresh_warranty has no docstring.", + "description": "UNDOCUMENTED: warranty.refresh_warranty has no docstring.\n\n**Auth:** permission:warranty.edit\n\n**Params:** path: warrantyid (int); body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/warranty/1/refresh -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "warrantyid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: warrantyid (int); body: JSON (fields not yet documented)" + } + } + } + } + } + }, + "/api/warranty/asset/{assetid}": { "get": { "tags": [ - "plugin-printedparts" + "plugin-warranty" + ], + "summary": "Warranties covering one asset (for the asset-detail panel).", + "description": "Warranties covering one asset (for the asset-detail panel).\n\n**Auth:** permission:warranty.view\n\n**Params:** path: assetid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/warranty/asset/1 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } ], - "summary": "Serve an uploaded item image from instance/printedpartsimages (public: fetched by tags on kiosk and lists)", - "description": "Serve an uploaded item image from instance/printedpartsimages (public: fetched by tags on kiosk and lists)\n\n**Auth:** none\n\n**Params:** path: filename (e.g. printeditem-42.jpg)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printedparts/image/printeditem-42.jpg -o clip.jpg\n```", - "security": [], "responses": { "200": { "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } } } }, @@ -15217,6 +34018,113 @@ } } }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "assetid", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/api/warranty/dashboard/expiring": { + "get": { + "tags": [ + "plugin-warranty" + ], + "summary": "Warranties running out, and ones that already have.", + "description": "Warranties running out, and ones that already have.\n\n**Auth:** permission:warranty.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/warranty/dashboard/expiring -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": [], + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/warranty/proof/{filename}": { + "get": { + "tags": [ + "plugin-warranty" + ], + "summary": "Download a proof document.", + "description": "Download a proof document.\n\n**Auth:** permission:warranty.view\n\n**Params:** path: filename (path)\n\n**Example:**\n```\ncurl http://localhost:5001/api/warranty/proof/example.png -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, "404": { "description": "No such record." } @@ -15234,4041 +34142,13 @@ ] } }, - "/api/printedparts/items/{item_id}/restock": { - "post": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Add freshly printed stock via a 'restock' ledger write (single-commit ledger + cached quantity); badge resolved...", - "description": "Add freshly printed stock via a 'restock' ledger write (single-commit ledger + cached quantity); badge resolved server-side to sso/name; 404 if item inactive\n\n**Auth:** jwt + permission:printedparts.restock\n\n**Params:** path: item_id; json body: quantity (positive int, required), badge (required, 422 BadgeError if unresolvable)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"quantity\":20,\"badge\":\"123456789\"}' http://localhost:5001/api/printedparts/items/42/restock\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "item_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: item_id; json body: quantity (positive int, required), badge (required, 422 BadgeError if unresolvable)" - } - } - } - } - } - }, - "/api/printedparts/items/{item_id}/adjust": { - "post": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Correct the count (damage, recount) via an 'adjust' ledger write; reason mandatory; rejects driving stock below zero...", - "description": "Correct the count (damage, recount) via an 'adjust' ledger write; reason mandatory; rejects driving stock below zero; fires low-stock alert on downward threshold crossing\n\n**Auth:** jwt + permission:printedparts.restock\n\n**Params:** path: item_id; json body: quantitychange (non-zero int, required), reason (required), badge (required)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"quantitychange\":-3,\"reason\":\"damaged in bin\",\"badge\":\"123456789\"}' http://localhost:5001/api/printedparts/items/42/adjust\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "item_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: item_id; json body: quantitychange (non-zero int, required), reason (required), badge (required)" - } - } - } - } - } - }, - "/api/printedparts/kiosk/item/{itemcode}": { - "get": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Item summary for a scanned bin barcode; resolves full code (itemcode or gagelabtag), 'TAG|rev' QR payloads (rev...", - "description": "Item summary for a scanned bin barcode; resolves full code (itemcode or gagelabtag), 'TAG|rev' QR payloads (rev stripped), or bare keypad digits when the numeric tail uniquely matches one active item\n\n**Auth:** none (deliberately open per decision record; kiosk cannot carry JWT)\n\n**Params:** path: itemcode (e.g. WJRP0042, 3DP0042, 42, or WJRP0042|3)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printedparts/kiosk/item/WJRP0042\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "itemcode", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/printedparts/kiosk/take": { - "post": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Kiosk checkout: decrement-only 'take' ledger write, badge-attributed, bounded by quantityonhand; records optional...", - "description": "Kiosk checkout: decrement-only 'take' ledger write, badge-attributed, bounded by quantityonhand; records optional print-file revision (explicit field or 'TAG|rev' tail); triggers low-stock alert on threshold crossing; the product's only open write\n\n**Auth:** none (deliberately open per decision record)\n\n**Params:** json body: itemcode (required, same resolution as kiosk/item), badge (required, resolved server-side, 422 on BadgeError), quantity (positive int <= on hand), revision (optional int)\n\n**Example:**\n```\ncurl -X POST -H 'Content-Type: application/json' -d '{\"itemcode\":\"WJRP0042|3\",\"badge\":\"123456789\",\"quantity\":2}' http://localhost:5001/api/printedparts/kiosk/take\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "json body: itemcode (required, same resolution as kiosk/item), badge (required, resolved server-side, 422 on BadgeError), quantity (positive int <= on hand), revision (optional int)" - } - } - } - } - } - }, - "/api/printedparts/reports/stock": { - "get": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Stock-level report for active items with low-stock flags and ledgerdelta (cache-vs-ledger reconcile; nonzero means a...", - "description": "Stock-level report for active items with low-stock flags and ledgerdelta (cache-vs-ledger reconcile; nonzero means a write bypassed the single-commit rule)\n\n**Auth:** jwt-optional (like all product reports)\n\n**Params:** query: format=csv for CSV download (printedparts-stock.csv), else JSON {columns, rows}\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printedparts/reports/stock?format=csv' -o stock.csv\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printedparts/reports/consumption": { - "get": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Take-transactions aggregated per item (takes count + quantitytaken), sorted by quantity taken descending", - "description": "Take-transactions aggregated per item (takes count + quantitytaken), sorted by quantity taken descending\n\n**Auth:** jwt-optional\n\n**Params:** query: days (default 30; 0 or negative = all time), format=csv (printedparts-consumption.csv)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printedparts/reports/consumption?days=90'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printedparts/reports/by-person": { - "get": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Take-transactions grouped by employee SSO (takes count + quantitytaken), sorted by quantity taken descending", - "description": "Take-transactions grouped by employee SSO (takes count + quantitytaken), sorted by quantity taken descending\n\n**Auth:** jwt-optional\n\n**Params:** query: days (default 30; 0 or negative = all time), format=csv (printedparts-by-person.csv)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printedparts/reports/by-person?days=30&format=csv' -o by-person.csv\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printedparts/items/{item_id}/files": { - "get": { - "tags": [ - "plugin-printedparts" - ], - "summary": "List the item's print-file revision history, newest revision first", - "description": "List the item's print-file revision history, newest revision first\n\n**Auth:** jwt + permission:printedparts.view\n\n**Params:** path: item_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOK\" http://localhost:5001/api/printedparts/items/42/files\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "item_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - }, - "post": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Upload the next print-file revision (append-only, auto-numbered max+1); allowed ext...", - "description": "Upload the next print-file revision (append-only, auto-numbered max+1); allowed ext: stl/3mf/gcode/gco/bgcode/step/stp/obj/amf; 100 MB cap; uploader recorded from JWT identity\n\n**Auth:** jwt + permission:printedparts.edit\n\n**Params:** path: item_id; multipart/form-data: file= (required), note= (optional)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOK\" -F 'file=@clip-v2.stl' -F 'note=thicker wall' http://localhost:5001/api/printedparts/items/42/files\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "item_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: item_id; multipart/form-data: file= (required), note= (optional)" - } - } - } - } - } - }, - "/api/printedparts/files/{file_id}/download": { - "get": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Download a print-file revision as an attachment under its original filename (jwt-optional so plain anchor downloads...", - "description": "Download a print-file revision as an attachment under its original filename (jwt-optional so plain anchor downloads work)\n\n**Auth:** jwt-optional\n\n**Params:** path: file_id\n\n**Example:**\n```\ncurl -OJ http://localhost:5001/api/printedparts/files/7/download\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/printedparts/files/{file_id}": { - "delete": { - "tags": [ - "plugin-printedparts" - ], - "summary": "Delete a bad print-file revision (wrong file uploaded): removes the stored file and the DB record", - "description": "Delete a bad print-file revision (wrong file uploaded): removes the stored file and the DB record\n\n**Auth:** jwt + permission:printedparts.delete\n\n**Params:** path: file_id\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOK\" http://localhost:5001/api/printedparts/files/7\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/network/types": { - "get": { - "tags": [ - "plugin-network" - ], - "summary": "List network device types, paginated", - "description": "List network device types, paginated\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active (default true; 'false' includes inactive), search (ilike on networkdevicetype)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/network/types?search=switch&per_page=50'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-network" - ], - "summary": "Create a network device type (reactivates a soft-deleted duplicate instead of 409ing)", - "description": "Create a network device type (reactivates a soft-deleted duplicate instead of 409ing)\n\n**Auth:** jwt + permission:network.create\n\n**Params:** body: networkdevicetype (required), description, icon, color\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/network/types -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"networkdevicetype\":\"Switch\",\"icon\":\"mdi-switch\",\"color\":\"#2196f3\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: networkdevicetype (required), description, icon, color" - } - } - } - } - } - }, - "/api/network/types/{type_id}": { - "get": { - "tags": [ - "plugin-network" - ], - "summary": "Get one network device type by ID", - "description": "Get one network device type by ID\n\n**Auth:** jwt-optional\n\n**Params:** path: type_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/types/3\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - }, - "put": { - "tags": [ - "plugin-network" - ], - "summary": "Update a network device type; 409 on name collision", - "description": "Update a network device type; 409 on name collision\n\n**Auth:** jwt + permission:network.edit\n\n**Params:** body: networkdevicetype, description, icon, color, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/network/types/3 -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"description\":\"Access switches\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: networkdevicetype, description, icon, color, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-network" - ], - "summary": "Hard-delete a device type; 409 if any device still references it", - "description": "Hard-delete a device type; 409 if any device still references it\n\n**Auth:** jwt + permission:network.delete\n\n**Params:** path: type_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/network/types/3 -H \"Authorization: Bearer $TOKEN\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/network": { - "get": { - "tags": [ - "plugin-network" - ], - "summary": "List network devices (Asset joined with NetworkDevice extension + primary IP), filtered/sorted/paginated", - "description": "List network devices (Asset joined with NetworkDevice extension + primary IP), filtered/sorted/paginated\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, assetnumber (exact match for idempotent import), search (assetnumber/name/serialnumber/hostname/gaugelabreference/maintenancereference), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, poe (true/false), managed (true/false), sort (hostname|assetnumber|name), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/network?typeid=2&poe=true&sort=hostname&dir=asc'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-network" - ], - "summary": "Create a network device (creates core Asset + NetworkDevice extension; upserts primary-IP Communication; audit-logged...", - "description": "Create a network device (creates core Asset + NetworkDevice extension; upserts primary-IP Communication; audit-logged; honors X-Import-Mode legacy timestamps)\n\n**Auth:** jwt + permission:network.create\n\n**Params:** body: assetnumber (required); name, serialnumber, gaugelabreference, maintenancereference, statusid (default 1), locationid, businessunitid, mapx, mapy, notes, networkdevicetypeid, vendorid, hostname (unique), firmwareversion, portcount, ispoe, ismanaged, rackunit, ipaddress\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/network -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"assetnumber\":\"NET-0042\",\"hostname\":\"wjf-sw-idf3-01\",\"networkdevicetypeid\":1,\"portcount\":48,\"ispoe\":true,\"ipaddress\":\"10.1.3.10\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: assetnumber (required); name, serialnumber, gaugelabreference, maintenancereference, statusid (default 1), locationid, businessunitid, mapx, mapy, notes, networkdevicetypeid, vendorid, hostname (unique), firmwareversion, portcount, ispoe, ismanaged, rackunit, ipaddress" - } - } - } - } - } - }, - "/api/network/{device_id}": { - "get": { - "tags": [ - "plugin-network" - ], - "summary": "Get one network device (asset dict + networkdevice sub-object + primary ipaddress)", - "description": "Get one network device (asset dict + networkdevice sub-object + primary ipaddress)\n\n**Auth:** jwt-optional\n\n**Params:** path: device_id (networkdeviceid)\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/17\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "device_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - }, - "put": { - "tags": [ - "plugin-network" - ], - "summary": "Update asset + network-device fields; 409 on assetnumber/hostname conflicts; change-diff audit log; upserts primary IP...", - "description": "Update asset + network-device fields; 409 on assetnumber/hostname conflicts; change-diff audit log; upserts primary IP when ipaddress present\n\n**Auth:** jwt + permission:network.edit\n\n**Params:** body: any of assetnumber, name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, mapx, mapy, notes, isactive, networkdevicetypeid, vendorid, hostname, firmwareversion, portcount, ispoe, ismanaged, rackunit, ipaddress\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/network/17 -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"firmwareversion\":\"16.12.4\",\"ipaddress\":\"10.1.3.11\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "device_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: any of assetnumber, name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, mapx, mapy, notes, isactive, networkdevicetypeid, vendorid, hostname, firmwareversion, portcount, ispoe, ismanaged, rackunit, ipaddress" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-network" - ], - "summary": "Soft-delete a network device (sets asset.isactive=false; audit-logged)", - "description": "Soft-delete a network device (sets asset.isactive=false; audit-logged)\n\n**Auth:** jwt + permission:network.delete\n\n**Params:** path: device_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/network/17 -H \"Authorization: Bearer $TOKEN\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "device_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/network/by-asset/{asset_id}": { - "get": { - "tags": [ - "plugin-network" - ], - "summary": "Look up a network device by its core assetid", - "description": "Look up a network device by its core assetid\n\n**Auth:** jwt-optional\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/by-asset/1042\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/network/by-hostname/{hostname}": { - "get": { - "tags": [ - "plugin-network" - ], - "summary": "Look up a network device by exact hostname", - "description": "Look up a network device by exact hostname\n\n**Auth:** jwt-optional\n\n**Params:** path: hostname\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/by-hostname/wjf-sw-idf3-01\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "hostname", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/network/dashboard/summary": { - "get": { - "tags": [ - "plugin-network" - ], - "summary": "Dashboard counts: total active devices, by type, by vendor, PoE vs non-PoE", - "description": "Dashboard counts: total active devices, by type, by vendor, PoE vs non-PoE\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/dashboard/summary\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/network/vlans": { - "get": { - "tags": [ - "plugin-network" - ], - "summary": "List VLANs, paginated, ordered by vlannumber", - "description": "List VLANs, paginated, ordered by vlannumber\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active (default true), search (name/description/vlannumber), type (exact vlantype)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/network/vlans?search=voice'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-network" - ], - "summary": "Create a VLAN; 409 on duplicate vlannumber; audit-logged", - "description": "Create a VLAN; 409 on duplicate vlannumber; audit-logged\n\n**Auth:** jwt + permission:network.create\n\n**Params:** body: vlannumber (required), name (required), description, vlantype\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/network/vlans -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"vlannumber\":120,\"name\":\"Shopfloor\",\"vlantype\":\"production\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: vlannumber (required), name (required), description, vlantype" - } - } - } - } - } - }, - "/api/network/vlans/{vlan_id}": { - "get": { - "tags": [ - "plugin-network" - ], - "summary": "Get one VLAN including its active subnets", - "description": "Get one VLAN including its active subnets\n\n**Auth:** jwt-optional\n\n**Params:** path: vlan_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/vlans/5\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "vlan_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - }, - "put": { - "tags": [ - "plugin-network" - ], - "summary": "Update a VLAN; 409 on vlannumber conflict; change-diff audit log", - "description": "Update a VLAN; 409 on vlannumber conflict; change-diff audit log\n\n**Auth:** jwt + permission:network.edit\n\n**Params:** body: vlannumber, name, description, vlantype, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/network/vlans/5 -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"description\":\"CNC cell VLAN\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "vlan_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: vlannumber, name, description, vlantype, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-network" - ], - "summary": "Soft-delete a VLAN; 400 if it still has active subnets; audit-logged", - "description": "Soft-delete a VLAN; 400 if it still has active subnets; audit-logged\n\n**Auth:** jwt + permission:network.delete\n\n**Params:** path: vlan_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/network/vlans/5 -H \"Authorization: Bearer $TOKEN\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "vlan_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/network/subnets": { - "get": { - "tags": [ - "plugin-network" - ], - "summary": "List subnets, paginated, ordered by cidr", - "description": "List subnets, paginated, ordered by cidr\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active (default true), search (cidr/name/description), vlanid, locationid, type (exact subnettype)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/network/subnets?vlanid=5'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-network" - ], - "summary": "Create a subnet; validates CIDR notation and vlanid existence; 409 on duplicate cidr; audit-logged", - "description": "Create a subnet; validates CIDR notation and vlanid existence; 409 on duplicate cidr; audit-logged\n\n**Auth:** jwt + permission:network.create\n\n**Params:** body: cidr (required, must contain '/'), name (required), description, gatewayip, subnetmask, networkaddress, broadcastaddress, vlanid, subnettype, locationid, dhcpenabled (default true), dhcprangestart, dhcprangeend, dns1, dns2\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/network/subnets -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"cidr\":\"10.1.3.0/24\",\"name\":\"IDF3 access\",\"vlanid\":5,\"gatewayip\":\"10.1.3.1\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: cidr (required, must contain '/'), name (required), description, gatewayip, subnetmask, networkaddress, broadcastaddress, vlanid, subnettype, locationid, dhcpenabled (default true), dhcprangestart, dhcprangeend, dns1, dns2" - } - } - } - } - } - }, - "/api/network/subnets/{subnet_id}": { - "get": { - "tags": [ - "plugin-network" - ], - "summary": "Get one subnet plus 'devices': every asset of any type (PC/printer/network) whose primary-IP Communication falls inside...", - "description": "Get one subnet plus 'devices': every asset of any type (PC/printer/network) whose primary-IP Communication falls inside the CIDR, with cross-plugin detail URLs\n\n**Auth:** jwt-optional\n\n**Params:** path: subnet_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/network/subnets/2\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "subnet_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - }, - "put": { - "tags": [ - "plugin-network" - ], - "summary": "Update a subnet; 409 on cidr conflict; change-diff audit log", - "description": "Update a subnet; 409 on cidr conflict; change-diff audit log\n\n**Auth:** jwt + permission:network.edit\n\n**Params:** body: any of cidr, name, description, gatewayip, subnetmask, networkaddress, broadcastaddress, vlanid, subnettype, locationid, dhcpenabled, dhcprangestart, dhcprangeend, dns1, dns2, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/network/subnets/2 -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"dhcpenabled\":false}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "subnet_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: any of cidr, name, description, gatewayip, subnetmask, networkaddress, broadcastaddress, vlanid, subnettype, locationid, dhcpenabled, dhcprangestart, dhcprangeend, dns1, dns2, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-network" - ], - "summary": "Soft-delete a subnet (isactive=false); audit-logged", - "description": "Soft-delete a subnet (isactive=false); audit-logged\n\n**Auth:** jwt + permission:network.delete\n\n**Params:** path: subnet_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/network/subnets/2 -H \"Authorization: Bearer $TOKEN\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "subnet_id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/usb": { - "get": { - "tags": [ - "plugin-usb" - ], - "summary": "List USB devices with checkout status (paginated); dual-mode: selfhosted tables or external cmmc_usb DB per...", - "description": "List USB devices with checkout status (paginated); dual-mode: selfhosted tables or external cmmc_usb DB per usb_directory_mode setting\n\n**Auth:** jwt-optional\n\n**Params:** query: page, per_page, status=available|checkedout|retired, search (matches device_id or device_desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/usb?status=available&search=kingston&page=1&per_page=25' -H \"Authorization: Bearer $JWT\"\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-usb" - ], - "summary": "Create a device (starts in checked-in status); 409 on duplicate device_id", - "description": "Create a device (starts in checked-in status); 409 on duplicate device_id\n\n**Auth:** jwt + permission:usb.create\n\n**Params:** body JSON: device_id (required), device_desc, device_owner (badge), locker_location\n\n**Example:**\n```\ncurl -X POST 'http://localhost:5001/api/usb' -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"device_id\":\"USB-0042\",\"device_desc\":\"Kingston 32GB\",\"device_owner\":\"212345678\",\"locker_location\":\"A3\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body JSON: device_id (required), device_desc, device_owner (badge), locker_location" - } - } - } - } - } - }, - "/api/usb/{device_id}": { - "get": { - "tags": [ - "plugin-usb" - ], - "summary": "Get one device plus its last 20 check-in/out log rows; 404 if unknown", - "description": "Get one device plus its last 20 check-in/out log rows; 404 if unknown\n\n**Auth:** jwt-optional\n\n**Params:** path: device_id\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/usb/USB-0042' -H \"Authorization: Bearer $JWT\"\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "device_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "plugin-usb" - ], - "summary": "Edit device fields (device_desc / device_owner / locker_location / status); 404 if unknown", - "description": "Edit device fields (device_desc / device_owner / locker_location / status); 404 if unknown\n\n**Auth:** jwt + permission:usb.edit\n\n**Params:** path: device_id; body JSON: any of device_desc, device_owner, locker_location, status\n\n**Example:**\n```\ncurl -X PUT 'http://localhost:5001/api/usb/USB-0042' -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"device_desc\":\"Kingston 64GB\",\"locker_location\":\"B1\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "device_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: device_id; body JSON: any of device_desc, device_owner, locker_location, status" - } - } - } - } - } - }, - "/api/usb/{device_id}/retire": { - "post": { - "tags": [ - "plugin-usb" - ], - "summary": "Retire a device (sets status to retired); 404 if unknown", - "description": "Retire a device (sets status to retired); 404 if unknown\n\n**Auth:** jwt + permission:usb.edit\n\n**Params:** path: device_id; no body\n\n**Example:**\n```\ncurl -X POST 'http://localhost:5001/api/usb/USB-0042/retire' -H \"Authorization: Bearer $JWT\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "device_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: device_id; no body" - } - } - } - } - } - }, - "/api/usb/{device_id}/checkout": { - "post": { - "tags": [ - "plugin-usb" - ], - "summary": "Check a device out to a badge (writes check-out log row, sets status checked-out, auto-creates user from HR directory)...", - "description": "Check a device out to a badge (writes check-out log row, sets status checked-out, auto-creates user from HR directory); 409 if already checked out\n\n**Auth:** jwt + permission:usb.create\n\n**Params:** path: device_id; body JSON: badge (required), locker_location (optional, also updates device)\n\n**Example:**\n```\ncurl -X POST 'http://localhost:5001/api/usb/USB-0042/checkout' -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"badge\":\"212345678\",\"locker_location\":\"A3\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "device_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: device_id; body JSON: badge (required), locker_location (optional, also updates device)" - } - } - } - } - } - }, - "/api/usb/{device_id}/checkin": { - "post": { - "tags": [ - "plugin-usb" - ], - "summary": "Check a device back in (writes check-in log row with sanitized/virus-scan flags, sets status checked-in); 400 if not...", - "description": "Check a device back in (writes check-in log row with sanitized/virus-scan flags, sets status checked-in); 400 if not currently checked out\n\n**Auth:** jwt + permission:usb.create\n\n**Params:** path: device_id; body JSON: badge (required), locker_location, sanitized (bool/1/0), scanned_viruses (bool/1/0)\n\n**Example:**\n```\ncurl -X POST 'http://localhost:5001/api/usb/USB-0042/checkin' -H \"Authorization: Bearer $JWT\" -H 'Content-Type: application/json' -d '{\"badge\":\"212345678\",\"sanitized\":true,\"scanned_viruses\":true}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "device_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: device_id; body JSON: badge (required), locker_location, sanitized (bool/1/0), scanned_viruses (bool/1/0)" - } - } - } - } - } - }, - "/api/usb/{device_id}/history": { - "get": { - "tags": [ - "plugin-usb" - ], - "summary": "Paginated check-in/out log for one device, newest first", - "description": "Paginated check-in/out log for one device, newest first\n\n**Auth:** jwt-optional\n\n**Params:** path: device_id; query: page, per_page\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/usb/USB-0042/history?page=1&per_page=50' -H \"Authorization: Bearer $JWT\"\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "device_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/usb/checkouts": { - "get": { - "tags": [ - "plugin-usb" - ], - "summary": "List check-out log rows (paginated), each with the device's current status joined in", - "description": "List check-out log rows (paginated), each with the device's current status joined in\n\n**Auth:** jwt-optional\n\n**Params:** query: page, per_page, active=true (only rows whose device is still checked out), badge (filter by badge_number)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/usb/checkouts?active=true&badge=212345678' -H \"Authorization: Bearer $JWT\"\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/usb/checkouts/active": { - "get": { - "tags": [ - "plugin-usb" - ], - "summary": "Latest check-out log row for every currently checked-out device (non-paginated list)", - "description": "Latest check-out log row for every currently checked-out device (non-paginated list)\n\n**Auth:** jwt-optional\n\n**Params:** query: badge (filter by badge_number)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/usb/checkouts/active?badge=212345678' -H \"Authorization: Bearer $JWT\"\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/usb/kiosk/device/{device_id}": { - "get": { - "tags": [ - "plugin-usb" - ], - "summary": "What the logged-out kiosk shows after a drive is scanned: description, checked-in/out status, locker, and the NAME of...", - "description": "What the logged-out kiosk shows after a drive is scanned: description, checked-in/out status, locker, and the NAME of the current holder (needed for the return-for-someone-else confirm; the holder's badge is never returned). 404 when the kiosk setting is off, so a disabled kiosk does not advertise the route\n\n**Auth:** none (kiosk; requires usb_kiosk_enabled)\n\n**Params:** path: device_id (the code on the drive label)\n\n**Example:**\n```\ncurl http://localhost:5001/api/usb/kiosk/device/USB-1\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "device_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/usb/kiosk/checkout": { - "post": { - "tags": [ - "plugin-usb" - ], - "summary": "Check a drive out at the kiosk, attributed to the scanned badge. A badge that resolves to no employee is refused with...", - "description": "Check a drive out at the kiosk, attributed to the scanned badge. A badge that resolves to no employee is refused with 422 (unlike the signed-in route, which creates the user row). 409 when the drive is already out\n\n**Auth:** none (kiosk; requires usb_kiosk_enabled)\n\n**Params:** body: device_id, badge\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/usb/kiosk/checkout -H 'Content-Type: application/json' -d '{\"device_id\":\"USB-1\",\"badge\":\"270015376\"}'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: device_id, badge" - } - } - } - } - } - }, - "/api/usb/kiosk/checkin": { - "post": { - "tags": [ - "plugin-usb" - ], - "summary": "Return a drive at the kiosk. Holder by default: a badge that does not match the current holder gets 409 with...", - "description": "Return a drive at the kiosk. Holder by default: a badge that does not match the current holder gets 409 with details.needsconfirm and the holder's name, and the caller re-posts with forsomeoneelse=true. The badge RECORDED is the one scanned, so the log shows who handed it back. Set usb_kiosk_allow_return_for_others=false to refuse a mismatch with 403 instead\n\n**Auth:** none (kiosk; requires usb_kiosk_enabled)\n\n**Params:** body: device_id, badge, forsomeoneelse (optional bool)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/usb/kiosk/checkin -H 'Content-Type: application/json' -d '{\"device_id\":\"USB-1\",\"badge\":\"270015376\"}'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: device_id, badge, forsomeoneelse (optional bool)" - } - } - } - } - } - }, - "/api/usb/kiosk/session": { - "post": { - "tags": [ - "plugin-usb" - ], - "summary": "What one badge may do at the kiosk: the caller's own drives (to return), the drives that are free (to take), and a...", - "description": "What one badge may do at the kiosk: the caller's own drives (to return), the drives that are free (to take), and a COUNT of everything else. The drives carry no barcode so the kiosk must offer a list; requiring a badge that resolves to a real person keeps that list from being anonymous, and a POST keeps the badge out of proxy access logs. 422 for an unresolvable badge\n\n**Auth:** none (kiosk; requires usb_kiosk_enabled)\n\n**Params:** body: badge\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/usb/kiosk/session -H 'Content-Type: application/json' -d '{\"badge\":\"270015376\"}'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: badge" - } - } - } - } - } - }, - "/api/usb/kiosk/others": { - "post": { - "tags": [ - "plugin-usb" - ], - "summary": "Drives checked out to OTHER people, with holder names, so somebody can return one left behind. Behind its own step...", - "description": "Drives checked out to OTHER people, with holder names, so somebody can return one left behind. Behind its own step rather than on the main kiosk screen, and 404 when usb_kiosk_allow_return_for_others is off - there is no point naming holders of drives the kiosk will not let anyone return\n\n**Auth:** none (kiosk; requires usb_kiosk_enabled)\n\n**Params:** body: badge\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/usb/kiosk/others -H 'Content-Type: application/json' -d '{\"badge\":\"270015376\"}'\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: badge" - } - } - } - } - } - }, - "/api/usb/reports/audit": { - "get": { - "tags": [ - "plugin-usb" - ], - "summary": "Removable-media audit: every check-out and check-in in the window, newest first, with who, which drive, when and which...", - "description": "Removable-media audit: every check-out and check-in in the window, newest first, with who, which drive, when and which locker - plus summary counts. Rows rather than totals, because an audit asks who had a specific drive on a specific day. Works against the external cmmc_usb log or the self-hosted tables; in self-hosted mode one stored loan expands into a take and a return so a returned drive is distinguishable from one still out\n\n**Auth:** jwt + usb.view\n\n**Params:** query: period (day|month|sixmonths|year|all, default month) or days= (0 = all time); format=csv for a download\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/usb/reports/audit?period=year' -H \"Authorization: Bearer $TOKEN\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/printers/types": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "List printer types, paginated, with active filter and name search.", - "description": "List printer types, paginated, with active filter and name search.\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active (default true; 'false' includes inactive), search\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/printers/types?search=laser&active=false'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-printers" - ], - "summary": "Create a new printer type (or reactivate an inactive duplicate).", - "description": "Create a new printer type (or reactivate an inactive duplicate).\n\n**Auth:** permission:printers.create\n\n**Params:** body: printertype (required), description, icon, color; reactivates a matching inactive type instead of 409\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"printertype\":\"Label\",\"color\":\"#00f\"}' http://localhost:5001/api/printers/types\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: printertype (required), description, icon, color; reactivates a matching inactive type instead of 409" - } - } - } - } - } - }, - "/api/printers/types/{type_id}": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Get a single printer type by ID.", - "description": "Get a single printer type by ID.\n\n**Auth:** jwt-optional\n\n**Params:** path: type_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/types/3\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "plugin-printers" - ], - "summary": "Update a printer type.", - "description": "Update a printer type.\n\n**Auth:** permission:printers.edit\n\n**Params:** body: printertype, description, icon, color, isactive (any subset); 409 on name clash\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"isactive\":false}' http://localhost:5001/api/printers/types/3\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: printertype, description, icon, color, isactive (any subset); 409 on name clash" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-printers" - ], - "summary": "Hard-delete a printer type when unused.", - "description": "Hard-delete a printer type when unused.\n\n**Auth:** permission:printers.delete\n\n**Params:** path: type_id; 409 if any printer still references the type\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/types/3\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "type_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/printers/drivers": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "List printer driver packages (named SMB/HTTP links).", - "description": "List printer driver packages (named SMB/HTTP links).\n\n**Auth:** jwt-optional\n\n**Params:** active (default true; 'false' includes inactive). Unpaginated.\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/drivers?active=false'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-printers" - ], - "summary": "Create a driver entry.", - "description": "Create a driver entry.\n\n**Auth:** permission:printers.create\n\n**Params:** body: name (required), location (required), description, modelnumberid, isactive\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"name\":\"HP UPD\",\"location\":\"\\\\\\\\fileserver\\\\drivers\\\\hpupd\"}' http://localhost:5001/api/printers/drivers\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: name (required), location (required), description, modelnumberid, isactive" - } - } - } - } - } - }, - "/api/printers/drivers/{driver_id}": { - "put": { - "tags": [ - "plugin-printers" - ], - "summary": "Update a driver entry.", - "description": "Update a driver entry.\n\n**Auth:** permission:printers.edit\n\n**Params:** body: name, location, description, isactive, modelnumberid (any subset)\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"isactive\":false}' http://localhost:5001/api/printers/drivers/5\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "driver_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: name, location, description, isactive, modelnumberid (any subset)" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-printers" - ], - "summary": "Hard-delete a driver entry.", - "description": "Hard-delete a driver entry.\n\n**Auth:** permission:printers.delete\n\n**Params:** path: driver_id\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/drivers/5\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "driver_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/printers": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "List printers (joined Asset+Printer) with filters, search, sorting, pagination; each row includes primary IP.", - "description": "List printers (joined Asset+Printer) with filters, search, sorting, pagination; each row includes primary IP.\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, assetnumber (exact-match for import idempotency), search (assetnumber/name/serial/hostname/windowsname/gaugelabreference/maintenancereference), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (hostname|assetnumber|name), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers?search=csf&typeid=2&sort=assetnumber&dir=desc'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-printers" - ], - "summary": "Create a printer (Asset + Printer extension + optional primary IP communication).", - "description": "Create a printer (Asset + Printer extension + optional primary IP communication).\n\n**Auth:** permission:printers.create\n\n**Params:** body: assetnumber (required); name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, printertypeid, vendorid, modelnumberid, hostname, windowsname, sharename, iscsf, installpath, pin, iscolor, isduplex, isnetwork, mapx, mapy, notes, ipaddress (creates primary IP comm); X-Import-Mode header preserves legacy timestamps\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"assetnumber\":\"PR-0042\",\"hostname\":\"wjprn042\",\"ipaddress\":\"10.1.2.42\",\"printertypeid\":1}' http://localhost:5001/api/printers\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: assetnumber (required); name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, printertypeid, vendorid, modelnumberid, hostname, windowsname, sharename, iscsf, installpath, pin, iscolor, isduplex, isnetwork, mapx, mapy, notes, ipaddress (creates primary IP comm); X-Import-Mode header preserves legacy timestamps" - } - } - } - } - } - }, - "/api/printers/install-list": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Flat unpaginated list of active network printers (with map positions) for the signed printer-installer EXE; replaces...", - "description": "Flat unpaginated list of active network printers (with map positions) for the signed printer-installer EXE; replaces classic apiprinters.asp.\n\n**Auth:** jwt-optional\n\n**Params:** format=text for pipe-delimited installer variant (printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy); default JSON\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/install-list?format=text'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printers/install-batch": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Generate and download a Windows .bat that installs selected printers (HP/Xerox via universal PrinterInstaller.exe...", - "description": "Generate and download a Windows .bat that installs selected printers (HP/Xerox via universal PrinterInstaller.exe, per-printer .exe /SILENT, or flags manual).\n\n**Auth:** jwt-optional\n\n**Params:** printerids (required, comma-separated printer IDs)\n\n**Example:**\n```\ncurl -OJ 'http://localhost:5001/api/printers/install-batch?printerids=1,2,3'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printers/pc-default": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Look up a PC's default printer via the defaultprinter asset relationship (parity with classic apipcdefaultprinter.asp)...", - "description": "Look up a PC's default printer via the defaultprinter asset relationship (parity with classic apipcdefaultprinter.asp); used by installer to preselect map hotspot.\n\n**Auth:** jwt-optional\n\n**Params:** machine (PC asset number), format=text for pipe-delimited variant; returns {} / empty body if no default set\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/pc-default?machine=0421&format=text'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printers/for-host/{hostname}": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Desired printer set for one PC: its own usesprinter/defaultprinter relationships, or, when it has none, the ones...", - "description": "Desired printer set for one PC: its own usesprinter/defaultprinter relationships, or, when it has none, the ones inherited through its controls edge to the machine it drives. Each entry carries what a client needs to install the queue (queue name, hostname, ipaddress, port, drivername, driverlocation, isdefault, inherited). A known host with nothing assigned returns an empty list and a null default (the client's designed no-op); an unknown hostname, or a site without the computers plugin, is a 404.\n\n**Auth:** jwt-optional\n\n**Params:** path: hostname (matched case-insensitively, as sent by the client's COMPUTERNAME); no query parameters\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/for-host/workstation01\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "hostname", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/printers/observed/{hostname}": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "The observed half of the loop and the mirror of /for-host: what this bay last REPORTED it has (POST...", - "description": "The observed half of the loop and the mirror of /for-host: what this bay last REPORTED it has (POST /api/collector/printers), each queue judged against the resolved assignment it SHOULD have. Read-only - nothing here changes an assignment, however wrong the bay looks. Every queue is classified matching / drifted (same printer, different port address or driver) / extra (a printer ShopDB knows, installed unassigned) / missing (assigned, not reported) / unknown (matches no printer, never guessed), each entry carrying both sides plus driftfields; the response adds observedat, the assignment source (own rows or the machine this PC controls), a per-classification summary, and a seedcandidate preview of what a seed would write. 404 only when the hostname has neither a report nor a computer record; a known PC that never reported returns an empty queue list.\n\n**Auth:** permission:printers.view\n\n**Params:** path: hostname (case-insensitive, short name or FQDN, as the reporting client sent it); no query parameters\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/observed/workstation01\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "hostname", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/printers/observed/for-asset/{asset_id}": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "The same observed-against-assigned comparison as /api/printers/observed/, reached from an asset page, with...", - "description": "The same observed-against-assigned comparison as /api/printers/observed/, reached from an asset page, with one block per reporting host. Assigned state lives on the MACHINE while observed state is reported by the PCs, so a machine answers with a block for each active PC that controls it (its own hostname first when the asset is itself a PC); blocks rather than one merged list because two PCs legitimately share one machine and the actionable part of drift is which box to walk to. Each block carries the same fields as the by-hostname route: queues classified matching / drifted / extra / missing / unknown, observedat, the assignment source, a summary, and a seedcandidate preview. Read-only. An asset nothing has reported for is an empty hosts list and a 200, not a 404.\n\n**Auth:** permission:printers.view\n\n**Params:** path: asset_id (a machine or a PC); no query parameters\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/observed/for-asset/312\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/printers/assignments/for-asset/{asset_id}": { - "put": { - "tags": [ - "plugin-printers" - ], - "summary": "Reconcile an asset's whole printer assignment in one write: soft-deletes usesprinter rows that went away, reactivates...", - "description": "Reconcile an asset's whole printer assignment in one write: soft-deletes usesprinter rows that went away, reactivates previously removed ones, creates new ones, and replaces the single defaultprinter row (exactly one per asset, optional, always one of the assigned printers). Removing an assignment has no side effects and never uninstalls anything on a client.\n\n**Auth:** permission:printers.edit\n\n**Params:** path: asset_id (the machine or PC asset the printers belong to); body: printerassetids (list of printer asset IDs, required, empty list clears the assignment), defaultprinterassetid (int or null; 400 unless it is one of printerassetids)\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"printerassetids\":[204,205],\"defaultprinterassetid\":204}' http://localhost:5001/api/printers/assignments/for-asset/312\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: asset_id (the machine or PC asset the printers belong to); body: printerassetids (list of printer asset IDs, required, empty list clears the assignment), defaultprinterassetid (int or null; 400 unless it is one of printerassetids)" - } - } - } - } - }, - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "This asset's OWN printer assignment - printerassetids and defaultprinterassetid - deliberately WITHOUT inheritance. The...", - "description": "This asset's OWN printer assignment - printerassetids and defaultprinterassetid - deliberately WITHOUT inheritance. The editor has to show what this asset's own rows say, or a machine's printers would appear ticked on the PC that inherits them and unticking one would silently create an override. /printers/for-host is the resolved view the client uses; this is the editable one\n\n**Auth:** optional jwt\n\n**Params:** asset_id in path\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/assignments/for-asset/14\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/printers/assignments/seed-from-observed/{asset_id}": { - "post": { - "tags": [ - "plugin-printers" - ], - "summary": "The ONE path from observed state to assigned state, and a person has to ask for it: no collector, cycle or background...", - "description": "The ONE path from observed state to assigned state, and a person has to ask for it: no collector, cycle or background job reaches this route, so a bay that installed the wrong printer can never make itself right by reporting it. Writes the matched observed queues through the same reconcile as PUT /printers/assignments/for-asset (usesprinter rows plus the single defaultprinter, set only when the observed default itself matched a printer). Returns what was written, the skipped queues, and warnings - including one when the target controls another asset, because its own rows then shadow that machine's assignment for good. Normally posted against the MACHINE so the assignment survives a reimage.\n\n**Auth:** permission:printers.edit\n\n**Params:** path: asset_id (the machine or PC to write the assignment on); body, all optional: hostname (which report to seed from; omitted, the asset's own hostname or its single controlling PC), allowunmatched (bool, seed the matched queues anyway when some queue matches no printer). 404 when no named or derived host has reported; 409 when several controlling PCs have reported, or when any queue matches no printer and allowunmatched is unset; 400 when nothing matched, since writing the empty set would silently unassign the asset. Nothing is written on any of those.\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"hostname\":\"workstation01\"}' http://localhost:5001/api/printers/assignments/seed-from-observed/312\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: asset_id (the machine or PC to write the assignment on); body, all optional: hostname (which report to seed from; omitted, the asset's own hostname or its single controlling PC), allowunmatched (bool, seed the matched queues anyway when some queue matches no printer). 404 when no named or derived host has reported; 409 when several controlling PCs have reported, or when any queue matches no printer and allowunmatched is unset; 400 when nothing matched, since writing the empty set would silently unassign the asset. Nothing is written on any of those." - } - } - } - } - } - }, - "/api/printers/{printer_id}": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Get one printer with full asset details, communications, and active drivers matching its model.", - "description": "Get one printer with full asset details, communications, and active drivers matching its model.\n\n**Auth:** jwt-optional\n\n**Params:** path: printer_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/17\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "printer_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "plugin-printers" - ], - "summary": "Update printer: asset, extension, and primary IP in one call.", - "description": "Update printer: asset, extension, and primary IP in one call.\n\n**Auth:** permission:printers.edit\n\n**Params:** body: any subset of asset fields (assetnumber, name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, mapx, mapy, notes, isactive) + printer fields (printertypeid, vendorid, modelnumberid, hostname, windowsname, sharename, iscsf, installpath, pin, iscolor, isduplex, isnetwork) + ipaddress (upserts/clears primary IP comm); 409 on assetnumber clash\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"ipaddress\":\"10.1.2.99\",\"iscolor\":true}' http://localhost:5001/api/printers/17\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "printer_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: any subset of asset fields (assetnumber, name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, mapx, mapy, notes, isactive) + printer fields (printertypeid, vendorid, modelnumberid, hostname, windowsname, sharename, iscsf, installpath, pin, iscolor, isduplex, isnetwork) + ipaddress (upserts/clears primary IP comm); 409 on assetnumber clash" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-printers" - ], - "summary": "Soft-delete a printer (sets the underlying asset isactive=false).", - "description": "Soft-delete a printer (sets the underlying asset isactive=false).\n\n**Auth:** permission:printers.delete\n\n**Params:** path: printer_id\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/17\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "printer_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/printers/by-asset/{asset_id}": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Get printer data keyed by core asset ID.", - "description": "Get printer data keyed by core asset ID.\n\n**Auth:** jwt-optional\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/by-asset/204\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/printers/{printer_id}/supplies": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Real-time supply levels from Zabbix for one printer, annotated with status/color/part numbers; fails soft (empty...", - "description": "Real-time supply levels from Zabbix for one printer, annotated with status/color/part numbers; fails soft (empty supplies, pingstatus -1) when Zabbix is off/unreachable.\n\n**Auth:** jwt-optional\n\n**Params:** path: printer_id; needs an IP communication on the printer\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/17/supplies\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "printer_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/printers/lowsupplies": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Fleet-wide report of printers with low/critical supply levels from Zabbix, with summary counts.", - "description": "Fleet-wide report of printers with low/critical supply levels from Zabbix, with summary counts.\n\n**Auth:** jwt-optional\n\n**Params:** none (data cached 5 min)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/lowsupplies\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printers/lookup": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).", - "description": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).\n\n**Auth:** jwt-optional\n\n**Params:** ip or fqdn (one required; value matched against communication ipaddress)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/lookup?ip=10.1.2.42'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printers/supplies/refresh": { - "post": { - "tags": [ - "plugin-printers" - ], - "summary": "Clear the cached Zabbix supply data so next reads pull fresh values (toner-report Refresh button).", - "description": "Clear the cached Zabbix supply data so next reads pull fresh values (toner-report Refresh button).\n\n**Auth:** permission:printers.create\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/supplies/refresh\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none" - } - } - } - } - } - }, - "/api/printers/dashboard/summary": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Dashboard summary: total active printers, counts by type and vendor, low/critical supply counts (Zabbix, if reachable).", - "description": "Dashboard summary: total active printers, counts by type and vendor, low/critical supply counts (Zabbix, if reachable).\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/dashboard/summary\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printers/supplies/meta": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Allowed enum values for supplytype, color, and capacitytier (for the UI forms).", - "description": "Allowed enum values for supplytype, color, and capacitytier (for the UI forms).\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/supplies/meta\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printers/models": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "List printer models with supply counts for the supply-management picker (restricted to models attached to printers or...", - "description": "List printer models with supply counts for the supply-management picker (restricted to models attached to printers or already carrying supplies).\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, search (modelnumber), vendorid|vendor_id, withsupplies=true (only models that already have supplies)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/models?withsupplies=true&search=M404'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printers/models/{modelnumberid}/supplies": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "List all active supplies (toner/drum/waste part numbers) mapped to a model.", - "description": "List all active supplies (toner/drum/waste part numbers) mapped to a model.\n\n**Auth:** jwt-optional\n\n**Params:** path: modelnumberid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/models/12/supplies\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "modelnumberid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "post": { - "tags": [ - "plugin-printers" - ], - "summary": "Add a supply part-number mapping to a model.", - "description": "Add a supply part-number mapping to a model.\n\n**Auth:** permission:printers.create\n\n**Params:** body: partnumber (required), supplytype (default toner), color (default none), capacitytier (default standard), marketingname, pageyield, notes; 409 if part already mapped to model\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"partnumber\":\"CF259A\",\"supplytype\":\"toner\",\"color\":\"black\"}' http://localhost:5001/api/printers/models/12/supplies\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "modelnumberid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: partnumber (required), supplytype (default toner), color (default none), capacitytier (default standard), marketingname, pageyield, notes; 409 if part already mapped to model" - } - } - } - } - } - }, - "/api/printers/supplies/{modelsupplyid}": { - "put": { - "tags": [ - "plugin-printers" - ], - "summary": "Update a model supply mapping.", - "description": "Update a model supply mapping.\n\n**Auth:** permission:printers.edit\n\n**Params:** body: any subset of supplytype, color, capacitytier, partnumber, marketingname, pageyield, notes; enum-validated, 409 on partnumber clash within model\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"capacitytier\":\"high\"}' http://localhost:5001/api/printers/supplies/44\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "modelsupplyid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: any subset of supplytype, color, capacitytier, partnumber, marketingname, pageyield, notes; enum-validated, 409 on partnumber clash within model" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-printers" - ], - "summary": "Hard-delete a model supply mapping.", - "description": "Hard-delete a model supply mapping.\n\n**Auth:** permission:printers.delete\n\n**Params:** path: modelsupplyid\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/supplies/44\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "modelsupplyid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/printers/supplies/forecast": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "One row per CARTRIDGE: days-to-empty from Zabbix history (hourly trends over a long window), soonest first, banded...", - "description": "One row per CARTRIDGE: days-to-empty from Zabbix history (hourly trends over a long window), soonest first, banded empty/soon/month/later, each with its part numbers and replacement count. Also returns orderlist - what to buy within horizondays, grouped by part number with a quantity. Cartridges with no honest estimate come back separately with the reason. available=false when Zabbix is unreachable\n\n**Auth:** jwt-optional\n\n**Params:** days (1-365, default 90)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/supplies/forecast?days=90'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/printers/dashboard/supplies": { - "get": { - "tags": [ - "plugin-printers" - ], - "summary": "Dashboard card: printers needing a cartridge, one row per printer. Reuses the low-supplies query and its five-minute...", - "description": "Dashboard card: printers needing a cartridge, one row per printer. Reuses the low-supplies query and its five-minute cache, so the card costs the same as the report\n\n**Auth:** permission:printers.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/dashboard/supplies\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/warranty": { + "/api/warranty/report": { "get": { "tags": [ "plugin-warranty" ], - "summary": "List warranties with linked-asset summaries and derived status (batch asset fetch, ordered by enddate with null last).", - "description": "List warranties with linked-asset summaries and derived status (batch asset fetch, ordered by enddate with null last).\n\n**Auth:** jwt-optional\n\n**Params:** query: active (default true; 'false' includes inactive), servicetag (exact match, for idempotent import), vendor (exact match), assetid (int, filter to warranties covering that asset), status (post-filter on derived status: expired|expiring|active|unknown)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/warranty?status=expiring&assetid=42'\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "plugin-warranty" - ], - "summary": "Create a warranty (vendor required) and optionally link it to assets; returns 201.", - "description": "Create a warranty (vendor required) and optionally link it to assets; returns 201.\n\n**Auth:** jwt + permission:warranty.create\n\n**Params:** JSON body: vendor (required), servicetag, provider (default 'manual', lowercased), servicelevel, startdate (YYYY-MM-DD), enddate (YYYY-MM-DD), notes, assetids (list of asset ids to link)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"vendor\":\"Dell\",\"servicetag\":\"ABC1234\",\"enddate\":\"2027-06-30\",\"assetids\":[42]}' http://localhost:5001/api/warranty\n```", + "summary": "Counts + lists bucketed by derived status.", + "description": "Counts + lists bucketed by derived status.\n\n**Auth:** permission:warranty.view\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/warranty/report -H \"Authorization: Bearer $TOK\"\n```", "security": [ { "bearerAuth": [] @@ -19279,7 +34159,30 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "buckets": { + "active": [], + "expired": [], + "expiring": [], + "unknown": [] + }, + "counts": { + "active": 0, + "expired": 0, + "expiring": 0, + "unknown": 0 + } + }, + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -19297,283 +34200,6 @@ "403": { "description": "Authenticated, but not permitted." } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "JSON body: vendor (required), servicetag, provider (default 'manual', lowercased), servicelevel, startdate (YYYY-MM-DD), enddate (YYYY-MM-DD), notes, assetids (list of asset ids to link)" - } - } - } - } - } - }, - "/api/warranty/asset/{assetid}": { - "get": { - "tags": [ - "plugin-warranty" - ], - "summary": "Active warranties covering one asset, for the asset-detail panel.", - "description": "Active warranties covering one asset, for the asset-detail panel.\n\n**Auth:** jwt-optional\n\n**Params:** path: assetid\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/warranty/asset/42\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "assetid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/warranty/{warrantyid}": { - "get": { - "tags": [ - "plugin-warranty" - ], - "summary": "Fetch a single warranty by id with asset summaries; 404 if missing.", - "description": "Fetch a single warranty by id with asset summaries; 404 if missing.\n\n**Auth:** jwt-optional\n\n**Params:** path: warrantyid\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/warranty/7\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "warrantyid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - }, - "put": { - "tags": [ - "plugin-warranty" - ], - "summary": "Partial update of any warranty field (only keys present in body change), including isactive and replacing asset links...", - "description": "Partial update of any warranty field (only keys present in body change), including isactive and replacing asset links via assetids.\n\n**Auth:** jwt + permission:warranty.edit\n\n**Params:** path: warrantyid; JSON body (all optional): vendor, servicetag, provider, servicelevel, startdate, enddate, notes, isactive (bool), assetids (full replacement list)\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"enddate\":\"2028-01-15\",\"assetids\":[42,43]}' http://localhost:5001/api/warranty/7\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "warrantyid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: warrantyid; JSON body (all optional): vendor, servicetag, provider, servicelevel, startdate, enddate, notes, isactive (bool), assetids (full replacement list)" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-warranty" - ], - "summary": "Hard-delete a warranty (and its asset links); 404 if missing.", - "description": "Hard-delete a warranty (and its asset links); 404 if missing.\n\n**Auth:** jwt + permission:warranty.delete\n\n**Params:** path: warrantyid\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/warranty/7\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "warrantyid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } - }, - "/api/warranty/{warrantyid}/refresh": { - "post": { - "tags": [ - "plugin-warranty" - ], - "summary": "Re-query the warranty's provider (dell etc.) by service tag and update servicelevel/startdate/enddate...", - "description": "Re-query the warranty's provider (dell etc.) by service tag and update servicelevel/startdate/enddate + lastcheckeddate; 400 if provider is manual or not configured.\n\n**Auth:** jwt + permission:warranty.edit\n\n**Params:** path: warrantyid; no body\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/warranty/7/refresh\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "warrantyid", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: warrantyid; no body" - } - } - } } } }, @@ -19582,8 +34208,8 @@ "tags": [ "plugin-warranty" ], - "summary": "Bulk Dell sync: look up serials of active assets lacking a dated warranty via Dell bulk_lookup, creating or updating...", - "description": "Bulk Dell sync: look up serials of active assets lacking a dated warranty via Dell bulk_lookup, creating or updating (canonicalizing to provider dell) warranties; returns candidates/tags/matched/created/updated counts.\n\n**Auth:** jwt + permission:warranty.edit\n\n**Params:** query: all (default false; 'true' re-checks assets that already have a dated warranty); no body\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" 'http://localhost:5001/api/warranty/sync/dell?all=true'\n```", + "summary": "Look up every asset serial (= Dell service tag) that has no warranty yet and create warranties for the ones Dell...", + "description": "Look up every asset serial (= Dell service tag) that has no warranty yet and create warranties for the ones Dell recognizes.\n\n**Auth:** permission:warranty.edit\n\n**Params:** body: JSON (fields not yet documented)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/warranty/sync/dell -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{}'\n```", "security": [ { "bearerAuth": [] @@ -19594,7 +34220,23 @@ "description": "Success. Body is the success_response envelope: {status, data, meta}.", "content": { "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope" + }, + "example": { + "data": { + "candidates": 0, + "created": 0, + "matched": 0, + "updated": 0 + }, + "message": "No PCs need a warranty lookup.", + "meta": { + "requestid": "00000000", + "timestamp": "2026-01-01T00:00:00.000000Z" + }, + "status": "success" + } } } }, @@ -19613,99 +34255,14 @@ "description": "Authenticated, but not permitted." } }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "query: all (default false; 'true' re-checks assets that already have a dated warranty); no body" - } - } - } - } - } - }, - "/api/warranty/report": { - "get": { - "tags": [ - "plugin-warranty" - ], - "summary": "Report for the Reports hub: active warranties bucketed by derived status (expired/expiring/active/unknown) with...", - "description": "Report for the Reports hub: active warranties bucketed by derived status (expired/expiring/active/unknown) with per-bucket counts and full lists.\n\n**Auth:** jwt-optional\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/warranty/report\n```", - "security": [ - {}, - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/warranty/{warrantyid}/proof": { - "post": { - "tags": [ - "plugin-warranty" - ], - "summary": "Upload or replace the proof-of-cover document (invoice, certificate)", - "description": "Upload or replace the proof-of-cover document (invoice, certificate)\n\n**Auth:** jwt + warranty.edit\n\n**Params:** multipart/form-data: file= (pdf/png/jpg/gif/webp/tif/msg/eml/doc/docx/xls/xlsx), max 25MB\n\n**Example:**\n```\ncurl -X POST -H 'Authorization: Bearer ' -F file=@invoice.pdf http://localhost:5001/api/warranty/12/proof\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, "parameters": [ { - "name": "warrantyid", - "in": "path", - "required": true, + "name": "all", + "in": "query", + "required": false, "schema": { - "type": "string" + "type": "string", + "default": "false" } } ], @@ -19716,4680 +34273,9 @@ "schema": { "type": "object", "additionalProperties": true, - "description": "multipart/form-data: file= (pdf/png/jpg/gif/webp/tif/msg/eml/doc/docx/xls/xlsx), max 25MB" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-warranty" - ], - "summary": "Remove a proof document and clear both columns", - "description": "Remove a proof document and clear both columns\n\n**Auth:** jwt + warranty.edit\n\n**Params:** none\n\n**Example:**\n```\ncurl -X DELETE -H 'Authorization: Bearer ' http://localhost:5001/api/warranty/12/proof\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "warrantyid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/warranty/proof/{filename}": { - "get": { - "tags": [ - "plugin-warranty" - ], - "summary": "Download a proof document under the vendor's original filename. Authenticated: carries pricing and a service tag", - "description": "Download a proof document under the vendor's original filename. Authenticated: carries pricing and a service tag\n\n**Auth:** jwt + warranty.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -OJ -H 'Authorization: Bearer ' http://localhost:5001/api/warranty/proof/warranty-12.pdf\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "filename", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/warranty/dashboard/expiring": { - "get": { - "tags": [ - "plugin-warranty" - ], - "summary": "Dashboard card: warranties running out and ones that already have, identified the way the floor identifies them - by...", - "description": "Dashboard card: warranties running out and ones that already have, identified the way the floor identifies them - by the PC's hostname and the machine it drives\n\n**Auth:** permission:warranty.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/warranty/dashboard/expiring\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/backups/kinds": { - "get": { - "tags": [ - "plugin-backups" - ], - "summary": "Registered backup kinds (NTLARS/DNC, part marker, UDC, CMM ...) with each kind's storage backend and download formats...", - "description": "Registered backup kinds (NTLARS/DNC, part marker, UDC, CMM ...) with each kind's storage backend and download formats, for UI that enumerates them rather than hardcoding the list\n\n**Auth:** permission:backups.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/backups/kinds\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/backups/asset/{assetid}": { - "get": { - "tags": [ - "plugin-backups" - ], - "summary": "Revision history for one asset, newest first. An unchanged configuration writes no revision, so the newest row may be...", - "description": "Revision history for one asset, newest first. An unchanged configuration writes no revision, so the newest row may be old and still current - each row carries both when it was captured and when it was last confirmed\n\n**Auth:** permission:backups.view\n\n**Params:** assetid in path; optional ?kind= to filter to one backup kind\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/backups/asset/42\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "assetid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/backups/asset/{assetid}/info": { - "get": { - "tags": [ - "plugin-backups" - ], - "summary": "A kind's at-a-glance card built from its latest revision, for the asset detail page", - "description": "A kind's at-a-glance card built from its latest revision, for the asset detail page\n\n**Auth:** permission:backups.view\n\n**Params:** assetid in path; ?kind= selects the kind whose card is wanted\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" \"http://localhost:5001/api/backups/asset/42/info?kind=ntlars\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "assetid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/backups/revisions/{backuprevisionid}": { - "get": { - "tags": [ - "plugin-backups" - ], - "summary": "One revision, including its parsed payload where the kind stores one (opaque kinds return metadata only)", - "description": "One revision, including its parsed payload where the kind stores one (opaque kinds return metadata only)\n\n**Auth:** permission:backups.view\n\n**Params:** backuprevisionid in path\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/backups/revisions/1188\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "backuprevisionid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/backups/revisions/{backuprevisionid}/diff": { - "get": { - "tags": [ - "plugin-backups" - ], - "summary": "Diff two revisions of the same asset and kind, key by key, which is what answers \"what changed on this machine\"", - "description": "Diff two revisions of the same asset and kind, key by key, which is what answers \"what changed on this machine\"\n\n**Auth:** permission:backups.view\n\n**Params:** backuprevisionid in path; ?against= to compare a specific pair, otherwise the previous revision of the same asset and kind\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/backups/revisions/1188/diff\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "backuprevisionid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/backups/revisions/{backuprevisionid}/download": { - "get": { - "tags": [ - "plugin-backups" - ], - "summary": "Render a revision back to its native file format - for NTLARS, a .reg in either registry dialect, so it can be loaded...", - "description": "Render a revision back to its native file format - for NTLARS, a .reg in either registry dialect, so it can be loaded straight back onto a controller PC. Returns the file, NOT the success envelope\n\n**Auth:** permission:backups.download\n\n**Params:** backuprevisionid in path; ?format= one of the kind's formats (see /api/backups/kinds)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -OJ \"http://localhost:5001/api/backups/revisions/1188/download?format=ntlars\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "backuprevisionid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/backups/dashboard/stale": { - "get": { - "tags": [ - "plugin-backups" - ], - "summary": "Dashboard card (contract 0.19.0): chains whose backup has stopped running, judged on when a config was last CONFIRMED...", - "description": "Dashboard card (contract 0.19.0): chains whose backup has stopped running, judged on when a config was last CONFIRMED rather than last changed\n\n**Auth:** permission:backups.view\n\n**Params:** none; the threshold is the backups_staledays setting, 0 disables the card\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/backups/dashboard/stale\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/tools/secrets": { - "post": { - "tags": [ - "plugin-tools" - ], - "summary": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never...", - "description": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never reaches the server, so what is held here is ciphertext nobody on this side can read - which is why SIZE is the only limit the server can enforce, and why this is for passwords and short text rather than documents\n\n**Auth:** permission:tools.secrets\n\n**Params:** body: ciphertext (base64, max 4096 chars), iv, expiryhours (1-168, default 24), views (1-5, default 1)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{\"ciphertext\":\"...\",\"iv\":\"...\"}' http://localhost:5001/api/tools/secrets\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: ciphertext (base64, max 4096 chars), iv, expiryhours (1-168, default 24), views (1-5, default 1)" - } - } - } - } - } - }, - "/api/tools/secrets/{token}": { - "post": { - "tags": [ - "plugin-tools" - ], - "summary": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather...", - "description": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather than GET because chat and mail clients prefetch GET urls to build previews, and a preview must not consume a one-view secret. Missing, expired and spent answer identically so a guessed token learns nothing\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/tools/secrets/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "token", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none" - } - } - } - } - }, - "get": { - "tags": [ - "plugin-tools" - ], - "summary": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will...", - "description": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will destroy it\" instead of consuming the only view to find out, and gives link scanners something harmless to fetch\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/tools/secrets/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "token", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/docs": { - "get": { - "tags": [ - "core-docs" - ], - "summary": "Redoc page rendering the generated spec. Public, and asset URLs are built with url_for so it works under any mount path", - "description": "Redoc page rendering the generated spec. Public, and asset URLs are built with url_for so it works under any mount path\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/docs/\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/docs/openapi.json": { - "get": { - "tags": [ - "core-docs" - ], - "summary": "The generated OpenAPI 3.1 spec - the machine-readable description of this API, and what MCP and other clients build...", - "description": "The generated OpenAPI 3.1 spec - the machine-readable description of this API, and what MCP and other clients build from. Returns the spec, NOT the success envelope\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/docs/openapi.json\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/docs/llms.txt": { - "get": { - "tags": [ - "core-docs" - ], - "summary": "Concise orientation guide for an LLM or a new integrator: auth schemes, the response envelope, and the common recipes...", - "description": "Concise orientation guide for an LLM or a new integrator: auth schemes, the response envelope, and the common recipes. Returns text/plain\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/docs/llms.txt\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/docs/redoc.standalone.js": { - "get": { - "tags": [ - "core-docs" - ], - "summary": "Vendored Redoc bundle, served locally so the documentation page works on an air-gapped server", - "description": "Vendored Redoc bundle, served locally so the documentation page works on an air-gapped server\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl -I http://localhost:5001/api/docs/redoc.standalone.js\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - }, - "/api/maplevels": { - "get": { - "tags": [ - "core-map" - ], - "summary": "Every building with its levels in display order, each carrying blueprint paths, native pixel size and marker count...", - "description": "Every building with its levels in display order, each carrying blueprint paths, native pixel size and marker count, plus defaultlevelid. PUBLIC: the printer installer map draws a blueprint before anyone logs in\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/maplevels\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - }, - "post": { - "tags": [ - "core-map" - ], - "summary": "Create a level. Name and sort order are separate because levels are not reliably numbered (basement, mezzanine, roof)...", - "description": "Create a level. Name and sort order are separate because levels are not reliably numbered (basement, mezzanine, roof), and gaps let one be inserted later without renumbering\n\n**Auth:** jwt + role:admin\n\n**Params:** body: buildingid and levelname (required), sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X POST http://localhost:5001/api/maplevels\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: buildingid and levelname (required), sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault" - } - } - } - } - } - }, - "/api/maplevels/{levelid}": { - "get": { - "tags": [ - "core-map" - ], - "summary": "One level: name, building, blueprints and native size. That size is what mapx/mapy on this level are pixels of (ADR-017)", - "description": "One level: name, building, blueprints and native size. That size is what mapx/mapy on this level are pixels of (ADR-017)\n\n**Auth:** none\n\n**Params:** levelid in path\n\n**Example:**\n```\ncurl http://localhost:5001/api/maplevels/2\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "levelid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-map" - ], - "summary": "Update a level. Changing mapwidth/mapheight returns a warning naming what it affects: the dimensions are the coordinate...", - "description": "Update a level. Changing mapwidth/mapheight returns a warning naming what it affects: the dimensions are the coordinate space every marker is expressed in, so a resize moves them all relative to the drawing - use a landmark transform instead\n\n**Auth:** jwt + role:admin\n\n**Params:** levelid in path; body: levelname, sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, isactive\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X PATCH http://localhost:5001/api/maplevels/1\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "levelid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "levelid in path; body: levelname, sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, isactive" - } - } - } - } - }, - "patch": { - "tags": [ - "core-map" - ], - "summary": "Update a level. Changing mapwidth/mapheight returns a warning naming what it affects: the dimensions are the coordinate...", - "description": "Update a level. Changing mapwidth/mapheight returns a warning naming what it affects: the dimensions are the coordinate space every marker is expressed in, so a resize moves them all relative to the drawing - use a landmark transform instead\n\n**Auth:** jwt + role:admin\n\n**Params:** levelid in path; body: levelname, sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, isactive\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X PATCH http://localhost:5001/api/maplevels/1\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "levelid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "levelid in path; body: levelname, sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "core-map" - ], - "summary": "Deactivate a level. Refused with 409 while assets are placed on it, and refused for the default level: deleting a...", - "description": "Deactivate a level. Refused with 409 while assets are placed on it, and refused for the default level: deleting a drawing out from under a marker leaves a position in a coordinate space that no longer exists\n\n**Auth:** jwt + role:admin\n\n**Params:** levelid in path\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X DELETE http://localhost:5001/api/maplevels/3\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "levelid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/maplevels/{levelid}/blueprint/{filename}": { - "get": { - "tags": [ - "core-map" - ], - "summary": "Serve a level's blueprint image, with sandbox headers so an SVG floor plan cannot execute as script", - "description": "Serve a level's blueprint image, with sandbox headers so an SVG floor plan cannot execute as script\n\n**Auth:** none\n\n**Params:** levelid and filename in path\n\n**Example:**\n```\ncurl -I http://localhost:5001/api/maplevels/2/blueprint/level-2-light.png\n```", - "security": [], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "levelid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "filename", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/maplevels/buildings": { - "post": { - "tags": [ - "core-map" - ], - "summary": "Create a building. An asset references the level, never the building, so the two cannot disagree", - "description": "Create a building. An asset references the level, never the building, so the two cannot disagree\n\n**Auth:** jwt + role:admin\n\n**Params:** body: buildingname (required), sortorder\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"buildingname\":\"Annex\"}' -X POST http://localhost:5001/api/maplevels/buildings\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: buildingname (required), sortorder" - } - } - } - } - } - }, - "/api/maplevels/buildings/{buildingid}": { - "put": { - "tags": [ - "core-map" - ], - "summary": "Rename or reorder a building. Levels move with it and nothing repositions", - "description": "Rename or reorder a building. Levels move with it and nothing repositions\n\n**Auth:** jwt + role:admin\n\n**Params:** buildingid in path; body: buildingname, sortorder, isactive\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X PATCH http://localhost:5001/api/maplevels/buildings/1\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "buildingid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "buildingid in path; body: buildingname, sortorder, isactive" - } - } - } - } - }, - "patch": { - "tags": [ - "core-map" - ], - "summary": "Rename or reorder a building. Levels move with it and nothing repositions", - "description": "Rename or reorder a building. Levels move with it and nothing repositions\n\n**Auth:** jwt + role:admin\n\n**Params:** buildingid in path; body: buildingname, sortorder, isactive\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X PATCH http://localhost:5001/api/maplevels/buildings/1\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "buildingid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "buildingid in path; body: buildingname, sortorder, isactive" - } - } - } - } - } - }, - "/api/maplevels/{levelid}/blueprint": { - "post": { - "tags": [ - "core-map" - ], - "summary": "Upload a level's blueprint. Reads the image's real pixel size from its header and adopts it when the level is EMPTY...", - "description": "Upload a level's blueprint. Reads the image's real pixel size from its header and adopts it when the level is EMPTY; with markers already placed it reports the mismatch and changes nothing, because adopting a new coordinate space silently moves every marker while looking like a successful upload\n\n**Auth:** jwt + role:admin\n\n**Params:** levelid in path; multipart/form-data: file=, theme=light|dark\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -F file=@second-floor.png -F theme=light -X POST http://localhost:5001/api/maplevels/2/blueprint\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "levelid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "levelid in path; multipart/form-data: file=, theme=light|dark" - } - } - } - } - } - }, - "/api/mappositions/transform": { - "post": { - "tags": [ - "core-map" - ], - "summary": "Move every placed marker on a level by a transform derived per axis from landmark pairs. NEVER from image dimensions: a...", - "description": "Move every placed marker on a level by a transform derived per axis from landmark pairs. NEVER from image dimensions: a level added below another changes canvas height without rescaling anything, and a dimension-derived scale would stretch Y and be wrong everywhere. Dry run returns every old and new position plus which land outside the target. Applying snapshots first and clears mapverifiedat, because a transformed position is a guess awaiting review\n\n**Auth:** permission:assets.edit\n\n**Params:** body: levelid (required), landmarks [{fromx,fromy,tox,toy}] (two or more), tolevelid, assetids, dryrun (defaults TRUE)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"levelid\":1,\"landmarks\":[{\"fromx\":0,\"fromy\":0,\"tox\":0,\"toy\":1450},{\"fromx\":1000,\"fromy\":1000,\"tox\":1000,\"toy\":2450}]}' -X POST http://localhost:5001/api/mappositions/transform\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: levelid (required), landmarks [{fromx,fromy,tox,toy}] (two or more), tolevelid, assetids, dryrun (defaults TRUE)" - } - } - } - } - } - }, - "/api/mappositions/positions": { - "post": { - "tags": [ - "core-map" - ], - "summary": "Set many positions at once, snapshotting first. levelid is per position rather than per request because a bulk save can...", - "description": "Set many positions at once, snapshotting first. levelid is per position rather than per request because a bulk save can span levels and inferring it is the guess this model exists to remove. Placing by hand counts as review, so mapverifiedat is stamped\n\n**Auth:** permission:assets.edit\n\n**Params:** body: positions [{assetid, mapx, mapy, levelid}] - levelid required per row - and verified\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"positions\":[{\"assetid\":42,\"mapx\":1200,\"mapy\":900,\"levelid\":2}]}' -X POST http://localhost:5001/api/mappositions/positions\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: positions [{assetid, mapx, mapy, levelid}] - levelid required per row - and verified" - } - } - } - } - } - }, - "/api/mappositions/verify": { - "post": { - "tags": [ - "core-map" - ], - "summary": "Mark markers reviewed against the current drawing without moving them - the common case in a review pass. No snapshot...", - "description": "Mark markers reviewed against the current drawing without moving them - the common case in a review pass. No snapshot, because no position changes\n\n**Auth:** permission:assets.edit\n\n**Params:** body: assetids (required), unverify\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"assetids\":[42,43]}' -X POST http://localhost:5001/api/mappositions/verify\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: assetids (required), unverify" - } - } - } - } - } - }, - "/api/mappositions/snapshots": { - "get": { - "tags": [ - "core-map" - ], - "summary": "Position snapshots, newest first, with what caused each and whether it has been restored. Metadata only - the positions...", - "description": "Position snapshots, newest first, with what caused each and whether it has been restored. Metadata only - the positions are large\n\n**Auth:** permission:assets.view\n\n**Params:** none; newest 50\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/mappositions/snapshots\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/mappositions/snapshots/{snapshotid}/restore": { - "post": { - "tags": [ - "core-map" - ], - "summary": "Put a snapshot back, snapshotting first so an undo is itself undoable. Restores level and review state, not just...", - "description": "Put a snapshot back, snapshotting first so an undo is itself undoable. Restores level and review state, not just coordinates, and reports assets that no longer exist rather than failing the whole restore\n\n**Auth:** permission:assets.edit\n\n**Params:** snapshotid in path\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X POST http://localhost:5001/api/mappositions/snapshots/7/restore\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "snapshotid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "snapshotid in path" - } - } - } - } - } - }, - "/api/guides": { - "get": { - "tags": [ - "guides" - ], - "summary": "List guides. Published guides plus the caller's own drafts; someone with guides.edit sees every draft", - "description": "List guides. Published guides plus the caller's own drafts; someone with guides.edit sees every draft\n\n**Auth:** jwt + guides.view\n\n**Params:** query: q (searches title, summary, tags and body), tag\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/guides?tag=partmarker' -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - }, - "post": { - "tags": [ - "guides" - ], - "summary": "Create a guide as a draft. Every block needs a unique, stable id: the id is what a reader's tick is remembered against...", - "description": "Create a guide as a draft. Every block needs a unique, stable id: the id is what a reader's tick is remembered against, and a save without one is refused\n\n**Auth:** jwt + guides.create\n\n**Params:** body: title (required), summary, tags (comma separated), blocks (array), note\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"title\":\"Swap a controller\",\"tags\":\"partmarker\",\"blocks\":[{\"id\":\"b1\",\"type\":\"paragraph\",\"text\":\"Power it off.\"}]}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: title (required), summary, tags (comma separated), blocks (array), note" - } - } - } - } - } - }, - "/api/guides/tags": { - "get": { - "tags": [ - "guides" - ], - "summary": "Every tag in use with a count, for filter chips", - "description": "Every tag in use with a count, for filter chips\n\n**Auth:** jwt + guides.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/tags -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/guides/{slug}": { - "get": { - "tags": [ - "guides" - ], - "summary": "Read a guide as a READER: returns the PUBLISHED revision, not the working copy, so an edit in progress does not change...", - "description": "Read a guide as a READER: returns the PUBLISHED revision, not the working copy, so an edit in progress does not change what somebody following the procedure sees. 404 (not 403) for an unpublished guide the caller cannot edit\n\n**Auth:** jwt + guides.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/replace-a-marker -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/guides/{guideid}/working": { - "get": { - "tags": [ - "guides" - ], - "summary": "The working copy, for the editor. Requires guides.edit, or guides.create while the guide is your own and unpublished", - "description": "The working copy, for the editor. Requires guides.edit, or guides.create while the guide is your own and unpublished\n\n**Auth:** jwt + guides.view (+ edit rights)\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/12/working -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "guideid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/guides/{guideid}/revisions": { - "get": { - "tags": [ - "guides" - ], - "summary": "Version history, newest first, flagging which revision is published", - "description": "Version history, newest first, flagging which revision is published\n\n**Auth:** jwt + guides.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/12/revisions -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "guideid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/guides/revisions/{revisionid}": { - "get": { - "tags": [ - "guides" - ], - "summary": "One revision including its blocks, for viewing or comparing an old version", - "description": "One revision including its blocks, for viewing or comparing an old version\n\n**Auth:** jwt + guides.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/revisions/34 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "revisionid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/guides/{guideid}": { - "put": { - "tags": [ - "guides" - ], - "summary": "Save the working copy. Writes a new revision ONLY when the content hash changes, so an autosave that changed nothing...", - "description": "Save the working copy. Writes a new revision ONLY when the content hash changes, so an autosave that changed nothing records nothing; the response carries saved=false in that case\n\n**Auth:** jwt + guides.view (+ edit rights)\n\n**Params:** body: any of title, summary, tags, blocks, note\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/guides/12 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"blocks\":[]}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "guideid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: any of title, summary, tags, blocks, note" - } - } - } - } - }, - "delete": { - "tags": [ - "guides" - ], - "summary": "Delete a guide and its revisions and images", - "description": "Delete a guide and its revisions and images\n\n**Auth:** jwt + guides.delete\n\n**Params:** none\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/guides/12 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "guideid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/guides/{guideid}/publish": { - "post": { - "tags": [ - "guides" - ], - "summary": "Point readers at the latest saved revision. Promotes an existing revision rather than snapshotting, so a publish cannot...", - "description": "Point readers at the latest saved revision. Promotes an existing revision rather than snapshotting, so a publish cannot ship unsaved edits nobody reviewed\n\n**Auth:** jwt + guides.publish\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/12/publish -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "guideid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none" - } - } - } - } - } - }, - "/api/guides/{guideid}/unpublish": { - "post": { - "tags": [ - "guides" - ], - "summary": "Withdraw from readers without deleting anything; revisions survive so the guide can be corrected and published again", - "description": "Withdraw from readers without deleting anything; revisions survive so the guide can be corrected and published again\n\n**Auth:** jwt + guides.publish\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/12/unpublish -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "guideid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none" - } - } - } - } - } - }, - "/api/guides/{guideid}/restore/{revisionid}": { - "post": { - "tags": [ - "guides" - ], - "summary": "Bring an old revision back as a NEW revision. History is never rewritten, so what was published in between stays on...", - "description": "Bring an old revision back as a NEW revision. History is never rewritten, so what was published in between stays on record\n\n**Auth:** jwt + guides.view (+ edit rights)\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/12/restore/34 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "guideid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "revisionid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none" - } - } - } - } - } - }, - "/api/guides/images": { - "post": { - "tags": [ - "guides" - ], - "summary": "Upload an image for a guide. guideid is optional because an image is pasted before a new guide has an id; the editor...", - "description": "Upload an image for a guide. guideid is optional because an image is pasted before a new guide has an id; the editor attaches it on save\n\n**Auth:** jwt + guides.create\n\n**Params:** multipart: file (png/jpeg/gif/webp, max 8 MB), guideid (optional)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/images -H \"Authorization: Bearer $TOK\" -F file=@screenshot.png -F guideid=12\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "multipart: file (png/jpeg/gif/webp, max 8 MB), guideid (optional)" - } - } - } - } - } - }, - "/api/guides/images/{imageid}": { - "get": { - "tags": [ - "guides" - ], - "summary": "Serve an uploaded image; cached immutably since an image is replaced rather than edited", - "description": "Serve an uploaded image; cached immutably since an image is replaced rather than edited\n\n**Auth:** jwt + guides.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/images/5 -H \"Authorization: Bearer $TOK\" --output shot.png\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "imageid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "List endpoint states. Registered both with and without the trailing slash, so both forms are described here", - "description": "List endpoint states. Registered both with and without the trailing slash, so both forms are described here\n\n**Auth:** endpoints.view\n\n**Params:** query: search (hostname substring)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/endpoints/{asset_id}": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "One endpoint: its agent state, version, and last check-in", - "description": "One endpoint: its agent state, version, and last check-in\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/1asset_id\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/{asset_id}/events": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Agent event history for one endpoint, newest first", - "description": "Agent event history for one endpoint, newest first\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id; query: eventid (page from this id)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/1asset_id/events\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/asset/{asset_id}/status": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Asset-detail panel: online state, uptime, and agent version for one endpoint", - "description": "Asset-detail panel: online state, uptime, and agent version for one endpoint\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/status\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/asset/{asset_id}/telemetrymeta": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Asset-detail panel: what telemetry this endpoint reports and when each series was last updated", - "description": "Asset-detail panel: what telemetry this endpoint reports and when each series was last updated\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/telemetrymeta\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/asset/{asset_id}/services": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Asset-detail panel: Windows services the agent watches on this endpoint, with running state", - "description": "Asset-detail panel: Windows services the agent watches on this endpoint, with running state\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/services\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/asset/{asset_id}/recentevents": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Asset-detail panel: the most recent agent events for one endpoint", - "description": "Asset-detail panel: the most recent agent events for one endpoint\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/recentevents\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/asset/{asset_id}/activity": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Asset-detail panel: one chronological feed merging curated Windows events with operator commands, newest first, capped...", - "description": "Asset-detail panel: one chronological feed merging curated Windows events with operator commands, newest first, capped at 15. Both are keyed by assetid, so this is two indexed reads and a merge rather than a join. A failed command is marked critical. The requester name is withheld from callers who do not themselves hold endpoints.command, so a read-only viewer gets the history without the roster of who restarted what\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/activity\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/asset/{asset_id}/interfaces": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Asset-detail panel: network interfaces as the collector reports them ({alias, ipv4, mac, gateway, subnet, dhcp})", - "description": "Asset-detail panel: network interfaces as the collector reports them ({alias, ipv4, mac, gateway, subnet, dhcp})\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/interfaces\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/dashboard/summary": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Dashboard card: endpoint counts by online/offline/never-seen", - "description": "Dashboard card: endpoint counts by online/offline/never-seen\n\n**Auth:** endpoints.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/dashboard/summary\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/endpoints/dashboard/quiet": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Dashboard card: endpoints that have gone quiet - no check-in inside the configured quiet-hours window", - "description": "Dashboard card: endpoints that have gone quiet - no check-in inside the configured quiet-hours window\n\n**Auth:** endpoints.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/dashboard/quiet\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/endpoints/dashboard/stability": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Dashboard card: endpoints ranked by reboot/agent-restart churn, to surface a machine that is flapping", - "description": "Dashboard card: endpoints ranked by reboot/agent-restart churn, to surface a machine that is flapping\n\n**Auth:** endpoints.view\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/dashboard/stability\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/endpoints/agentkey": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Status of the endpoints agent key: whether one exists, when it was issued, and its last use. Never returns the key...", - "description": "Status of the endpoints agent key: whether one exists, when it was issued, and its last use. Never returns the key itself\n\n**Auth:** admin role\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/agentkey\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - }, - "delete": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Revoke the endpoints agent key. The kill switch for agent ingest: reports and command polls stop being authorized...", - "description": "Revoke the endpoints agent key. The kill switch for agent ingest: reports and command polls stop being authorized immediately\n\n**Auth:** admin role\n\n**Params:** none\n\n**Example:**\n```\ncurl -X DELETE -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/agentkey\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/endpoints/agentkey/regenerate": { - "post": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Issue a new endpoints agent key and return it ONCE. The key is scoped to collector.ingest.endpoints only, so rotating...", - "description": "Issue a new endpoints agent key and return it ONCE. The key is scoped to collector.ingest.endpoints only, so rotating it cannot disturb the computers or GE-Enforce collectors. Every agent must be reconfigured with the new value\n\n**Auth:** admin role\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/agentkey/regenerate\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "none" - } - } - } - } - } - }, - "/api/endpoints/{asset_id}/commands": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Command history for one endpoint: what was queued, claimed, and what it returned", - "description": "Command history for one endpoint: what was queued, claimed, and what it returned\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/1asset_id/commands\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "post": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Queue one allowlisted command for an endpoint. Unknown actions are rejected, destructive ones (system_restart) demand a...", - "description": "Queue one allowlisted command for an endpoint. Unknown actions are rejected, destructive ones (system_restart) demand a stricter permission, the effective policy must permit the action for this endpoint, and a second command will not stack while one is pending or running\n\n**Auth:** endpoints.command\n\n**Params:** path: asset_id; body: action (required, allowlisted), target, targetkind\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"action\":\"service_restart\",\"target\":\"MTConnectAgent\",\"targetkind\":\"service\"}' http://localhost:5001/api/endpoints/1/commands\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: asset_id; body: action (required, allowlisted), target, targetkind" - } - } - } - } - } - }, - "/api/endpoints/commands/next": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Agent long-poll. Claims the oldest pending command for the calling host and atomically marks it running so no second...", - "description": "Agent long-poll. Claims the oldest pending command for the calling host and atomically marks it running so no second poll claims it; 204 when nothing is queued inside the hold window, and the agent re-polls. Collector-token auth, not JWT\n\n**Auth:** collector token (collector.ingest.endpoints)\n\n**Params:** query: hostname (required)\n\n**Example:**\n```\ncurl -H \"X-Collector-Key: $KEY\" \"http://localhost:5001/api/endpoints/commands/next?hostname=WJSF-PC-01\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/endpoints/commands/{command_id}/result": { - "post": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Agent reports a claimed command result. Finalizes the command row and, for MTConnect actions that observed run state...", - "description": "Agent reports a claimed command result. Finalizes the command row and, for MTConnect actions that observed run state, caches that on the EndpointState so the UI toggle reflects reality between polls - both in one commit. Collector-token auth, not JWT\n\n**Auth:** collector token (collector.ingest.endpoints)\n\n**Params:** path: command_id; body: the command outcome (status, output)\n\n**Example:**\n```\ncurl -X POST -H \"X-Collector-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"status\":\"succeeded\",\"output\":\"ok\"}' http://localhost:5001/api/endpoints/commands/1/result\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "command_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: command_id; body: the command outcome (status, output)" - } - } - } - } - } - }, - "/api/endpoints/monitors": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "List every monitor definition, shipped and local, ordered shipped-first then by display name", - "description": "List every monitor definition, shipped and local, ordered shipped-first then by display name\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - }, - "post": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Create a local monitor definition; source is forced to 'local' so a caller cannot mint a shipped row", - "description": "Create a local monitor definition; source is forced to 'local' so a caller cannot mint a shipped row\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: kind (process|service, required), displayname (required), processname (required for process), servicename (required for service), launchpath, datacollectorpath, logcollectorpath, isactive (default true)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"kind\":\"service\",\"displayname\":\"Spooler\",\"servicename\":\"Spooler\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: kind (process|service, required), displayname (required), processname (required for process), servicename (required for service), launchpath, datacollectorpath, logcollectorpath, isactive (default true)" - } - } - } - } - } - }, - "/api/endpoints/monitors/{monitor_id}": { - "put": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Update a local monitor definition; 403 on a shipped row, which is product-owned and read-only", - "description": "Update a local monitor definition; 403 on a shipped row, which is product-owned and read-only\n\n**Auth:** jwt + require_role admin\n\n**Params:** path: monitor_id; body: same fields as create, all optional\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/endpoints/monitors/4 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"isactive\":false}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "monitor_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: monitor_id; body: same fields as create, all optional" - } - } - } - } - }, - "delete": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Delete a local monitor definition; 403 on a shipped row", - "description": "Delete a local monitor definition; 403 on a shipped row\n\n**Auth:** jwt + require_role admin\n\n**Params:** path: monitor_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/endpoints/monitors/4 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "monitor_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/monitors/resolve": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Resolve the monitor set a PC type should watch: shipped defaults for 'Shopfloor PC' plus whatever active site policy...", - "description": "Resolve the monitor set a PC type should watch: shipped defaults for 'Shopfloor PC' plus whatever active site policy attaches to that type. The human-facing view of what the agent will be told\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** query: pctype (required)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/endpoints/monitors/resolve?pctype=Shopfloor+PC' -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/endpoints/monitors/resolved": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Agent runtime watch-list. The agent calls this each telemetry cycle and receives {processes[], services[]} shaped like...", - "description": "Agent runtime watch-list. The agent calls this each telemetry cycle and receives {processes[], services[]} shaped like its own MonitorConfig, so a policy-attached monitor is watched without editing appsettings.json on the bay. Unknown hostname is a 404; a PC type with no monitors returns empty lists\n\n**Auth:** endpoints collector API key (X-API-Key/Bearer), not JWT\n\n**Params:** query: hostname (required)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/endpoints/monitors/resolved?hostname=F7MHPF24' -H \"X-API-Key: $ENDPOINTS_KEY\"\n```", - "security": [ - { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/endpoints/operatorreports": { - "post": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Agent uploads one operator issue report captured from the tray. The hostname must resolve to a computer asset. The...", - "description": "Agent uploads one operator issue report captured from the tray. The hostname must resolve to a computer asset. The screenshot is stored under a server-generated uuid filename; a retention sweep runs opportunistically so the feed stays bounded without a scheduler\n\n**Auth:** endpoints collector API key (X-API-Key/Bearer), not JWT\n\n**Params:** multipart/form-data: hostname (required), reason (default 'Other (free text)', truncated to 128), details, screenshot (png, 10 MB cap)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/operatorreports -H \"X-API-Key: $ENDPOINTS_KEY\" -F hostname=F7MHPF24 -F 'details=Machine will not home' -F screenshot=@shot.png\n```", - "security": [ - { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "multipart/form-data: hostname (required), reason (default 'Other (free text)', truncated to 128), details, screenshot (png, 10 MB cap)" - } - } - } - } - } - }, - "/api/endpoints/panel/{asset_id}/operatorreports": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Operator Reports asset panel: this endpoint's reports newest first, each carrying a hasimage flag so the viewer knows...", - "description": "Operator Reports asset panel: this endpoint's reports newest first, each carrying a hasimage flag so the viewer knows whether to request the screenshot\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/panel/46/operatorreports -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/operatorreports/{operatorreportid}/image": { - "get": { - "tags": [ - "plugin-endpoints" - ], - "summary": "Serve one operator report's screenshot, hardened with the shared upload headers (CSP sandbox + nosniff); 404 when the...", - "description": "Serve one operator report's screenshot, hardened with the shared upload headers (CSP sandbox + nosniff); 404 when the report carries no image\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: operatorreportid\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/operatorreports/12/image -H \"Authorization: Bearer $TOK\" -o shot.png\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "operatorreportid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/scripts": { - "get": { - "tags": [ - "core-scripts" - ], - "summary": "List script catalog entries, active only by default", - "description": "List script catalog entries, active only by default\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** query: includeinactive (default false)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - }, - "post": { - "tags": [ - "core-scripts" - ], - "summary": "Create a script catalog entry; the body is uploaded separately so metadata and content version independently", - "description": "Create a script catalog entry; the body is uploaded separately so metadata and content version independently\n\n**Auth:** jwt + require_permission applications.create\n\n**Params:** body: name (required), description, runcontext (system|user, default system)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"name\":\"Restart spooler\",\"runcontext\":\"system\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: name (required), description, runcontext (system|user, default system)" - } - } - } - } - } - }, - "/api/scripts/{script_id}": { - "get": { - "tags": [ - "core-scripts" - ], - "summary": "Get one script entry with its metadata and run context", - "description": "Get one script entry with its metadata and run context\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "script_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "core-scripts" - ], - "summary": "Update a script entry's metadata; the stored body is untouched", - "description": "Update a script entry's metadata; the stored body is untouched\n\n**Auth:** jwt + require_permission applications.edit\n\n**Params:** path: script_id; body: name, description, runcontext, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"description\":\"Clears the print queue\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "script_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: script_id; body: name, description, runcontext, isactive" - } - } - } - } - }, - "delete": { - "tags": [ - "core-scripts" - ], - "summary": "Remove a script catalog entry", - "description": "Remove a script catalog entry\n\n**Auth:** jwt + require_permission applications.delete\n\n**Params:** path: script_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "script_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/scripts/{script_id}/body": { - "post": { - "tags": [ - "core-scripts" - ], - "summary": "Upload or replace the script body; stored on disk and hashed so a delivery can pin the exact bytes", - "description": "Upload or replace the script body; stored on disk and hashed so a delivery can pin the exact bytes\n\n**Auth:** jwt + require_permission applications.edit\n\n**Params:** path: script_id; multipart/form-data: file (the .ps1)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts/3/body -H \"Authorization: Bearer $TOK\" -F 'file=@restart-spooler.ps1'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "script_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: script_id; multipart/form-data: file (the .ps1)" - } - } - } - } - } - }, - "/api/scripts/body/{filename}": { - "get": { - "tags": [ - "core-scripts" - ], - "summary": "Download a stored script body for review in the UI", - "description": "Download a stored script body for review in the UI\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** path: filename (as stored)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/body/restart-spooler.ps1 -H \"Authorization: Bearer $TOK\" -o restart-spooler.ps1\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "filename", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/apppackage/{appversionid}": { - "get": { - "tags": [ - "endpoints-delivery" - ], - "summary": "Agent-facing installer download for a pinned app version; serves the exact bytes the update_app descriptor pinned by...", - "description": "Agent-facing installer download for a pinned app version; serves the exact bytes the update_app descriptor pinned by SHA-256\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: appversionid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/12 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```", - "security": [ - { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "appversionid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/apppackage/app/{app_id}": { - "get": { - "tags": [ - "endpoints-delivery" - ], - "summary": "Agent-facing installer download resolved from the application rather than a specific version", - "description": "Agent-facing installer download resolved from the application rather than a specific version\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/4 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```", - "security": [ - { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/apppackage/app/{app_id}/{filename}": { - "get": { - "tags": [ - "endpoints-delivery" - ], - "summary": "As above, naming the stored file explicitly so the agent writes it back under the expected name", - "description": "As above, naming the stored file explicitly so the agent writes it back under the expected name\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: app_id (int), filename\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/4/setup.exe -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```", - "security": [ - { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "app_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "filename", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/scriptbody/{script_id}": { - "get": { - "tags": [ - "endpoints-delivery" - ], - "summary": "Agent-facing script download for a run_script delivery, on the same key as the rest of the delivery path", - "description": "Agent-facing script download for a run_script delivery, on the same key as the rest of the delivery path\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/scriptbody/3 -H 'X-API-Key: $COLLECTOR_KEY' -o run.ps1\n```", - "security": [ - { - "apiKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "script_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/asset/{asset_id}/applications": { - "get": { - "tags": [ - "endpoints-delivery" - ], - "summary": "Installed-software snapshot the agent last reported for this endpoint, for the asset detail panel; a per-report...", - "description": "Installed-software snapshot the agent last reported for this endpoint, for the asset detail panel; a per-report snapshot rather than history\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/313/applications -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/endpoints/siteagent": { - "get": { - "tags": [ - "endpoints-siteagent" - ], - "summary": "Report whether a prebuilt agent binary is staged and can therefore be packaged", - "description": "Report whether a prebuilt agent binary is staged and can therefore be packaged\n\n**Auth:** jwt + require_role admin\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - } - } - }, - "/api/endpoints/siteagent/package": { - "post": { - "tags": [ - "endpoints-siteagent" - ], - "summary": "Bundle the agent as a downloadable ZIP with a generated install.ps1 that provisions credentials, so a bay install needs...", - "description": "Bundle the agent as a downloadable ZIP with a generated install.ps1 that provisions credentials, so a bay install needs no hand editing\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: baseurl, collectorkey, cadenceseconds, and the site TLS cert when one is readable\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/package -H \"Authorization: Bearer $TOK\" -o shopdb-agent.zip\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: baseurl, collectorkey, cadenceseconds, and the site TLS cert when one is readable" - } - } - } - } - } - }, - "/api/endpoints/adoptiontool/package": { - "post": { - "tags": [ - "endpoints-siteagent" - ], - "summary": "Bundle the agentless adoption tool, for taking an inventory of a machine that will not run the service", - "description": "Bundle the agentless adoption tool, for taking an inventory of a machine that will not run the service\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: as for the site agent package\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/adoptiontool/package -H \"Authorization: Bearer $TOK\" -o shopdb-adoption.zip\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "body: as for the site agent package" - } - } - } - } - } - }, - "/api/endpoints/siteagent/binaries/{binkey}": { - "get": { - "tags": [ - "endpoints-siteagent" - ], - "summary": "List the staged versions of one agent binary, newest first, with the current one marked", - "description": "List the staged versions of one agent binary, newest first, with the current one marked\n\n**Auth:** jwt + require_permission endpoints.agentpackage\n\n**Params:** path: binkey (agent|tray)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent/binaries/agent -H \"Authorization: Bearer $TOK\"\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "binkey", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "post": { - "tags": [ - "endpoints-siteagent" - ], - "summary": "Stage an uploaded binary as a new version. This supplies the executable every future package installs and runs as...", - "description": "Stage an uploaded binary as a new version. This supplies the executable every future package installs and runs as SYSTEM on the fleet, so it is audit-logged with the stored file's sha256\n\n**Auth:** jwt + require_permission endpoints.agentpackage\n\n**Params:** path: binkey (agent|tray); multipart/form-data: file (a Windows PE, 64 MB cap), version (strict dotted-numeric), makecurrent (default true)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/agent -H \"Authorization: Bearer $TOK\" -F file=@shopdbagent.exe -F version=1.6.3\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "binkey", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: binkey (agent|tray); multipart/form-data: file (a Windows PE, 64 MB cap), version (strict dotted-numeric), makecurrent (default true)" - } - } - } - } - } - }, - "/api/endpoints/siteagent/binaries/{binkey}/detect": { - "post": { - "tags": [ - "endpoints-siteagent" - ], - "summary": "Read the version out of an uploaded PE's resource table so the upload form can prefill it. Stores nothing", - "description": "Read the version out of an uploaded PE's resource table so the upload form can prefill it. Stores nothing\n\n**Auth:** jwt + require_permission endpoints.agentpackage\n\n**Params:** path: binkey; multipart/form-data: file\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/agent/detect -H \"Authorization: Bearer $TOK\" -F file=@shopdbagent.exe\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "binkey", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: binkey; multipart/form-data: file" - } - } - } - } - } - }, - "/api/endpoints/siteagent/binaries/{binkey}/adopt": { - "post": { - "tags": [ - "endpoints-siteagent" - ], - "summary": "Adopt a binary already staged on disk into the version manifest, for a build that predates the store", - "description": "Adopt a binary already staged on disk into the version manifest, for a build that predates the store\n\n**Auth:** jwt + require_permission endpoints.agentpackage\n\n**Params:** path: binkey; body: version\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/agent/adopt -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"version\":\"1.6.2\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "binkey", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: binkey; body: version" - } - } - } - } - } - }, - "/api/endpoints/siteagent/binaries/{binkey}/current": { - "put": { - "tags": [ - "endpoints-siteagent" - ], - "summary": "Point the current staged build at another version, so a package rolls back to it. The retention sweep never prunes the...", - "description": "Point the current staged build at another version, so a package rolls back to it. The retention sweep never prunes the current one\n\n**Auth:** jwt + require_permission endpoints.agentpackage\n\n**Params:** path: binkey; body: version (must already be staged)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/endpoints/siteagent/binaries/agent/current -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"version\":\"1.6.2\"}'\n```", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success. Body is the success_response envelope: {status, data, meta}.", - "content": { - "application/json": { - "$ref": "#/components/schemas/SuccessEnvelope" - } - } - }, - "default": { - "description": "Error. Body is the error envelope; the code and message are nested under data.error.", - "content": { - "application/json": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - }, - "401": { - "description": "Missing or invalid credentials." - }, - "403": { - "description": "Authenticated, but not permitted." - }, - "404": { - "description": "No such record." - } - }, - "parameters": [ - { - "name": "binkey", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "path: binkey; body: version (must already be staged)" - } + "description": "body: JSON (fields not yet documented)" + }, + "example": {} } } } diff --git a/docs/proposals/README.md b/docs/proposals/README.md new file mode 100644 index 00000000..87649072 --- /dev/null +++ b/docs/proposals/README.md @@ -0,0 +1,27 @@ + + +# Proposals + +A proposal is a design written before the work, kept afterwards as the record of +why the thing looks the way it does. A proposal is NOT a decision record: where +a proposal and an ADR disagree, the ADR wins. + +**None of these is current guidance.** Read one to understand why something +exists, not to learn how it works today - for that, read the page named in the +right-hand column. + +| Proposal | What it designed | Built? | Read instead | +|---|---|---|---| +| [dashboard-live-fleet](dashboard-live-fleet.md) | A dashboard that shows what needs doing instead of row counts | Yes | [ADR-010](../adr/ADR-010-frontend-plugin-hooks.md) for the contract it introduced | +| [device-identity](device-identity.md) | One durable identity for the device a PC drives, ending in the registry | Phase 1 built (the collector `deviceid` field). Phases 2 and 3 not started | The proposal itself, for the remaining phases | +| [printer-assignment](printer-assignment.md) | Printers as a property of the machine, installed by the PC | Yes, v0.11.3 and v0.12.0 | [PRINTER-INSTALLER](../PRINTER-INSTALLER.md), [COLLECTOR-INTEGRATION](../COLLECTOR-INTEGRATION.md) | + +A proposal that is adopted says so in its own status line, and names what +adopted it. A proposal nobody built stays here anyway: the alternatives it +rejected are the part worth keeping. diff --git a/docs/proposals/dashboard-live-fleet.md b/docs/proposals/dashboard-live-fleet.md new file mode 100644 index 00000000..f8d0bdd0 --- /dev/null +++ b/docs/proposals/dashboard-live-fleet.md @@ -0,0 +1,179 @@ + + +# Proposal: a dashboard that shows the fleet, not the row count + +- Status: IMPLEMENTED (a proposal, not a decision record - the + contract it introduced is ADR-010 and contract 0.19.0) +- Date: 2026-08-11 +- Author: ShopDB maintainers +- Relates to: ADR-010 (frontend plugin hooks), ADR-013 / ADR-014 (lean per-site builds), ADR-006 (collector contract), ADR-012 (GE-Enforce manifest ownership) + +## The problem + +The dashboard reports totals: assets, machines, PCs, network devices, printers, +measuring tools. Those numbers are true every day and actionable on none of +them. Nobody opens the page to learn there are 417 assets. + +Meanwhile the system already knows things worth acting on and shows them +nowhere: + +- a bay returned 500 to every collector report for a day and a half, and the + only evidence was a log file on the PC +- eleven part markers' backups were overwriting each other for weeks +- a PC can stop reporting entirely and nothing says so +- GE-Enforce records enforcement failures, stale manifests and repeated + self-heals, and none of it surfaces outside a report modal + +Every one of those was found by someone going to look. The dashboard should be +where they find you. + +## What already exists + +Two discoveries shaped this proposal. + +**The plugin mechanism is built and dead.** `BasePlugin.get_dashboard_widgets` +exists, `GET /api/dashboard/widgets` aggregates across enabled plugins, skips +disabled ones, isolates a broken plugin in production and sorts by position. +Five plugins - notifications, network, machines, computers, printers - already +declare widgets. + +**Nothing consumes it.** The frontend never calls the endpoint, and not one of +the declared components (`PrinterStatusWidget`, `ComputerStatusWidget`, +`MachineStatusWidget`, `NotificationsWidget`, `NetworkStatusWidget`) exists. +The declarations point at components that were never written. + +So this is less "build a dashboard" than "finish one", plus a contract fix. + +**The data is already collected.** Nothing here needs a new collector field: + +| Signal | Source | +|---|---| +| PC reporting / silent | `computers.lastreporteddate` | +| Machine has a controlling PC | `controls` links, `collector:machine` | +| Backed up, stale, never | `backuprevisions` per asset and kind | +| Enforcement failed | `manifestenforcementreports.status`, `failedcount` | +| Which entry failed, and why | `manifestenforcementresults.action`, `exitcode`, `message` | +| Stale manifest | report `appliedversion` versus the scope's published version | +| Drift that will not stick | repeated `selfhealed` results for one entry | +| Enforcer version spread | `manifestenforcementreports.enforcerversion` | +| Installed software and versions | `computerinstalledapps` | +| Toner critical / low | printer supplies, Zabbix-fed | +| Warranty expiring | warranty plugin | +| Mis-numbered bays | the `check-shared-machines` query | + +## Proposed change to the widget contract + +The existing contract names a **component per widget**. That does not scale and +does not survive a lean build: every widget needs a bespoke Vue component, and a +plugin-provided component has to be staged into the frontend bundle to exist at +all. + +ADR-010 already solved this for asset panels: a plugin declares `render: 'list'` +or `'keyvalue'` or `'tabs'` plus a `map` of fields, and a GENERIC core component +renders it. Do the same here. A plugin declares data and shape; core owns the +rendering. + +```python +{ + 'id': 'geenforce-failures', + 'title': 'Enforcement failures', + 'endpoint': '/api/geenforce/dashboard/failures', + 'render': 'exceptions', # generic renderer + 'severity': 'critical', # critical | warning | info + 'permission': 'geenforce.view', + 'position': 10, + 'empty': 'hide', # hide | line + 'map': { + 'title': 'hostname', + 'detail': 'entryname', + 'meta': [{'key': 'message'}, {'key': 'exitcode'}], + 'link': '/pcs/{computerid}', + }, +} +``` + +Three renderers cover everything listed above: + +- `exceptions` - a list of things that are wrong, each linking to the thing +- `metric` - a single number with a trend or threshold, for the cases where the + count IS the story (toner critical: 3) +- `list` - recent items, for notifications and KB adds + +## Principles + +**An exception board, not a stat board.** The default view is what needs a +person today. A count appears only where the count is the story. + +**Empty cards must shrink.** A card that says "nothing wrong" every day trains +people to stop reading the page. That is exactly how `ntlars-backup.log` reached +3,234 lines with 17 that mattered. `empty: 'hide'` is the default; `'line'` for +the few where absence is itself news. + +**Every row links to the thing.** A dashboard that says three printers are low +without linking to them is a worse version of a report. + +**Cards respect RBAC.** A card declares a `permission`; a user without it never +sees the card or its endpoint. Toner levels are not sensitive, but "which PCs +expose VNC" is, and the dashboard must not become a way around role gating. + +**Thresholds are settings with neutral defaults** (ADR-015): quiet window, toner +percentage, warranty horizon. Every site will disagree with the numbers. + +**One endpoint per card, lazily loaded, failing independently.** A hung Zabbix +call must not blank the page. + +## Wave one + +Cards whose data is already reliable, ordered by how actionable they are: + +1. **Enforcement failures** - current reports with `status = failed`, showing + the failing entry and exit code. +2. **PCs not reporting** - `lastreporteddate` beyond the quiet window, and PCs + with no GE-Enforce report at all, which is a different and worse case. +3. **Backups missing or stale** - machines with no revision of a kind their + PC type should produce, and revisions older than the interval. +4. **Toner critical, then low** - links to the printer, then to its admin page. +5. **Warranties expiring** - within the configured horizon. +6. **Mis-numbered bays** - `check-shared-machines`, promoted from a CLI command + nobody will remember to run. + +Then, lower and quieter: stale manifest versions, repeated self-heals, enforcer +version spread, active notifications, recent application and KB additions. + +## Wave two: desired versus observed + +The highest-value card and the most work. The manifest declares what a PC type +should have; `computerinstalledapps` records what it does; `filters.py` already +decides which entries apply to a given PC. Diffing those answers "which bays are +missing something they should have" - which nothing in the system answers today, +and which is the thing GE-Enforce exists to guarantee. + +Deferred to wave two because it needs the resolver wired into a query path, and +because wave one needs nothing new. + +## Risks + +**Aggregate queries on the most-visited page.** These are fleet-wide scans on +every load. Cache 30-60 seconds, and log slow cards - a lazily-loaded card can +be slow for months before anyone mentions it. + +**Cards become the new noise.** Six cards that are always empty are as useless +as one number that never changes. If a card is empty for a month, delete it. + +**Plugin gating must be real.** A lean site without `printers` must render no +toner card. The existing endpoint already skips disabled plugins; the frontend +must not hardcode a card list alongside it. + +## Non-goals + +- Per-user dashboard customisation. Later, if asked for. +- Historical charting. This is a "what needs doing now" board; trends belong in + reports. +- Replacing the shopfloor TV dashboard, which is a different audience with + different needs. diff --git a/docs/proposals/device-identity.md b/docs/proposals/device-identity.md new file mode 100644 index 00000000..290f5119 --- /dev/null +++ b/docs/proposals/device-identity.md @@ -0,0 +1,235 @@ + + +# Proposal: one identity for the device a PC drives, ending in the registry + +Status: PHASE 1 BUILT AND IN THIS TREE - `plugins/computers/plugin.py` declares +`deviceid` on the computers collector schema and prefers it over the older +per-family fallbacks. Phases 2 and 3 not started. +Author: planning session 2026-08-20. + +## 1. What this is + +A shop-floor PC often drives something: a CMM, a Keyence, a Genspect, a +wax-trace unit, a part marker. ShopDB needs to know WHICH one, and the answer +has to survive the PC being replaced. + +Today that answer is assembled from whatever each bay family happens to have, +and two of the three sources are derived from the PC itself, so they do not +survive a swap. This proposal converges them on one identity, moves it from +files to the registry, and does it in three phases so no bay is stranded. + +## 2. Why PC-derived identity fails + +Two mechanisms stand in for a real identity today: + +- **Reuse**: look for a prior collector link from THIS PC asset. +- **Mint**: build `-`, and a PC's asset number is its + hostname. + +Both survive a re-image. Neither survives a PC SWAP. A new hostname is a new PC +asset with no prior link and a predicted number that has never existed, so the +same physical device gets a SECOND record while the first keeps its config and +its backup history under a dead PC's name. + +That is not hypothetical: minting from the hostname left 43 legacy `MT-####` +tools shadowed by minted twins, three records deep in places. + +## 3. The rail that must not be crossed + +`machinenumber` answers "WHICH BAY is this" and is what GE-Enforce +`TargetMachineNumbers` gates on. The device identity answers "WHICH DEVICE is +this". They are different facts and must stay in different fields. + +Putting a device id into the machine-number channel would silently stop every +bay-gated manifest entry from matching - a failure that looks like nothing +happening, on every cycle, with no error anywhere. + +## 4. Where identity comes from today + +| Bay family | Identity source | Survives a PC swap | +|---|---|---| +| DNC / collections | `MachineNo` in the eDNC/NTLARS registry | yes | +| CMM | `C:\Enrollment\cmm\cmmid.txt` | yes, but see below | +| Keyence, Genspect, wax-trace | nothing, before phase 1 | no | +| Part markers | nothing, before phase 1 | no | + +**`cmmid.txt` is not only an identity file.** It is a PATH KEY, and that is why +it cannot simply be retired: + +| Consumer | Use | +|---|---| +| `resolve-cmm-bay-config.ps1` | writes it | +| `Verify-And-Heal-Staging.ps1` | stages `installers-post\cmm\backups\` | +| `09-Setup-CMM.ps1` | locates the bay's staged backup set | +| `Restore-CMM.ps1` | restores from `\\`, same-bay by design | +| collector | reads it as `machineNo` | + +Only the last row is an identity use. The rest address directories by it. + +It is also doing the job badly: for a CMM bay it reports the INSTRUMENT's id in +the `machinenumber` channel, and the server then resolves it back to a measuring +tool at step 4 of its own fallback chain. That round trip is what phases 2 and 3 +remove. + +## Phase 1 - one field, one file (DONE) + +The collector accepts a single `deviceid`, read from `C:\Enrollment\asset-id.txt` +and resolved BEFORE anything PC-derived, for every device family. + +- One file for every bay with no `MachineNo` registry to identify it. +- It names the asset and NOT its type: the pc-type already says what kind of + device it is, through `SUBORDINATE_DEVICE_MAP`, so a device type declared + later through `subordinatedevice_` needs no new file and no client + change. +- Wire field is `deviceid`, not `assetid`, because `assetid` is already this + contract's RESPONSE field for a PC's integer primary key. +- `measuringtoolid` (0.12.0) is accepted as an alias and the older file is still + read, so a bay staged in that window keeps reporting. +- Guards: an unresolvable value warns rather than minting a phantom, and a value + resolving to the WRONG KIND of asset is refused with the asset named. + +Nothing was removed. Every previous fallback still runs, one place lower. + +## Phase 2 - imaging writes it, and `cmmid.txt` stops being an identity + +Repository: the PXE/imaging repo, not this one. **Needs deploying to the live +server, not only committing** - the live boot image is authoritative. + +1. `resolve-cmm-bay-config.ps1` writes `C:\Enrollment\asset-id.txt` ALONGSIDE + `cmmid.txt`. Two files, two questions: the identity, and the backup-set key. + `cmmid.txt` keeps every path use it has today. +2. Every other device pc-type writes `asset-id.txt` at imaging from its bay + config. Bays with no device write nothing, and the file's absence stays + meaningful. +3. The collector demotes `cmmid.txt` to last place in the machine-number chain + and stops being the intended path for CMM identity. It is still read, so a + grandfathered bay that never gets re-imaged keeps reporting. +4. The value written is the device's `assetnumber` as ShopDB holds it. Where a + CMM's asset number is already its bay id (`CMM3`), phase 2 is a no-op in + content and a change only in which file carries it. + +Exit criterion: a report from a re-imaged bay of each device family carries +`deviceid`, and no bay of those families relies on the `cmmid.txt` fallback. +The collector response warnings are the check: an adopted device is silent, a +minted one is not. + +## Phase 3 - identity moves to the registry + +`HKLM:\SOFTWARE\GE\ShopDB\DeviceId`. + +This is the destination because the registry survives the profile cleanup and +disk hygiene that eats `C:\Enrollment`, is readable as SYSTEM with no file ACL +to get wrong, and is already how a DNC bay is identified. It needs no new key +and no new read path: the collector already opens +`HKLM:\SOFTWARE\GE\ShopDB` (and the `WOW6432Node` variant) for `BaseUrl` and +`ApiToken`, and `Install-GEEnforce.ps1` already writes that key. + +**One value, every family.** `DeviceId` is a single registry value, not a key +per device type. The client sends one field and the pc-type decides which sync +consumes it, exactly as `asset-id.txt` works in phase 1, so: + +| Bay | `DeviceId` holds | Replaces | +|---|---|---| +| CMM | the instrument's `assetnumber` | `cmmid.txt` as IDENTITY only | +| Keyence | the instrument's `assetnumber` | nothing; had none | +| Genspect | the instrument's `assetnumber` | nothing; had none | +| wax-trace | the instrument's `assetnumber` | nothing; had none | +| part marker | the marker's `assetnumber` | minting from the hostname | + +A device family added later through `subordinatedevice_` is covered on +the day it is declared, with no client change and no new registry value. + +Resolution order after phase 3: + +1. `HKLM:\SOFTWARE\GE\ShopDB\DeviceId` +2. `C:\Enrollment\asset-id.txt` +3. `C:\Enrollment\measuringtool-id.txt` (0.12.0 window) +4. `C:\Enrollment\cmm\cmmid.txt` as `machineNo` (grandfathered CMM bays) + +Then, and only then, the file branches can be deleted one at a time, newest +first, each when no bay has reported through it for a full inventory cycle. + +**`cmmid.txt` survives phase 3 regardless**, as the CMM backup-set path key. It +is not on the deletion list; only its identity use is. + +### PXE and GE-Enforce prefer it too, not just the collector + +Phase 3 is not only "the collector reads a registry value". All three writers +and readers move together: + +| Component | Change | +|---|---| +| PXE / imaging | writes `DeviceId` at enrollment instead of (or as well as) the file | +| GE-Enforce | resolves device identity registry-first, mirroring how it already resolves the machine number | +| collector | reads the registry ahead of the file | + +**GE-Enforce already made this exact decision for the machine number, and wrote +down why.** `Install-FromManifest.ps1` resolves `TargetMachineNumbers` gating +from the eDNC registry BEFORE `C:\Enrollment\machine-number.txt`, because: + +> the imaging-time `machine-number.txt` is written ONCE by `startnet.cmd` at the +> PXE menu and is NOT updated on reassignment, so it goes stale. + +**`asset-id.txt` inherits that defect exactly.** It is written once at imaging +and nothing updates it when a device is swapped, moved between bays, or +replaced. The bay then reports an identity that was true on imaging day, and the +collector - which now trusts that value FIRST, ahead of every other source - +adopts the wrong device with no warning, because the value still resolves. + +That makes phase 3 a correctness fix rather than a tidy-up. A file that only +imaging writes cannot be the top of a resolution order for something that +changes during a PC's life. + +It also implies a reassignment path, the way `Set-MachineNumber` / +`Update-MachineNumber` exist for the bay number: something that rewrites +`DeviceId` when a device is swapped, and migrates any per-device state with it. +Without that, phase 3 moves the staleness from a file to a registry value rather +than curing it. + +### Doing phase 3 early is cheaper than doing it late + +If the registry read lands before imaging is changed, phase 2 can write the +REGISTRY for new bays and skip the file era entirely for them. The file then +exists only for bays staged in the window between phase 1 and phase 2, and the +eventual cleanup is deleting a code branch rather than sweeping the fleet. + +The cost of adding the read now is one branch in the client and one row in the +docs. The cost of adding it after every bay has a file is a migration. + +## 5. What this does not change + +- `machinenumber` and its GE-Enforce gating. Untouched in all three phases. +- A part marker still files `partof` the operation from `machinenumber`. The + device identity says WHICH marker the PC controls, not which operation that + marker belongs to. +- Minting. It stays as the last resort for a bay that names nothing, because a + device nobody declared is still better recorded than not recorded. Anything + minted should be treated as a placeholder to be reconciled. + +## 6. Before phase 2: find what is already duplicated + +Phase 1 stops NEW duplicates. It does not merge the ones a PC swap has already +created. Before imaging starts writing files, run a read-only query for device +assets whose controlling PC no longer exists, or whose asset number carries a +hostname that is not a current PC. That list is the reconciliation backlog, and +it is much easier to read before the fleet starts reporting new identities over +the top of it. + +## 7. Rollback + +Each phase is independently reversible because nothing is removed until the +phase after it proves the replacement: + +- Phase 1: stop sending `deviceid` and every previous fallback still runs. +- Phase 2: stop writing `asset-id.txt` and bays fall back to what they used + before. +- Phase 3: stop writing the registry value and bays fall back to the file. + +The only irreversible step is deleting a fallback branch, which is deliberately +outside all three phases. diff --git a/docs/proposals/printer-assignment.md b/docs/proposals/printer-assignment.md new file mode 100644 index 00000000..f3b59c3f --- /dev/null +++ b/docs/proposals/printer-assignment.md @@ -0,0 +1,346 @@ + + +# Proposal: assign printers to a machine in ShopDB, let the PC install them + +Status: SHIPPED AND RELEASED. Built out 2026-08-18/19; the client script is +built too. The feature itself released in v0.11.3, the observation half and +`seed-from-observed` adoption in v0.12.0. +Author: planning session 2026-08-18. + +One thing this proposal did NOT anticipate, resolved 2026-08-24 in `adc7ef4`: +the computers collector carried a `_sync_printer_links` that wrote +`defaultprinter` / `connectedto` rows straight from a PC report, which is the +ASSIGNED-vs-OBSERVED split this proposal rests on, broken from the other end. +It is retired; `defaultprinter` and `printers` are now ignored (not rejected) on +the collector payload. See [COLLECTOR-INTEGRATION.md](../COLLECTOR-INTEGRATION.md). + +## 1. What this is + +Today a printer reaches a shop-floor PC because a person walks up to it, opens +the printer installer, finds the printer on a floor plan and clicks it. That is +fine for someone choosing a printer, and wrong for a bay whose printers are a +property of the bay. + +This proposal makes the assignment data. The printers belong to the MACHINE, not +to the box currently driving it: tick the printers that belong on the machine, +mark one default, and the assignment reaches whichever PC controls that machine. +The PC converges on its next GE-Enforce cycle - installing what is missing and +setting the default - and keeps converging, so a reimaged bay comes back with +its printers and a bay that drifts is corrected. + +The point of putting the assignment on the machine is that a reimaged PC needs +no backup and no restore step. The asset register is the source of truth, and a +replacement PC that inherits the `controls` edge inherits the printers with it. + +The map installer stays, for the case it is actually good at: a person at an +unmanaged or office PC picking a printer that nobody assigned. + +## 2. Why it is worth doing + +- **The assignment becomes a record.** "Which printers does that bay have" is a + question ShopDB can answer, and today it cannot. +- **A reimage stops costing a visit.** The bay reinstalls its own printers, from + the machine's record, with nothing saved off the old PC. +- **Swapping the PC keeps the printers.** They were never the PC's. +- **Drift is corrected, not just detected.** A queue deleted by a user comes + back. +- **It removes the walk-up from the common case.** The installer's map remains + for the uncommon one. + +## 3. What already exists + +Most of the model is in place, which is why this is a small feature rather than +a project. + +| piece | state | +|---|---| +| PC to printer link | `defaultprinter` asset relationship, seeded by `flask seed reference-data` | +| Propagation mechanism | `RelationshipTypePropagation` (ADR-001): "type X propagates through connections of type Y" | +| A working precedent | `resolve_asset_position` walks `partof` then `controls` to give a PC the machine's map position | +| Default lookup | `GET /api/printers/pc-default?machine=NNNN` | +| Host lookup | `GET /api/computers/by-hostname/` | +| Printer model | `Printer.modelnumberid` - populated for 44 of 44 printers at the reference site | +| Driver record | `PrinterDriver` (name, `location` as SMB path or URL, optional `modelnumberid`) | +| Per-printer install path | `Printer.installpath` | +| Batch install | `GET /api/printers/install-batch?printerids=1,2,3` | +| Client transport | GE-Enforce manifest entries, `Type=PS1`, running as SYSTEM every cycle | +| Silent driver staging | Proven in `PrinterInstaller.iss`: trust the catalog's signing cert, then `pnputil /add-driver` | + +## 4. The data model + +### 4.1 One new relationship type + +`usesprinter`, directional, source -> printer, meaning "this printer is +installed here". It is seeded next to `defaultprinter`, which already exists and +means "which of them is the default". Both are seed data, not a migration, which +is how every other relationship type shipped. + +### 4.2 Two propagation rails, consumed at READ time + +`usesprinter` propagates through `controls`, and so does `defaultprinter`. Both +are rows in `relationshiptypepropagations`, the same mechanism map positions +use. Inventing a second mechanism for this was the alternative, and it was +rejected. + +The rails are inert at write time on purpose. The create-time fan-out +(`propagate_relationship`) skips directional through-types, and `controls` is +directional, so assigning a printer to a machine does not copy rows onto its PC. +The walk happens when something asks, which is what makes the next rule possible. + +### 4.3 Resolution order for a PC + +1. The PC's OWN active `usesprinter` / `defaultprinter` rows, if it has any. +2. Otherwise, one hop out along its `controls` edges to the machines it drives, + and those machines' rows instead, tagged as inherited. + +Own beats inherited, whole set at a time: a PC with its own assignment is +overriding the bay, not adding to it. An office PC controls no machine and still +works, because step 1 is the normal case for it. + +The override is a real trap and the UI has to say so. A tech who "fixes" a bay +by editing the PC has shadowed the machine's record, and the machine will keep +disagreeing until someone clears the PC's own rows. + +### 4.4 One default, optional, and never dangling + +The unique constraint is `(source, target, type)`, which happily accepts two +different defaults. So the rule is enforced in the API on write: + +- Exactly one `defaultprinter` per asset. Setting a default replaces the + existing one. +- A default is OPTIONAL. A bay with three printers and no default is valid. +- The default must be one of the assigned printers. Unassigning the printer that + is currently default clears the default rather than leaving it dangling. + +### 4.5 One column on `printerdrivers` + +`drivername` - the driver's exact name as the INF declares it, e.g. +`HP Universal Printing PCL 6`. `Add-PrinterDriver` matches on that string, not +on `name`, which is ours to choose, and a mismatch is the usual failure. +Deriving it by parsing the INF on hundreds of bays is fragile; a human +confirming it once in ShopDB is not. + +It is a plugin-chain migration (`printers0003drivername`), nullable, guarded so a +re-run is a no-op. `printerdrivers` was created by a core migration but its DDL +moved to the printers chain at the ADR-008 cutover. + +The `installmethod` column (`pnputil` or `dpinst`) proposed earlier is NOT being +built. See section 8: if production confirms no Brother printers, everything is +`pnputil` and the column has no second value to hold. + +## 5. What has to be built + +### 5.1 A resolution helper in core + +The read-time walk of section 4.3, beside `resolve_asset_position` and exported +on the `shopdb.api` contract surface (an additive minor bump). It has to live in +core because `RelationshipTypePropagation` is not on the contract surface, and a +plugin may not reach past it (ADR-002). + +The through-type comes from the seeded rails, not from a hardcoded `'controls'`, +so a site that adds a rail gets the behaviour without a code change. + +### 5.2 Two endpoints + +``` +GET /api/printers/for-host/ +``` + +The desired printer set for one PC, resolved per section 4.3, each entry with +what a client needs to install it: queue name, host or IP, port, driver name, +driver location, and which one is default. + +Resolved by hostname, not machine number: the collector already upserts PCs by +hostname, and an office PC has no machine number. Matched case-insensitively - +`COMPUTERNAME` is uppercase and MySQL forgives that where SQLite does not. + +An unknown host, a site without the computers plugin, or nothing assigned all +return an empty set. That is the client's designed no-op and it must stay +indistinguishable from "assigned nothing". + +``` +PUT /api/printers/assignments/for-asset/ +``` + +The whole assignment for one asset - machine or PC - in one call: +`{printerassetids: [...], defaultprinterassetid: N|null}`. It reconciles rather +than inserting: rows that went away are soft-deleted, rows that come back +REACTIVATE the soft-deleted row (the unique constraint spans inactive rows, so a +blind insert is an integrity error on assign, unassign, re-assign), new rows are +created, and the single default is replaced. + +The rules in 4.4 hold here or nowhere. Row-at-a-time writes through the generic +relationships path leave two-default windows and know nothing of the subset rule. + +**Removing an assignment NEVER uninstalls anything.** Server-side the row simply +goes: no cascade, no side effects, nothing queued for the client to undo. + +**Per-PC assignments must NOT go in the manifest.** Manifests are keyed by scope +and PC type and sync broadly; putting per-PC rows there would leak every bay's +configuration to every bay and grow without limit. One manifest entry runs one +script that asks the API what THIS host gets - the mirror image of +`Report-AssetToShopDB.ps1`. + +### 5.3 UI on the PC form + +A printer multi-select plus a default dropdown whose options are only the +currently selected printers, clearing itself when its printer is deselected. +Saved through the reconcile endpoint against the PC's asset. + +The computers plugin does not depend on the printers plugin and must not start: +the section hides itself when the printers API is not there. + +The machine-side picker is out of scope for now, which means the machine's +assignment is editable only through the generic relationships card. That is the +side the design says is primary, so it is the obvious next piece of UI. + +### 5.4 One client script, in two contexts + +`Set-ShopdbPrinters.ps1`, shipped in `plugins/printers/client/` beside the +contract it consumes, and run as a manifest entry with `DetectionMethod=Always`. + +*As SYSTEM, every cycle:* + +1. `GET /api/printers/for-host/$env:COMPUTERNAME` +2. For each assigned printer with no queue: trust the driver catalog's cert, + `pnputil /add-driver`, create the port, create the queue +3. Write the desired default to `HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter` +4. Ensure the per-user task exists + +*In the user's context, at logon and on a repeat:* + +5. Read that value, compare with the current default, set it if it differs, and + clear "Let Windows manage my default printer" - otherwise Windows silently + overrides the choice the next time someone prints elsewhere + +The default printer is per-user state, which is the only reason this needs two +contexts. Everything else is machine state and belongs to the cycle. + +Converge, do not reinstall: when the state matches, the script does nothing. +Nothing here needs the manifest to know when a printer changes, because the +desired state is fetched, not declared. + +## 6. Desired state and observed state are not the same thing + +Everything above is DESIRED state: what SHOULD be installed on a PC. Nothing in +this feature knows what IS installed on it. The client reads the desired state, +converges toward it, and reports nothing back. + +Reporting the observed state is the obvious next feature and is deliberately not +this one. If the collector sent the installed queues per host - name, port, +driver, which is default - then comparing that against the resolved assignment +gives drift detection for free: "this bay is missing the label printer", "this +PC has three queues nobody assigned", "the default is not the assigned one". + +Keeping them apart is a rule, not a preference: + +- **Observed data never writes `usesprinter` rows.** A register that learns from + what it finds mirrors the drift instead of correcting it, and the fault + becomes the desired state. +- **Observed data belongs on the computer record, timestamped**, like the rest + of the collector payload. It is an observation with an age, not a decision. +- **An empty answer from the API means "nothing assigned", not "nothing + installed"**, which is exactly why section 5.2 refuses to make removal + uninstall anything. +- The two can disagree indefinitely and that is a report to read, not an error + to resolve automatically. + +## 7. Decisions to take before writing the client + +1. **Never remove a queue by default.** A transient API failure would otherwise + strip printers fleet-wide. Deletion is an explicit opt-in, per PC. +2. **Enforced or set-once for the default?** Re-applying every cycle overrides a + user who chose their own default - correct for a locked bay, irritating on an + office PC. Set-once is Active Setup or RunOnce. Make it a per-PC-type flag + rather than one global answer. +3. **Failure is silent and safe**: unreachable API means change nothing, log, + exit 0 - the convention `Report-AssetToShopDB.ps1` already follows. + +Open on the server side, and each one changes the response contract: + +4. **How a universal driver resolves.** `PrinterDriver` links to a printer by + exact `modelnumberid`, and the target state is roughly four rows dominated by + HP UPD and Xerox GPD, which match no single model. Either the driver row + gains a vendor, or a `modelnumberid IS NULL` row matches on the printer's + resolved vendor name. Until this is settled, `for-host` returns no driver for + 41 of 44 printers. +5. **What `port` means when it is null.** RAW 9100 is the obvious default; whose + job it is to apply it - server or script - has to be written down once. +6. **Who may read `for-host`.** `pc-default` and `install-list` are anonymous; + the collector and the GE-Enforce fetch use scoped service tokens. This one + discloses per-PC configuration keyed by hostname. +7. **Two inherited defaults.** A PC can legitimately control both bays of a + dual-bay machine, or several machines. The union of assigned printers is + easy; the default needs a deterministic rule, or none when it is ambiguous. +8. **Legacy `defaultprinter` rows have no `usesprinter` row**, because they + predate the type. Either an active default implies assignment on read + (zero-touch, preferred) or a one-time backfill writes the missing rows. + Otherwise existing defaults vanish from `for-host` while still showing in + `pc-default`. +9. **Deletions through the generic relationships card bypass the reconcile + endpoint** and can strand an active default pointing at an unassigned + printer. Either the resolver drops dangling defaults or the core delete path + learns the rule. + +## 8. What the fleet data says, and the one prerequisite + +The reference site's 44 printers are HP 26, Xerox 15, Zebra 1, HID 1, Epson 1. + +- **HP and Xerox are 41 of 44, and both have true universal drivers** (HP UPD, + Xerox Global Print Driver). One driver record each serves every queue of that + make. +- **There are no Brother printers at all**, yet the installer carries 208 files + of per-model Brother MFC-J inkjet drivers. Those are host-based GDI devices + with no Printer-class INF, which is the only reason a second staging method + (DPInst) exists. If production confirms no Brother, that payload and that code + path can both go - and with them the `installmethod` column. +- **Zebra, HID and Epson are one printer each**, and the HP DesignJet plotter is + a fourth special case - a PostScript device the UPD does not cover. + +**Prerequisite: populate `printerdrivers`.** It currently holds ONE row, and it +points at a per-model folder (`HP LaserJet Pro M607 Driver`) rather than the +universal driver - the opposite of how a UPD should be used. The table needs +roughly four rows: HP UPD, Xerox GPD, one per oddity, and DesignJet when its +payload is restored. Each needs `drivername` copied verbatim from its INF. +Nothing in this proposal works until a printer can resolve to a driver. + +## 9. Deployment constraint that shapes the design + +**The SFLD share is mounted only during GE-Enforce's cycle.** Any work touching +a share path must run as a manifest entry inside that cycle, never as its own +scheduled task. The failure is silent - the task reports 0 processed, 0 +installed, 0 failed - and it has cost a session before. + +This is why driver staging belongs in the cycle even though the per-user default +does not, and why "the assignment script schedules a task that installs drivers" +is the wrong shape. + +## 10. Upgrading an existing site + +Three steps, and the third is the one that gets forgotten: + +1. `flask db upgrade` - no core migration in this feature, but a deploy runs it. +2. `flask plugin upgrade-all` - applies `drivername`. Skipping it is the classic + 1054 unknown-column error. +3. `flask seed reference-data` - REQUIRED. Without it the `usesprinter` type and + both propagation rails do not exist, and `for-host` resolves nothing, quietly, + because empty is also the healthy answer. + +Pair the upgrade with a smoke check against a known bay. A site with reversed +legacy `controls` rows (machine -> PC) should run +`flask relationships fix-controls-direction` first, or inheritance resolves for +none of those PCs. + +## 11. What this does not change + +- The printer installer keeps working, for walk-up and self-service. +- Nothing about how printers are modelled, mapped or reported. +- The collector contract. Section 6 would change it; this feature does not. +- Sites not running GE-Enforce: the same endpoint suits an Intune remediation or + a DSC `Script` resource, since it is a plain HTTP GET and a PowerShell script. diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6c7a0da0..11000c59 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -18,12 +18,12 @@ const api = axios.create({ // page would be readable by anyone who could load the page, so it is per-device // and never shipped with the bundle. // -// One entry per kiosk: the two are separate screens in separate rooms and a -// shared token would mean provisioning one grants the other. -export const KIOSK_TOKEN_KEYS = { - '/printedparts/kiosk/': 'printedparts-kiosk-token', - '/usb/kiosk/': 'usb-kiosk-token', -} +// One entry per kiosk, keyed by the URL prefix its routes live under: two +// screens in two rooms must not share a token, or provisioning one grants the +// other. Empty in this build - no bundled plugin serves a kiosk - and this is +// the extension point a plugin that adds one registers its prefix in, together +// with claimKioskToken below and the interceptor that sends the header. +export const KIOSK_TOKEN_KEYS = {} function kioskTokenFor(url) { const path = url || '' @@ -216,14 +216,113 @@ export const endpointsApi = { get(assetId) { return api.get(`/endpoints/${assetId}`) }, - enqueueCommand(assetId, action, target = null) { + // opts.policyoverride + opts.overridereason drive the admin break-glass path. + // Both are required together server-side: an override without a stated reason + // is rejected, so the caller must collect the reason before sending. + enqueueCommand(assetId, action, target = null, opts = {}) { const body = { action } if (target) body.target = target + if (opts.policyoverride) { + body.policyoverride = true + body.overridereason = opts.overridereason + } return api.post(`/endpoints/${assetId}/commands`, body) }, + // Pushes the published agent build to one endpoint. Separate from + // enqueueCommand because the server prepares the delivery (packages the + // build, seeds the upgrade script) before queueing it as a pinned + // run_script; the caller has no script id to pass and must not invent one. + enqueueAgentUpgrade(assetId, opts = {}) { + const body = {} + if (opts.policyoverride) { + body.policyoverride = true + body.overridereason = opts.overridereason + } + return api.post(`/endpoints/${assetId}/agentupgrade`, body) + }, + // Per-PC credential administration (admin role, re-checked server-side). + // blockreenroll keeps the PC from enrolling again until allowReenroll. + revokeCredential(assetId, opts = {}) { + const body = {} + if (opts.blockreenroll) { + body.blockreenroll = true + if (opts.reason) body.reason = opts.reason + } + return api.post(`/endpoints/enrollment/assets/${assetId}/revoke`, body) + }, + allowReenroll(assetId) { + return api.post(`/endpoints/enrollment/assets/${assetId}/allow-reenroll`) + }, listCommands(assetId) { return api.get(`/endpoints/${assetId}/commands`) }, + // Recent run_script commands only, with the agent-reported detail the plain + // command list omits. Feeds the run history under the Run Scripts tab. + listScriptRuns(assetId, limit = 5) { + return api.get(`/endpoints/${assetId}/scriptruns`, { params: { limit } }) + }, + // Collected-log history for one endpoint. Metadata only - the log bodies are + // fetched one at a time, because a 20-row history of multi-hundred-KB logs + // would make opening the tab far more expensive than reading the one log the + // operator actually wants. + listLogCollections(assetId) { + return api.get(`/endpoints/${assetId}/logcollections`) + }, + // One collected log including its content, for the scrollable pane. + getLogCollection(collectionId) { + return api.get(`/endpoints/logcollections/${collectionId}`) + }, + // Blob, not JSON: the server sends this as a text/plain attachment, and the + // caller turns it into a save dialog. + downloadLogCollection(collectionId) { + return api.get(`/endpoints/logcollections/${collectionId}/download`, { + responseType: 'blob', + }) + }, + // Active break-glass holds. Readable by any endpoints.view holder, since a + // suppressed action is drift an operator must be able to explain. + listPolicyHolds(assetId) { + return api.get(`/endpoints/${assetId}/policyholds`) + }, + // Desired-mode actions that a hold would actually affect, with hold state. + listHoldableActions(assetId) { + return api.get(`/endpoints/${assetId}/holdableactions`) + }, + // Admin-only. Takes one action name or an array of them; the server applies + // them atomically, so a bad name rejects the whole request rather than + // leaving a partial set of holds behind. + // minutes: null uses the server default window, 0 means indefinite. + placePolicyHold(assetId, actions, reason, minutes = null) { + const list = Array.isArray(actions) ? actions : [actions] + const body = { actions: list, reason } + // Explicit null check, NOT truthiness: 0 is a meaningful value here + // (indefinite) and a falsy test would silently downgrade it to the + // default window. + if (minutes !== null && minutes !== undefined) body.minutes = minutes + return api.post(`/endpoints/${assetId}/policyholds`, body) + }, + releasePolicyHold(assetId, action) { + return api.delete(`/endpoints/${assetId}/policyholds/${action}`) + }, + // Endpoint lock: an admin quarantine refusing ACTIONS from non-admins on this + // endpoint. Distinct from a policy hold - a hold suppresses reconciliation of + // one action and expires on its own; a lock gates callers and stands until an + // admin lifts it. Returns null when the endpoint is not locked. + // + // Readable by any endpoints.view holder on purpose: an operator whose action + // was refused needs to see who locked it and why, or they file a ticket about + // a machine that is working fine. + getEndpointLock(assetId) { + return api.get(`/endpoints/${assetId}/lock`) + }, + // Admin-only. Reason is required and is quoted verbatim in the refusal shown + // to blocked users, so it is the message, not paperwork. + placeEndpointLock(assetId, reason) { + return api.post(`/endpoints/${assetId}/lock`, { reason }) + }, + releaseEndpointLock(assetId) { + return api.delete(`/endpoints/${assetId}/lock`) + }, telemetryMeta(assetId) { return api.get(`/endpoints/asset/${assetId}/telemetrymeta`) }, @@ -711,8 +810,16 @@ export const applicationsApi = { update(id, data) { return api.put(`/applications/${id}`, data) }, - delete(id) { - return api.delete(`/applications/${id}`) + // Hard delete. The backend requires the operator's typed confirmation in the + // body, so the caller must pass what the user actually typed - this is not + // defaulted here, or the guard would be meaningless. + delete(id, confirm) { + return api.delete(`/applications/${id}`, { data: { confirm } }) + }, + // Strips policies, versions and uploaded executables but keeps the catalogue + // row and its image. Same typed-confirmation contract as delete. + revertToRecord(id, confirm) { + return api.post(`/applications/${id}/revert`, { confirm }) }, // multipart image upload; backend sets image to the served URL uploadImage(id, file) { @@ -768,8 +875,11 @@ export const applicationsApi = { } // ShopDB-hosted script library. Backs run_script delivery: the agent fetches a -// script body by digest with its collector credential and runs it in the -// script's configured run context (system or user). +// script body by digest with its collector credential and runs it as SYSTEM. +// The UI does not offer a run context - scripts exist to trigger an already +// registered scheduled task, and the agent service has no user session to +// borrow. Anything needing a user's own token must be that scheduled task's +// problem, not this library's. export const scriptsApi = { list(params = {}) { return api.get('/scripts', { params }) @@ -979,42 +1089,6 @@ export const notificationsApi = { } } -// USB Devices API -export const usbApi = { - list(params = {}) { - return api.get('/usb', { params }) - }, - // Every USB device, paged past the backend's 100-row cap. Batch label printing - // and "pick any record" dropdowns must use this: list() with a large - // perpage is clamped to 100 and still returns a success response, so - // the tail simply goes missing. - listAll(params = {}) { - return fetchAllPages('/usb', params) - }, - get(id) { - return api.get(`/usb/${id}`) - }, - create(data) { - return api.post('/usb', data) - }, - update(id, data) { - return api.put(`/usb/${id}`, data) - }, - checkout(id, data) { - return api.post(`/usb/${id}/checkout`, data) - }, - checkin(id, data = {}) { - return api.post(`/usb/${id}/checkin`, data) - }, - getHistory(id, params = {}) { - return api.get(`/usb/${id}/history`, { params }) - }, - // check-out log rows for one badge; activeonly = only currently-held devices - getUserCheckouts(badge, activeonly = true) { - const path = activeonly ? '/usb/checkouts/active' : '/usb/checkouts' - return api.get(path, { params: { badge } }) - } -} // Reports API export const reportsApi = { @@ -1046,7 +1120,8 @@ export const reportsApi = { } } -// Employees API (wjf_employees database) +// Employees API. Core, not a plugin: the rows live in the site's external HR +// database and this app only reads them (see shopdb/core/api/employees.py). export const employeesApi = { search(query, limit = 10) { return api.get('/employees/search', { params: { q: query, limit } }) @@ -1056,33 +1131,6 @@ export const employeesApi = { }, lookupMultiple(ssoList) { return api.get('/employees/lookup', { params: { sso: ssoList } }) - }, - // Self-hosted directory management (directory_mode=selfhosted) - directory: { - list() { - return api.get('/employees/directory') - }, - create(data) { - return api.post('/employees/directory', data) - }, - update(sso, data) { - return api.put(`/employees/directory/${sso}`, data) - }, - remove(sso) { - return api.delete(`/employees/directory/${sso}`) - }, - importCsv(csv) { - return api.post('/employees/directory/import', { csv }) - }, - // multipart photo upload; backend sets photofilename + returns photourl - uploadPhoto(sso, file) { - const form = new FormData() - form.append('file', file) - return api.post(`/employees/${sso}/photo`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) - }, - removePhoto(sso) { - return api.delete(`/employees/${sso}/photo`) - } } } @@ -1549,115 +1597,4 @@ export const warrantyApi = { } } -// Measuring tools API (plugin) -export const measuringtoolsApi = { - list(params = {}) { - return api.get('/measuringtools', { params }) - }, - // Every measuring tool, paged past the backend's 100-row cap. Batch label printing - // and "pick any record" dropdowns must use this: list() with a large - // perpage is clamped to 100 and still returns a success response, so - // the tail simply goes missing. - listAll(params = {}) { - return fetchAllPages('/measuringtools', params) - }, - get(id) { - return api.get(`/measuringtools/${id}`) - }, - getByAsset(assetid) { - return api.get(`/measuringtools/by-asset/${assetid}`) - }, - create(data) { - return api.post('/measuringtools', data) - }, - update(id, data) { - return api.put(`/measuringtools/${id}`, data) - }, - remove(id) { - return api.delete(`/measuringtools/${id}`) - }, - calibrationReport() { - return api.get('/measuringtools/report/calibration') - }, - // Measuring-tool types - types: { - list(params = {}) { - return api.get('/measuringtools/types', { params }) - }, - get(id) { - return api.get(`/measuringtools/types/${id}`) - }, - create(data) { - return api.post('/measuringtools/types', data) - }, - update(id, data) { - return api.put(`/measuringtools/types/${id}`, data) - }, - remove(id) { - return api.delete(`/measuringtools/types/${id}`) - } - } -} -// 3D printed parts (printedparts plugin) -export const printedpartsApi = { - list(params = {}) { - return api.get('/printedparts/items', { params }) - }, - // Every printed item, paged past the backend's 100-row cap. The label sheet - // must print the whole selection, not the first page of it. - listAll(params = {}) { - return fetchAllPages('/printedparts/items', params) - }, - get(printeditemid) { - return api.get(`/printedparts/items/${printeditemid}`) - }, - create(data) { - return api.post('/printedparts/items', data) - }, - update(printeditemid, data) { - return api.put(`/printedparts/items/${printeditemid}`, data) - }, - remove(printeditemid) { - return api.delete(`/printedparts/items/${printeditemid}`) - }, - restore(printeditemid) { - return api.post(`/printedparts/items/${printeditemid}/restore`) - }, - uploadImage(printeditemid, file) { - const formData = new FormData() - formData.append('file', file) - return api.post(`/printedparts/items/${printeditemid}/image`, formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) - }, - deleteImage(printeditemid) { - return api.delete(`/printedparts/items/${printeditemid}/image`) - }, - restock(printeditemid, data) { - return api.post(`/printedparts/items/${printeditemid}/restock`, data) - }, - adjust(printeditemid, data) { - return api.post(`/printedparts/items/${printeditemid}/adjust`, data) - }, - kioskItem(itemcode) { - return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`) - }, - kioskTake(data) { - return api.post('/printedparts/kiosk/take', data) - }, - listFiles(printeditemid) { - return api.get(`/printedparts/items/${printeditemid}/files`) - }, - uploadFile(printeditemid, file, note) { - const formData = new FormData() - formData.append('file', file) - if (note) formData.append('note', note) - return api.post(`/printedparts/items/${printeditemid}/files`, formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) - }, - removeFile(fileid) { - return api.delete(`/printedparts/files/${fileid}`) - } -} diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index e3f6afff..36aad50e 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -922,6 +922,29 @@ input[type="radio"] { color: white; } +/* Report a Bug sits directly above the theme toggle and matches it exactly. + The sidebar is dark in BOTH themes, so these are fixed white alphas like the + rules above rather than theme tokens - a var(--text-*) here would resolve to + a dark colour on a dark sidebar in light mode and disappear. */ +.bug-report-link { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + padding: 0.75rem 1.25rem; + color: rgba(255,255,255,0.7); + font-size: 13px; + text-decoration: none; + cursor: pointer; + transition: all 0.2s; +} + +.bug-report-link:hover { + background: rgba(255,255,255,0.1); + color: white; +} + /* User menu */ .user-menu { padding: 1rem 1.25rem; diff --git a/frontend/src/components/AssetRelationships.vue b/frontend/src/components/AssetRelationships.vue index 279d2d90..e5fb17cb 100644 --- a/frontend/src/components/AssetRelationships.vue +++ b/frontend/src/components/AssetRelationships.vue @@ -205,7 +205,7 @@ - - diff --git a/frontend/src/components/NetworkAllowlistPicker.vue b/frontend/src/components/NetworkAllowlistPicker.vue new file mode 100644 index 00000000..52b5ed48 --- /dev/null +++ b/frontend/src/components/NetworkAllowlistPicker.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index b199931e..0dc09ead 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -3,7 +3,7 @@ a panel as JSON (title, endpoint, render mode, field map); this renders it on any asset detail page, so a plugin adds detail-page UI with no Vue. --> + +
+

Collector API

+

+ The four legacy collector routes (/api/collector/pc, + /apps, /heartbeat, /bulk) name + the PC they write in the request body, so the shared collector key is + an any-host write key on them: whoever holds it can rewrite the + logged-in user, last boot time, serial number and installed apps of + any hostname. A collector-scoped managed token is unaffected. +

+ +
+ + + Off by default, which is what every site does today. Turning it on + stops Report-AssetToShopDB.ps1 and + Report-PrintersToShopDB.ps1 reporting through those + routes until they are moved to per-PC credentials, along with + anything else still calling them with the shared key. Every + shared-key call is logged as "Legacy collector route used with the + shared key" whether this is on or off - read those warnings for a + while first, or turning it on is a guess. On the shipped IIS deploy + that line names the route but not the caller: the X-Forwarded-For + rewrite rule in web.config ships disabled, so every request reaches + the app from 127.0.0.1. Count the routes, not the addresses, unless + that rule is on. The shipped reporters that post to + /api/collector/computers and + /api/collector/printers are not affected. This should + follow the move to per-PC credentials, not lead it. + +
+
+ {{ legacySharedKeyError }} +
+
+

User Management

@@ -151,14 +204,66 @@ diff --git a/frontend/src/views/settings/IntegrationsHub.vue b/frontend/src/views/settings/IntegrationsHub.vue new file mode 100644 index 00000000..c851ae57 --- /dev/null +++ b/frontend/src/views/settings/IntegrationsHub.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/src/views/settings/IntegrationsLayout.vue b/frontend/src/views/settings/IntegrationsLayout.vue new file mode 100644 index 00000000..7e350672 --- /dev/null +++ b/frontend/src/views/settings/IntegrationsLayout.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/src/views/settings/LocationsHub.vue b/frontend/src/views/settings/LocationsHub.vue new file mode 100644 index 00000000..1720f339 --- /dev/null +++ b/frontend/src/views/settings/LocationsHub.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/frontend/src/views/settings/ModelCatalogHub.vue b/frontend/src/views/settings/ModelCatalogHub.vue new file mode 100644 index 00000000..78208523 --- /dev/null +++ b/frontend/src/views/settings/ModelCatalogHub.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/frontend/src/views/settings/NetworkHub.vue b/frontend/src/views/settings/NetworkHub.vue new file mode 100644 index 00000000..2af0ddd8 --- /dev/null +++ b/frontend/src/views/settings/NetworkHub.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/frontend/src/views/settings/PrintingSettings.vue b/frontend/src/views/settings/PrintingSettings.vue index d0c493aa..4d86bb5c 100644 --- a/frontend/src/views/settings/PrintingSettings.vue +++ b/frontend/src/views/settings/PrintingSettings.vue @@ -26,20 +26,6 @@

-
- -
-
-
- -
-
-
- -
@@ -185,7 +143,6 @@ const encodesRows = [ { key: 'label_default_encodes_computer', label: 'Computer' }, { key: 'label_default_encodes_printer', label: 'Printer' }, { key: 'label_default_encodes_network_device', label: 'Network device' }, - { key: 'label_default_encodes_measuring_tool', label: 'Measuring tool', hasLocation: true }, ] onMounted(loadSettings) diff --git a/frontend/src/views/settings/SecurityHub.vue b/frontend/src/views/settings/SecurityHub.vue new file mode 100644 index 00000000..c9c9e618 --- /dev/null +++ b/frontend/src/views/settings/SecurityHub.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/frontend/src/views/settings/ServiceNowSettings.vue b/frontend/src/views/settings/ServiceNowSettings.vue index ffcd7a8b..8de20c4e 100644 --- a/frontend/src/views/settings/ServiceNowSettings.vue +++ b/frontend/src/views/settings/ServiceNowSettings.vue @@ -1,7 +1,32 @@ + + diff --git a/frontend/src/views/settings/asset-types/SuppliesTab.vue b/frontend/src/views/settings/asset-types/SuppliesTab.vue new file mode 100644 index 00000000..dc740b1c --- /dev/null +++ b/frontend/src/views/settings/asset-types/SuppliesTab.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/frontend/src/views/settings/asset-types/TypesTabWrapper.vue b/frontend/src/views/settings/asset-types/TypesTabWrapper.vue new file mode 100644 index 00000000..3db15eb4 --- /dev/null +++ b/frontend/src/views/settings/asset-types/TypesTabWrapper.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/src/views/settings/settingsNav.js b/frontend/src/views/settings/settingsNav.js index a5b87238..a291f97c 100644 --- a/frontend/src/views/settings/settingsNav.js +++ b/frontend/src/views/settings/settingsNav.js @@ -4,95 +4,59 @@ // Order: site identity + the reference-data catalogs users touch daily come // first, then the platform/system groups (integrations, communication, search, // plugins, access) cluster together at the end. -import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload } from 'lucide-vue-next' +import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload, DatabaseBackup } from 'lucide-vue-next' export const settingsGroups = [ { title: 'Site & Facility', cards: [ - { to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' }, - { to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' }, - { to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' }, - { to: '/settings/importsiteassets', icon: Upload, title: 'Import Site Assets', description: 'Seed Machines, PCs, and Applications via the Asset Collector script, existing databases, or Intune/Azure exports; set the shared CSV collection location and download collected files' }, + { to: '/settings/siteconfig', icon: Home, title: 'Site Configuration', description: 'Site identity, facility settings, floor map, branding, and asset imports' }, ], }, { title: 'General Reference', cards: [ - { to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' }, - { to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' }, - { to: '/settings/modeltypes', icon: Monitor, title: 'Model Types', description: 'Manage model type categories' }, - { to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' }, - { to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' }, - { to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' }, - { to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' }, + { to: '/settings/catalog', icon: Package, title: 'Model Catalog', description: 'Manage vendors, model types, equipment models, and operating systems' }, + { to: '/settings/assetconfig', icon: Cog, title: 'Asset Configuration', description: 'Configure asset statuses, relationship types, custom fields, and identifiers' }, + { to: '/settings/assettypes', icon: Factory, title: 'Asset Types', description: 'Manage asset type subtypes, colors, and custom fields for all asset categories' }, { to: '/settings/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' }, - // Operating Systems is cross-asset (PCs, machines, measuring tools, network - // devices all run an OS), so it lives in General Reference, not under PCs. - { to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates (used by PCs, machines, network devices, and more)' }, ], }, { title: 'PCs', cards: [ - { to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' }, { to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' }, ], }, { title: 'Printers', cards: [ - { to: '/settings/printertypes', icon: Printer, title: 'Printer Types', description: 'Manage printer subtypes + map colors' }, { to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' }, { to: '/settings/printerdrivers', icon: Printer, title: 'Printer Drivers', description: 'Named SMB / HTTP links to printer driver packages' }, ], }, - { - title: 'Machines', - cards: [ - { to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' }, - ], - }, - // The "Measuring Tools" cards are contributed by the measuringtools plugin via - // the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog). - // This empty placeholder fixes the group's position among the asset groups - // (right after Machines); the merger fills it in place instead of appending a - // new group at the end. When the plugin is disabled the group stays empty and - // the rail/landing drop it (empty groups never render). - { - title: 'Measuring Tools', - cards: [], - }, { title: 'Locations & Organization', cards: [ - { to: '/settings/employeedirectory', icon: Contact, title: 'Employee Directory', description: 'Manage the self-hosted people directory (add/edit/import); read-only in external HR mode' }, - { to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' }, - { to: '/settings/locationtypes', icon: Tag, title: 'Location Types', description: 'Manage location types + colors' }, - { to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' }, + { to: '/settings/locations', icon: MapPin, title: 'Locations & Organization', description: 'Physical locations, business units, and employee directory' }, ], }, { title: 'Network', cards: [ - { to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' }, - { to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' }, - { to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' }, + { to: '/settings/network', icon: Globe, title: 'Network Infrastructure', description: 'Manage VLANs and IP subnets' }, ], }, { title: 'Displays & Kiosks', cards: [ { to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default location' }, - { to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' }, ], }, { title: 'Integrations', cards: [ - { to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' }, - { to: '/settings/zabbix', icon: Droplets, title: 'Zabbix Supplies', description: 'Zabbix API for real-time printer toner and supply monitoring' }, - { to: '/settings/dellwarranty', icon: ShieldCheck, title: 'Dell Warranty', description: 'Dell TechDirect warranty API lookup by service tag' }, + { to: '/settings/integrations', icon: Link, title: 'Integrations', description: 'ServiceNow, Zabbix, and Dell Warranty API connections' }, ], }, { @@ -105,24 +69,20 @@ export const settingsGroups = [ title: 'Search & Identity', cards: [ { to: '/settings/globalsearch', icon: Search, title: 'Global Search', description: 'Choose which content types (domains) appear in global search results' }, - { to: '/settings/assetidentifiers', icon: Fingerprint, title: 'Asset Identifiers', description: 'Enable optional asset identifiers per type: gauge lab, maintenance, FQDN hostname' }, - { to: '/settings/printing', icon: Printer, title: 'Printing & Labels', description: 'Printed QR code and USB label targets, barcode style, and URL templates' }, + { to: '/settings/printing', icon: Printer, title: 'Printing & Labels', description: 'Printed QR code label targets, barcode style, and URL templates' }, ], }, { title: 'System', cards: [ { to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' }, + { to: '/settings/shopdbbackups', icon: DatabaseBackup, title: 'Site Backups', description: 'Scheduled database and file backups with guided restore; keeps any day in the last 5' }, ], }, { title: 'Access & Security', cards: [ - { to: '/settings/authentication', icon: KeyRound, title: 'Authentication', description: 'SAML single sign-on (SSO) with your IdP, local login, and auto-create users' }, - { to: '/settings/apitokens', icon: Key, title: 'API Tokens', description: 'Personal access tokens for scripts and integrations (e.g. long-running imports) that outlive login sessions' }, - { to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' }, - { to: '/settings/audit', icon: History, title: 'Audit & Logging', description: 'Audit log retention period and history purge policy' }, - { to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' }, + { to: '/settings/security', icon: ShieldCheck, title: 'Access & Security', description: 'Authentication, API tokens, users, roles, and audit logs' }, ], }, ] diff --git a/frontend/src/views/settings/types/NotificationTypesTab.vue b/frontend/src/views/settings/types/NotificationTypesTab.vue new file mode 100644 index 00000000..99f2db6b --- /dev/null +++ b/frontend/src/views/settings/types/NotificationTypesTab.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md b/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md deleted file mode 100644 index 82612f3e..00000000 --- a/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md +++ /dev/null @@ -1,89 +0,0 @@ -# Fix LocationOnly Equipment Types Migration - -## Issue -During the original migration from the VBScript ShopDB site, 110 machines were imported with `LocationOnly` type even though they have models assigned that specify the correct equipment type. - -## Root Cause -The migration copied the `machinetypeid` directly from the `machines` table, but many machines had `machinetypeid=1` (LocationOnly) even when they had a model with a proper type. - -## Affected Records -- **110 machines** in the `machines` table -- **110 equipment** records in the `equipment` table - -## Example -| Machine | Current Type | Should Be | Model | -|---------|-------------|-----------|-------| -| 7502 | LocationOnly | Horizontal Machining Center | a81nx | -| 3108 | LocationOnly | Vertical Lathe | VTM-100 | -| 4001 | LocationOnly | 5-axis Mill | VP9000 | - -## Fix SQL - -### Step 1: Preview affected records -```sql --- Preview what will be changed -SELECT - m.machineid, - m.machinenumber, - mt_current.machinetype as current_type, - mt_model.machinetype as correct_type, - mo.modelnumber -FROM machines m -JOIN models mo ON mo.modelnumberid = m.modelnumberid -JOIN machinetypes mt_current ON mt_current.machinetypeid = m.machinetypeid -JOIN machinetypes mt_model ON mt_model.machinetypeid = mo.machinetypeid -WHERE m.machinetypeid = 1 -- Currently LocationOnly -AND mo.machinetypeid != 1; -- Model has a real type -``` - -### Step 2: Fix the machines table -```sql --- Update machines table to use the model's machine type -UPDATE machines m -JOIN models mo ON mo.modelnumberid = m.modelnumberid -SET m.machinetypeid = mo.machinetypeid -WHERE m.machinetypeid = 1 -- Currently LocationOnly -AND mo.machinetypeid != 1; -- Model has a real type -``` - -### Step 3: Fix the equipment table -```sql --- Update equipment table to match --- Note: equipmenttypeid values match machinetypeid values -UPDATE equipment e -JOIN assets a ON a.assetid = e.assetid -JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1) -SET e.equipmenttypeid = m.machinetypeid -WHERE e.equipmenttypeid = 1 -- Currently LocationOnly -AND m.machinetypeid != 1; -- Machine now has correct type -``` - -### Alternative Step 3 (if asset numbers don't match) -```sql --- Update equipment based on their own model reference -UPDATE equipment e -JOIN models mo ON mo.modelnumberid = e.modelnumberid -SET e.equipmenttypeid = mo.machinetypeid -WHERE e.equipmenttypeid = 1 -- Currently LocationOnly -AND mo.machinetypeid != 1 -- Model has a real type -AND e.modelnumberid IS NOT NULL; -``` - -## Verification -```sql --- Count remaining LocationOnly records that should have been fixed -SELECT COUNT(*) as remaining_fixable -FROM machines m -JOIN models mo ON mo.modelnumberid = m.modelnumberid -WHERE m.machinetypeid = 1 -AND mo.machinetypeid != 1; --- Should return 0 after fix -``` - -## Notes for Future Migrations -1. When importing equipment, always check if the model has a type and use that as the default -2. Only use "LocationOnly" for genuine location markers (rooms, offices) without models -3. Validate after import: any equipment with a model should NOT be LocationOnly - -## Date Created -2026-01-27 diff --git a/migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md b/migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md deleted file mode 100644 index 906af210..00000000 --- a/migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md +++ /dev/null @@ -1,92 +0,0 @@ -# Migrate USB Devices from Equipment Table - -## Issue -6 USB devices were incorrectly stored in the `equipment` table instead of the proper `usbdevices` table. - -## Status -- [x] `pin` field added to `usbdevices` table for encrypted devices -- [x] `pin` field added to USBDevice model -- [x] USB Device equipment type deactivated (equipmenttypeid=44) -- [ ] Migrate 6 USB devices to usbdevices table - -## Affected Records -| Asset Number | Name | Serial Number | -|--------------|------|---------------| -| 82841957-5891 | Green Kingston 64GB | 82841957 | -| 48854302-5892 | Blue Kingston 64GB | 48854302 | -| 75953637-5893 | Blue Kingston 64GB USB 3.0 | 75953637 | -| 41299370-5904 | Lenovo Portable DVD | 41299370 | -| 15492331-5905 | TEAC Portable Floppy Drive | 15492331 | -| 25777358-5906 | Netgear WiFi Adapter | 25777358 | - -## Migration SQL - -### Step 1: Insert into usbdevices -```sql -INSERT INTO usbdevices ( - serialnumber, - label, - assetnumber, - usbdevicetypeid, - storagelocation, - notes, - createddate, - modifieddate, - isactive -) -SELECT - a.serialnumber, - a.name, - a.assetnumber, - NULL, -- Assign proper type later - NULL, -- Storage location TBD - a.notes, - a.createddate, - a.modifieddate, - a.isactive -FROM assets a -JOIN equipment e ON e.assetid = a.assetid -WHERE e.equipmenttypeid = 44; -- USB Device type -``` - -### Step 2: Delete from equipment table -```sql -DELETE e FROM equipment e -JOIN assets a ON a.assetid = e.assetid -WHERE e.equipmenttypeid = 44; -``` - -### Step 3: Delete from assets table -```sql -DELETE a FROM assets a -JOIN equipment e ON e.assetid = a.assetid -WHERE e.equipmenttypeid = 44; - --- Note: Run step 2 first since it references assets -``` - -### Alternative: Manual Migration -Since there are only 6 devices, you may prefer to: -1. Manually create them in the USB Devices section -2. Delete the equipment/asset records - -## USB Device Types to Create -```sql --- Check if types exist, create if needed -INSERT IGNORE INTO usbdevicetypes (typename, description, icon) VALUES -('Flash Drive', 'USB flash drive / thumb drive', 'usb'), -('External HDD', 'External hard disk drive', 'harddisk'), -('External SSD', 'External solid state drive', 'harddisk'), -('Card Reader', 'SD/CF card reader', 'sdcard'), -('Optical Drive', 'External CD/DVD/Blu-ray drive', 'disc'), -('WiFi Adapter', 'USB wireless network adapter', 'wifi'), -('Other', 'Other USB device', 'usb'); -``` - -## Notes -- USB Device equipment type has been deactivated (isactive=0) -- New USB devices should be created in the USB Devices section -- USB devices do NOT need map positions (no mapleft/maptop) - -## Date Created -2026-01-27 diff --git a/migrations/PRODUCTION_MIGRATION_GUIDE.md b/migrations/PRODUCTION_MIGRATION_GUIDE.md deleted file mode 100644 index 7a68acf9..00000000 --- a/migrations/PRODUCTION_MIGRATION_GUIDE.md +++ /dev/null @@ -1,165 +0,0 @@ -# Production Migration Guide - -## Overview -This guide documents the process for migrating data from the legacy VBScript ShopDB site to the new Flask-based ShopDB. - -## Database Architecture - -### Legacy System (VBScript) -- Single `machines` table containing all equipment, PCs, printers -- `machinetypes` table for classification -- `models` table with `machinetypeid` reference - -### New System (Flask) -- Core `assets` table (unified asset registry) -- Plugin-specific extension tables: - - `equipment` (equipmenttypeid -> equipmenttypes) - - `computers` (computertypeid -> computertypes) - - `printers` (printertypeid -> printertypes) - - `network_device` (networkdevicetypeid -> networkdevicetypes) - -## Key Mappings - -### Asset Type Mapping -| Legacy Category | New Asset Type | Extension Table | -|-----------------|---------------|-----------------| -| Equipment machines | Equipment | equipment | -| PC/Computer | Computer | computers | -| Printer | Printer | printers | -| Network (IDF, Switch, AP) | Network Device | network_device | - -### Type ID Alignment -The `equipmenttypes` table IDs match `machinetypes` IDs for easy migration: -- equipmenttypeid = machinetypeid (where applicable) - -## Migration Steps - -### Step 1: Export from Legacy Database -```sql --- Export machines with all related data -SELECT - m.*, - mt.machinetype, - mo.modelnumber, - mo.machinetypeid as model_typeid, - v.vendor, - bu.businessunit, - s.status -FROM machines m -LEFT JOIN machinetypes mt ON mt.machinetypeid = m.machinetypeid -LEFT JOIN models mo ON mo.modelnumberid = m.modelnumberid -LEFT JOIN vendors v ON v.vendorid = m.vendorid -LEFT JOIN businessunits bu ON bu.businessunitid = m.businessunitid -LEFT JOIN statuses s ON s.statusid = m.statusid; -``` - -### Step 2: Create Assets -For each machine, create an asset record: -```sql -INSERT INTO assets (assetnumber, name, assettypeid, statusid, locationid, businessunitid, mapleft, maptop) -SELECT - CONCAT(machinenumber, '-', machineid), -- Unique asset number - alias, - CASE - WHEN category = 'PC' THEN 2 -- Computer - WHEN category = 'Printer' THEN 4 -- Printer - ELSE 1 -- Equipment - END, - statusid, - locationid, - businessunitid, - mapleft, - maptop -FROM machines; -``` - -### Step 3: Create Extension Records -```sql --- For Equipment -INSERT INTO equipment (assetid, equipmenttypeid, vendorid, modelnumberid) -SELECT - a.assetid, - COALESCE(mo.machinetypeid, m.machinetypeid), -- Use model's type if available! - m.vendorid, - m.modelnumberid -FROM machines m -JOIN assets a ON a.assetnumber = CONCAT(m.machinenumber, '-', m.machineid) -LEFT JOIN models mo ON mo.modelnumberid = m.modelnumberid -WHERE m.category = 'Equipment' OR m.category IS NULL; -``` - -### Step 4: Post-Migration Fixes - -#### Fix LocationOnly Equipment Types -Equipment imported with LocationOnly type should inherit type from their model: -```sql --- Fix equipment that has a model with a proper type -UPDATE equipment e -JOIN assets a ON a.assetid = e.assetid -JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1) -JOIN models mo ON mo.modelnumberid = m.modelnumberid -SET e.equipmenttypeid = mo.machinetypeid -WHERE e.equipmenttypeid = 1 -- LocationOnly -AND mo.machinetypeid != 1; -- Model has real type -``` - -## Validation Queries - -### Check for Orphaned Assets -```sql -SELECT a.* FROM assets a -LEFT JOIN equipment e ON e.assetid = a.assetid -LEFT JOIN computers c ON c.assetid = a.assetid -LEFT JOIN printers p ON p.assetid = a.assetid -WHERE a.assettypeid = 1 -- Equipment type -AND e.assetid IS NULL; -``` - -### Check LocationOnly with Models -```sql --- Should return 0 after migration fix -SELECT COUNT(*) -FROM equipment e -JOIN assets a ON a.assetid = e.assetid -JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1) -JOIN models mo ON mo.modelnumberid = m.modelnumberid -WHERE e.equipmenttypeid = 1 -AND mo.machinetypeid != 1; -``` - -### Verify Type Distribution -```sql -SELECT et.equipmenttype, COUNT(*) as count -FROM equipment e -JOIN equipmenttypes et ON et.equipmenttypeid = e.equipmenttypeid -GROUP BY et.equipmenttype -ORDER BY count DESC; -``` - -## Common Issues - -### Issue: Equipment marked as LocationOnly but has model -**Cause**: Migration copied machinetypeid from machines table instead of using model's type -**Fix**: See FIX_LOCATIONONLY_EQUIPMENT_TYPES.md - -### Issue: Missing model relationship -**Cause**: Equipment table modelnumberid not populated during migration -**Fix**: Link through machines table using asset number pattern - -### Issue: Duplicate asset numbers -**Cause**: Asset number generation didn't account for existing duplicates -**Fix**: Use unique suffix or check before insert - -## Scripts Location -- `/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - Fix for LocationOnly type issue -- `/scripts/import_from_mysql.py` - Original import script (may need updates) -- `/scripts/migration/` - Migration utilities - -## Notes -- Always backup before running migration fixes -- Test on staging/dev before production -- Verify counts before and after each fix -- Keep legacy `machines` table for reference during transition - -## Date Created -2026-01-27 diff --git a/migrations/versions/7d42_split_endpoints_deploy.py b/migrations/versions/7d42_split_endpoints_deploy.py new file mode 100644 index 00000000..166fd605 --- /dev/null +++ b/migrations/versions/7d42_split_endpoints_deploy.py @@ -0,0 +1,151 @@ +"""Split endpoints.deploy into three independent scope grants. + +endpoints.deploy bundled three actions of very different risk behind one name: + + scheduledtask_run -> starts a task already registered and allowlisted agent-side + update_app -> runs a pinned installer + run_script -> ARBITRARY code as SYSTEM unless the descriptor pins a user + +Because they shared a grant, there was no way to let an operator push an +application update without also handing them arbitrary SYSTEM code execution on +every endpoint they could reach. The permission name described the lightest of +the three and silently carried the heaviest. + +commands.ELEVATED_ACTION_PERMISSIONS now maps each action to its own permission +(endpoints.corptools, endpoints.updateapp, endpoints.runscript). The enqueue +route reads that table, so no route logic changed. + +This migration preserves access EXACTLY. Anyone holding endpoints.deploy today +can already do all three, so every role holding it receives all three new +grants. Nothing widens: no role that lacked endpoints.deploy gains anything. The +security win is not in this migration - it is that from here the three can be +granted separately. + +endpoints.deploy itself is then removed. Leaving it behind would be worse than +useless: nothing reads it any more, but the role grid would keep offering it and +operators would keep ticking it expecting an effect. + +Revision ID: 7d42_split_endpoints_deploy +Revises: 7d41_drop_guest_tools_secrets +""" +from alembic import op + +import sqlalchemy as sa + + +revision = '7d42_split_endpoints_deploy' +down_revision = '7d41_drop_guest_tools_secrets' +branch_labels = None +depends_on = None + + +OLD_PERMISSION = 'endpoints.deploy' + +NEW_PERMISSIONS = [ + ('endpoints.corptools', + 'Start approved scheduled tasks on endpoints (limited to tasks already ' + 'registered and allowlisted on the agent)', + 'endpoints'), + ('endpoints.updateapp', + 'Push application installers to endpoints (runs a pinned installer as ' + 'SYSTEM)', + 'endpoints'), + ('endpoints.runscript', + 'Run staged scripts on endpoints (ARBITRARY CODE, executes as SYSTEM ' + 'unless the script pins a user context; grant only to fully trusted ' + 'operators)', + 'endpoints'), +] + + +def _tables_present(bind): + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + return {'roles', 'permissions', 'rolepermissions'} <= tables + + +def _ensure_permission(bind, name, description, category): + """Insert a permission if absent. Guarded in Python so the statement stays + portable - MySQL's FROM DUAL idiom does not run on SQLite, which the test + suite uses.""" + exists = bind.execute(sa.text( + 'SELECT 1 FROM permissions WHERE name = :name'), {'name': name}).first() + if exists: + return + bind.execute(sa.text( + 'INSERT INTO permissions (name, description, category) ' + 'VALUES (:name, :description, :category)'), + {'name': name, 'description': description, 'category': category}) + + +def upgrade(): + bind = op.get_bind() + if not _tables_present(bind): + return + + for name, description, category in NEW_PERMISSIONS: + _ensure_permission(bind, name, description, category) + + # Preserve access: every role holding the old bundle gets all three halves. + for name, _description, _category in NEW_PERMISSIONS: + bind.execute(sa.text( + 'INSERT INTO rolepermissions (roleid, permissionid) ' + 'SELECT rp.roleid, new.permissionid ' + 'FROM rolepermissions rp ' + 'JOIN permissions old ON old.permissionid = rp.permissionid ' + 'JOIN permissions new ON new.name = :newname ' + 'WHERE old.name = :oldname ' + 'AND NOT EXISTS (SELECT 1 FROM rolepermissions existing ' + ' WHERE existing.roleid = rp.roleid ' + ' AND existing.permissionid = new.permissionid)'), + {'newname': name, 'oldname': OLD_PERMISSION}) + + bind.execute(sa.text( + 'DELETE FROM rolepermissions WHERE permissionid IN ' + '(SELECT permissionid FROM permissions WHERE name = :oldname)'), + {'oldname': OLD_PERMISSION}) + bind.execute(sa.text( + 'DELETE FROM permissions WHERE name = :oldname'), + {'oldname': OLD_PERMISSION}) + + +def downgrade(): + """Rebuild the bundle and grant it to anyone holding any of the three. + + Deliberately generous in this direction: on the way back the only safe + reading of "held one of the three" is "held the bundle", because the bundle + is all-or-nothing. Downgrading therefore restores capability rather than + quietly removing it from an operator mid-shift. + """ + bind = op.get_bind() + if not _tables_present(bind): + return + + _ensure_permission( + bind, OLD_PERMISSION, + 'Run staged scripts, start approved scheduled tasks, and push ' + 'application installers to endpoints (scripts run as SYSTEM; grant ' + 'only to fully trusted operators)', + 'endpoints') + + newnames = [name for name, _d, _c in NEW_PERMISSIONS] + bind.execute(sa.text( + 'INSERT INTO rolepermissions (roleid, permissionid) ' + 'SELECT DISTINCT rp.roleid, old.permissionid ' + 'FROM rolepermissions rp ' + 'JOIN permissions new ON new.permissionid = rp.permissionid ' + 'JOIN permissions old ON old.name = :oldname ' + 'WHERE new.name IN :newnames ' + 'AND NOT EXISTS (SELECT 1 FROM rolepermissions existing ' + ' WHERE existing.roleid = rp.roleid ' + ' AND existing.permissionid = old.permissionid)' + ).bindparams(sa.bindparam('newnames', expanding=True)), + {'oldname': OLD_PERMISSION, 'newnames': newnames}) + + for name in newnames: + bind.execute(sa.text( + 'DELETE FROM rolepermissions WHERE permissionid IN ' + '(SELECT permissionid FROM permissions WHERE name = :name)'), + {'name': name}) + bind.execute(sa.text( + 'DELETE FROM permissions WHERE name = :name'), {'name': name}) diff --git a/migrations/versions/7d43_userpermissions.py b/migrations/versions/7d43_userpermissions.py new file mode 100644 index 00000000..46cfdc51 --- /dev/null +++ b/migrations/versions/7d43_userpermissions.py @@ -0,0 +1,59 @@ +"""Per-user direct permission grants. + +Permissions were reachable only through roles. At 5-7 operators per instance +that forces a role to be minted for every one-off capability - "Bob, and only +Bob, may push scripts" - until the role list is a mixture of job titles and +single-capability flags and nobody can tell which is which. + +A row here is an ORDINARY permission grant. It is identical in power to one held +through a role: it counts toward the API token scope ceiling, it is folded by +both User.haspermission() and User.getpermissions(), and it is revoked the same +way. There is deliberately no second class of permission. + +Nothing is granted by this migration. The table is created empty; existing +authority is untouched. + +Revision ID: 7d43_userpermissions +Revises: 7d42_split_endpoints_deploy +""" +from alembic import op + +import sqlalchemy as sa + + +revision = '7d43_userpermissions' +down_revision = '7d42_split_endpoints_deploy' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + if 'userpermissions' in set(inspector.get_table_names()): + return + + op.create_table( + 'userpermissions', + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('permissionid', sa.Integer(), nullable=False), + # Cascade on both sides: a direct grant is meaningless once either end + # is gone, and leaving orphans would let a recycled id silently inherit + # someone else's capability. + sa.ForeignKeyConstraint(['userid'], ['users.userid'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['permissionid'], + ['permissions.permissionid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('userid', 'permissionid'), + ) + op.create_index('idx_userpermissions_userid', 'userpermissions', ['userid']) + + +def downgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + if 'userpermissions' not in set(inspector.get_table_names()): + return + op.drop_index('idx_userpermissions_userid', table_name='userpermissions') + op.drop_table('userpermissions') diff --git a/migrations/versions/7d44_application_monitorable.py b/migrations/versions/7d44_application_monitorable.py new file mode 100644 index 00000000..8e96e63b --- /dev/null +++ b/migrations/versions/7d44_application_monitorable.py @@ -0,0 +1,42 @@ +"""Add applications monitorable fields + +Backs the "monitorable" checkbox on the Application form: an app that the +endpoint agent can watch as a process monitor. A monitor needs something to look +for (the Windows image name) and something to start when it is missing (the +launch path), so the API refuses to save ismonitorable without both. + +The two text columns stay NULLABLE rather than carrying a constraint, because +every application row that existed before this migration has neither value and +must remain valid. The pairing rule is enforced in shopdb/core/api/applications.py +where it can return a useful message, not in the schema where it would only +produce an IntegrityError. + +Revision ID: 7d44_app_monitorable +Revises: 7d43_userpermissions +Create Date: 2026-09-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d44_app_monitorable' +down_revision = '7d43_userpermissions' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('applications', sa.Column( + 'ismonitorable', sa.Boolean(), nullable=True, server_default=sa.false(), + comment='Can be watched by the endpoint agent (needs launch path + process name)')) + op.add_column('applications', sa.Column( + 'monitorlaunchpath', sa.String(length=512), nullable=True)) + op.add_column('applications', sa.Column( + 'monitorprocessname', sa.String(length=255), nullable=True)) + + +def downgrade(): + op.drop_column('applications', 'monitorprocessname') + op.drop_column('applications', 'monitorlaunchpath') + op.drop_column('applications', 'ismonitorable') diff --git a/migrations/versions/7d45_purge_inactive_apps.py b/migrations/versions/7d45_purge_inactive_apps.py new file mode 100644 index 00000000..1cd933ad --- /dev/null +++ b/migrations/versions/7d45_purge_inactive_apps.py @@ -0,0 +1,86 @@ +"""Purge soft-deleted applications that still reserve their names. + +Delete used to set isactive=False and leave the row in place. The uniqueness +check on create looks at every row regardless of isactive, so each of those +rows kept its name reserved permanently: creating an application with the same +name failed with "already exists" against a row no screen would display, and +nothing in the UI could clear it. + +Delete is a hard delete now, but that only fixes rows deleted from here on. +This clears the ones already stranded, in every environment rather than just +the one where the bug was noticed. + +Dependent rows are cleared the same way the API does it, and for the same +reasons: appversions and computerinstalledapps are NOT NULL and meaningless +without their application, while knowledgebase.appid is nullable and the +article is somebody's writing, so it is unlinked rather than destroyed. + +Uploaded installers belonging to these rows are deliberately NOT touched. A +migration that deletes files cannot be rolled back and cannot be dry-run, and a +stray file is harmless next to that risk. Deletes made through the API do clean +them up. + +Revision ID: 7d45_purge_inactive_apps +Revises: 7d44_app_monitorable +""" + +from alembic import op +import sqlalchemy as sa + + +revision = '7d45_purge_inactive_apps' +down_revision = '7d44_app_monitorable' +branch_labels = None +depends_on = None + + +def _table_exists(bind, name): + """True when a table is present. + + The plugin tables are only there when the plugin is installed, so each is + checked before being touched. + """ + return sa.inspect(bind).has_table(name) + + +def upgrade(): + bind = op.get_bind() + + if not _table_exists(bind, 'applications'): + return + + doomed = [row[0] for row in bind.execute(sa.text( + 'SELECT appid FROM applications WHERE isactive = false' + ))] + + if not doomed: + return + + for appid in doomed: + params = {'appid': appid} + + if _table_exists(bind, 'knowledgebase'): + bind.execute(sa.text( + 'UPDATE knowledgebase SET appid = NULL WHERE appid = :appid' + ), params) + + if _table_exists(bind, 'computerinstalledapps'): + bind.execute(sa.text( + 'DELETE FROM computerinstalledapps WHERE appid = :appid' + ), params) + + if _table_exists(bind, 'appversions'): + bind.execute(sa.text( + 'DELETE FROM appversions WHERE appid = :appid' + ), params) + + bind.execute(sa.text( + 'DELETE FROM applications WHERE appid = :appid' + ), params) + + +def downgrade(): + # The rows are gone and their contents were not captured, so there is + # nothing to restore. Failing loudly is better than pretending otherwise. + raise NotImplementedError( + 'Purged applications cannot be restored; recover from a backup.') diff --git a/migrations/versions/7d46_nest_app_packages.py b/migrations/versions/7d46_nest_app_packages.py new file mode 100644 index 00000000..70e6a628 --- /dev/null +++ b/migrations/versions/7d46_nest_app_packages.py @@ -0,0 +1,136 @@ +"""Move uploaded installers into per-application directories. + +Installers used to be stored flat as "application-.", which threw away +the name the vendor shipped. That is what made a re-import look like new +software: the operator had no way to see that the file already in the catalogue +was the same one they were about to upload again, so duplicate applications and +versions accumulated. Installers now keep their filename, namespaced by +application id so a generic "setup.exe" from two vendors cannot collide. + +That only helps files uploaded from here on. This moves the ones already stored, +so existing download links keep working instead of 404ing against a path the +route no longer serves. + +The original filename cannot be recovered - the old scheme discarded it at +upload - so these land as "installer.". That is no worse than what they +were called before, and re-uploading the installer restores the real name. + +Files are MOVED, not copied or deleted: a move is reversible, which is what +lets downgrade() put them back. This is deliberately unlike 7d45, which refused +to touch files because it could only have deleted them. + +The database is the part that must not be left inconsistent, so installpath is +rewritten for every affected row whether or not its file was found on disk. A +row pointing at a missing file is a broken download; a row pointing at the old +scheme is a broken download that also cannot be repaired by re-uploading. + +Revision ID: 7d46_nest_app_packages +Revises: 7d45_purge_inactive_apps +""" + +import os +import re + +from alembic import op +import sqlalchemy as sa + +revision = '7d46_nest_app_packages' +down_revision = '7d45_purge_inactive_apps' +branch_labels = None +depends_on = None + + +OLD_PREFIX = '/api/applications/package/' + +# application-., the only shape the old uploader produced. +OLD_NAME = re.compile(r'^application-(\d+)\.([A-Za-z0-9]+)$') + + +def _package_dir(): + """The instance-relative directory installers are stored in. + + Resolved the same way the API resolves it. If the app context is not + available the migration still rewrites the database and simply leaves the + files alone, rather than failing the whole upgrade over a path lookup. + """ + try: + from flask import current_app + return os.path.join(current_app.instance_path, 'applicationpackages') + except Exception: + return None + + +def upgrade(): + bind = op.get_bind() + rows = bind.execute(sa.text( + "SELECT appid, installpath FROM applications " + "WHERE installpath LIKE :prefix" + ), {'prefix': OLD_PREFIX + '%'}).fetchall() + + directory = _package_dir() + + for appid, installpath in rows: + tail = installpath[len(OLD_PREFIX):] + match = OLD_NAME.match(tail) + if not match: + # Already nested (contains a slash) or otherwise not the old shape. + # Leaving it alone is correct: it is already being served. + continue + + ext = match.group(2).lower() + stored = f'installer.{ext}' + + if directory: + source = os.path.join(directory, tail) + target_dir = os.path.join(directory, str(appid)) + if os.path.isfile(source): + os.makedirs(target_dir, exist_ok=True) + try: + os.replace(source, os.path.join(target_dir, stored)) + except OSError: + # Best effort. The row is still repointed below so the + # schema is consistent; a missing file is a re-upload. + pass + + bind.execute(sa.text( + "UPDATE applications SET installpath = :path WHERE appid = :appid" + ), {'path': f'{OLD_PREFIX}{appid}/{stored}', 'appid': appid}) + + +def downgrade(): + bind = op.get_bind() + rows = bind.execute(sa.text( + "SELECT appid, installpath FROM applications " + "WHERE installpath LIKE :prefix" + ), {'prefix': OLD_PREFIX + '%'}).fetchall() + + directory = _package_dir() + + for appid, installpath in rows: + tail = installpath[len(OLD_PREFIX):] + if '/' not in tail: + continue + nested_id, _, stored = tail.partition('/') + if nested_id != str(appid): + continue + + ext = os.path.splitext(stored)[1].lstrip('.').lower() + if not ext: + continue + flat = f'application-{appid}.{ext}' + + if directory: + source = os.path.join(directory, str(appid), stored) + if os.path.isfile(source): + try: + os.replace(source, os.path.join(directory, flat)) + # Remove the now-empty directory so a re-upgrade does not + # find a stale one. Only if empty: a directory with other + # files in it is not ours to remove. + os.rmdir(os.path.join(directory, str(appid))) + except OSError: + pass + + bind.execute(sa.text( + "UPDATE applications SET installpath = :path WHERE appid = :appid" + ), {'path': OLD_PREFIX + flat, 'appid': appid}) diff --git a/migrations/versions/7d47_endpoints_operatorreports.py b/migrations/versions/7d47_endpoints_operatorreports.py new file mode 100644 index 00000000..556756f3 --- /dev/null +++ b/migrations/versions/7d47_endpoints_operatorreports.py @@ -0,0 +1,97 @@ +"""Split operator report viewing out of endpoints.view. + +Operator reports carry a screenshot of whatever was on the operator's screen +when they filed the report. That is an uncontrolled human capture - it can +include part numbers, customer names, email, or anything else that happened to +be visible - whereas the rest of endpoints.view is uniform machine-collected +telemetry. Reading a PC's CPU load does not imply a right to read someone's +screen, so the reports and their images move to their own grant. + +Backfill preserves access rather than revoking it: every user who can see +endpoints today gets the new grant, so nobody loses a panel they were already +using. Tightening from there is an administrative decision made per account in +the user editor, not something this migration should force. The grant is a +per-user direct permission (endpoints.operatorreports is in the scope +denylist), so the backfill writes userpermissions rows, not role rows. + +Revision ID: 7d47_endpoints_operatorreports +Revises: 7d46_nest_app_packages +""" +from alembic import op + +import sqlalchemy as sa + + +revision = '7d47_endpoints_operatorreports' +down_revision = '7d46_nest_app_packages' +branch_labels = None +depends_on = None + + +NEW_PERMISSION = ( + 'endpoints.operatorreports', + 'View operator-submitted reports and their screenshots', + 'endpoints', +) + +SOURCE_PERMISSION = 'endpoints.view' + + +def _tables_present(bind): + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + return {'users', 'roles', 'permissions', 'rolepermissions', + 'userpermissions', 'userroles'} <= tables + + +def upgrade(): + bind = op.get_bind() + if not _tables_present(bind): + return + + name, description, category = NEW_PERMISSION + # Guarded in Python so the statement stays portable: MySQL's FROM DUAL + # idiom does not run on SQLite, which the test suite uses. + exists = bind.execute(sa.text( + 'SELECT 1 FROM permissions WHERE name = :name'), + {'name': name}).first() + if not exists: + bind.execute(sa.text( + 'INSERT INTO permissions (name, description, category) ' + 'VALUES (:name, :description, :category)'), + {'name': name, 'description': description, 'category': category}) + + # Preserve access for anyone who reaches operator reports today, which is + # anyone holding endpoints.view through a role. NOT EXISTS keeps the + # migration re-runnable and avoids duplicate rows for users in two + # qualifying roles. + bind.execute(sa.text( + 'INSERT INTO userpermissions (userid, permissionid) ' + 'SELECT DISTINCT ur.userid, new.permissionid ' + 'FROM userroles ur ' + 'JOIN rolepermissions rp ON rp.roleid = ur.roleid ' + 'JOIN permissions old ON old.permissionid = rp.permissionid ' + 'JOIN permissions new ON new.name = :newname ' + 'WHERE old.name = :oldname ' + 'AND NOT EXISTS (SELECT 1 FROM userpermissions existing ' + ' WHERE existing.userid = ur.userid ' + ' AND existing.permissionid = new.permissionid)'), + {'newname': name, 'oldname': SOURCE_PERMISSION}) + + +def downgrade(): + bind = op.get_bind() + if not _tables_present(bind): + return + + name = NEW_PERMISSION[0] + bind.execute(sa.text( + 'DELETE FROM userpermissions WHERE permissionid IN ' + '(SELECT permissionid FROM permissions WHERE name = :name)'), + {'name': name}) + bind.execute(sa.text( + 'DELETE FROM rolepermissions WHERE permissionid IN ' + '(SELECT permissionid FROM permissions WHERE name = :name)'), + {'name': name}) + bind.execute(sa.text( + 'DELETE FROM permissions WHERE name = :name'), {'name': name}) diff --git a/migrations/versions/7d48_script_workingdirectory.py b/migrations/versions/7d48_script_workingdirectory.py new file mode 100644 index 00000000..4aef61e7 --- /dev/null +++ b/migrations/versions/7d48_script_workingdirectory.py @@ -0,0 +1,41 @@ +"""Add scripts workingdirectory + +Backs the optional "run in this folder" field on a library script. The agent +otherwise runs a delivered script from its own staging directory, which is +correct for a self-contained script and wrong for one that shells out to a +corporate tool already installed on the endpoint (Sysinternals and similar, +delivered by Intune) and referenced by relative path. + +NULLABLE with no default, so every script row that existed before this +migration stays valid and keeps today's behaviour exactly: empty means the +staging directory. The path is validated in shopdb/core/api/scripts.py where a +rejection can explain itself, not in the schema where it would only produce an +IntegrityError. + +This column grants no capability. A script body is already arbitrary PowerShell +run as SYSTEM and can change its own directory; the boundary is the +endpoints.runscript grant and the digest-pinned library. + +Revision ID: 7d48_script_workingdir +Revises: 7d47_endpoints_operatorreports +Create Date: 2026-09-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d48_script_workingdir' +down_revision = '7d47_endpoints_operatorreports' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('scripts', sa.Column( + 'workingdirectory', sa.String(length=255), nullable=True, + comment='Folder to run the script in; empty means the agent staging dir')) + + +def downgrade(): + op.drop_column('scripts', 'workingdirectory') diff --git a/migrations/versions/7d49_script_errormode.py b/migrations/versions/7d49_script_errormode.py new file mode 100644 index 00000000..a8da063b --- /dev/null +++ b/migrations/versions/7d49_script_errormode.py @@ -0,0 +1,46 @@ +"""Add scripts errormode + +Backs the per-script "how should a non-terminating error be treated" field. + +PowerShell invoked with -File exits 0 whenever it reaches the end of the body, +even if every meaningful line inside wrote an error. The agent derives the +command verdict from that exit code, so a script could report SUCCEEDED while +doing none of its work - which is exactly how an account-provisioning script +came back green with the account only partly built. + +NOT NULL with server_default 'default', so every script row that existed before +this migration keeps today's permissive behaviour exactly. Strict is opt-in per +script and never retroactive: turning it on globally would change the meaning of +bodies that were authored under the permissive rule and deliberately tolerate a +failing Remove-Item, probe with Get-LocalUser, or loop past a bad item. Those +would begin aborting partway through, which is worse than a false green because +it leaves the endpoint half-configured. + +The value is validated in shopdb/core/api/scripts.py where a rejection can +explain itself, not in the schema where it would only produce an IntegrityError. + +Revision ID: 7d49_script_errormode +Revises: 7d48_script_workingdir +Create Date: 2026-09-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d49_script_errormode' +down_revision = '7d48_script_workingdir' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('scripts', sa.Column( + 'errormode', sa.String(length=16), nullable=False, + server_default='default', + comment="'default' keeps PowerShell's permissive behaviour; " + "'strict' stops on the first error so the exit code is honest")) + + +def downgrade(): + op.drop_column('scripts', 'errormode') diff --git a/migrations/versions/7d50_script_captureoutput.py b/migrations/versions/7d50_script_captureoutput.py new file mode 100644 index 00000000..1df3a48e --- /dev/null +++ b/migrations/versions/7d50_script_captureoutput.py @@ -0,0 +1,48 @@ +"""Add scripts captureoutput + +Backs the per-script "may a successful run send its output back" field. + +The agent already captures a script's full output and always writes it to the +endpoint log. What this controls is narrower: whether that output also crosses +to the server and is persisted in the command's resultjson, where the Run +Scripts history can show it. + +Failures have always sent output and still do - a failed run has to be +diagnosable without walking to the machine. Success is the case in question, +and it is opt-in because output carries whatever the body happened to print: +hostnames, share paths, an account name, a token an author echoed while +debugging. Accepting that exposure for every routine green run is a poor +trade, so the author decides per script, after reading the body. + +BOOLEAN NOT NULL defaulting FALSE, so every script row written before this +migration keeps exactly today's behaviour: metadata on success, output only on +failure. Same rule as errormode - never retroactive, because a library-wide +flip would start persisting output from bodies nobody re-read with this +question in mind. + +Revision ID: 7d50_script_captureoutput +Revises: 7d49_script_errormode +Create Date: 2026-09-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d50_script_captureoutput' +down_revision = '7d49_script_errormode' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('scripts', sa.Column( + 'captureoutput', sa.Boolean(), nullable=False, + server_default=sa.false(), + comment='When true, a successful run also returns its output to the ' + 'server; failures always do. Off by default because script ' + 'output can contain whatever the body printed.')) + + +def downgrade(): + op.drop_column('scripts', 'captureoutput') diff --git a/migrations/versions/7d51_endpoint_logcollections.py b/migrations/versions/7d51_endpoint_logcollections.py new file mode 100644 index 00000000..d80fa9b7 --- /dev/null +++ b/migrations/versions/7d51_endpoint_logcollections.py @@ -0,0 +1,92 @@ +"""Add endpointlogcollections + +Backs the manual Log Collector tab: one row per plaintext log file an operator +collected from an endpoint by path. + +Collected content gets its own table rather than living in +endpointcommands.resultjson for three reasons. It is bulky, and resultjson is +read by every command-history list that only wants a status. It needs its own +retention rule, which a command row cannot carry without deleting the audit +record of the command itself. And the download route has to address a single +collection by id and serve it as a file, which is awkward against a JSON column. + +Retention is PER ASSET (20, enforced on insert in the result route), not global. +The history renders on a single machine's detail page, so a global cap would let +one busy endpoint quietly evict the history of another - the panel would be +unreliable exactly where it is read. + +endpointcommandid is SET NULL rather than CASCADE: commands are reaped and +expired over time, and a collected log an operator is still reading must not +vanish because its originating command row was cleaned up. assetid stays CASCADE +because a log has no meaning once the machine is gone. + +filesizebytes is BIGINT while contentbytes is INTEGER on purpose - the file on +disk may be gigabytes, while what we store is a bounded tail. + +Revision ID: 7d51_endpoint_logcollections +Revises: 7d50_script_captureoutput +Create Date: 2026-09-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d51_endpoint_logcollections' +down_revision = '7d50_script_captureoutput' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'endpointlogcollections', + sa.Column('endpointlogcollectionid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('endpointcommandid', sa.Integer(), nullable=True), + sa.Column('sourcepath', sa.String(length=512), nullable=False, + comment='Path as the AGENT resolved it, which may differ ' + 'from what the operator typed once environment ' + 'variables are expanded endpoint-side.'), + sa.Column('content', sa.Text(length=16777215), nullable=True), + sa.Column('filesizebytes', sa.BigInteger(), nullable=True, + comment='Size of the file on disk. Differs from contentbytes ' + 'when the stored copy is a truncated tail.'), + sa.Column('contentbytes', sa.Integer(), nullable=True), + sa.Column('linecount', sa.Integer(), nullable=True), + sa.Column('istruncated', sa.Boolean(), nullable=False, + server_default=sa.false(), + comment='True when content is only the tail of a larger file.'), + sa.Column('filemodifiedat', sa.DateTime(), nullable=True), + sa.Column('collectedbyuserid', sa.Integer(), nullable=True), + sa.Column('collectedbyusername', sa.String(length=100), nullable=True, + comment='Username snapshot; the id alone becomes unreadable ' + 'if the user is later removed.'), + sa.Column('collectedat', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['endpointcommandid'], + ['endpointcommands.endpointcommandid'], + ondelete='SET NULL'), + sa.ForeignKeyConstraint(['collectedbyuserid'], ['users.userid'], + ondelete='SET NULL'), + sa.PrimaryKeyConstraint('endpointlogcollectionid'), + ) + op.create_index('ix_endpointlogcollections_assetid', + 'endpointlogcollections', ['assetid']) + op.create_index('ix_endpointlogcollections_collectedat', + 'endpointlogcollections', ['collectedat']) + # Serves both hot paths: listing an asset's history newest-first, and + # finding that asset's oldest rows to prune on insert. + op.create_index('idx_endpointlogcollections_assetcollected', + 'endpointlogcollections', ['assetid', 'collectedat']) + + +def downgrade(): + op.drop_index('idx_endpointlogcollections_assetcollected', + table_name='endpointlogcollections') + op.drop_index('ix_endpointlogcollections_collectedat', + table_name='endpointlogcollections') + op.drop_index('ix_endpointlogcollections_assetid', + table_name='endpointlogcollections') + op.drop_table('endpointlogcollections') diff --git a/plugins/applications/frontend/views/ApplicationDetail.vue b/plugins/applications/frontend/views/ApplicationDetail.vue index 2f31b286..5a558158 100644 --- a/plugins/applications/frontend/views/ApplicationDetail.vue +++ b/plugins/applications/frontend/views/ApplicationDetail.vue @@ -30,10 +30,7 @@ Printer App Hidden -

Links & Paths

@@ -103,7 +162,7 @@
- + Up to 500MB. Anything larger belongs on the share - put its path in - Install Path instead. Downloads require a login. + Installer path instead. Downloads require a login. + +
+ +
+ + + + + Read from the installer. Change it if the vendor numbers releases + differently. + + + Read from the installer where one is present. MSI and archive + files do not carry a version, so type it in to keep a later + re-import from looking like new software. +
@@ -181,11 +262,26 @@
{{ error }}
+
{{ versionWarning }}
+
Cancel + +
@@ -215,16 +311,32 @@ const form = ref({ islicenced: false, isprinter: false, isrequired: false, + ismonitorable: false, + monitorlaunchpath: '', + monitorprocessname: '', ishidden: false, applicationlink: '', documentationpath: '', installpath: '', image: '', - applicationnotes: '' + applicationnotes: '', + // Not a column on applications - it creates/updates an AppVersion row on + // save. Held here so the field can sit next to the installer that produced + // it, which is where the operator is looking when it matters. + version: '' }) const supportTeams = ref([]) +// The value as saved, so we can tell a NEW deployment from a re-save of an +// application that was already required. Only the former warrants a warning. +const wasRequired = ref(false) +const acknowledgePolicy = ref(false) + +const showsPolicyWarning = computed( + () => form.value.isrequired && !wasRequired.value +) + // Files are chosen before the application exists, so they are held here and // uploaded once there is an id to attach them to (see saveApplication). const pendingImage = ref(null) @@ -234,6 +346,12 @@ const uploadedPackage = ref('') const packageProgress = ref(null) const busyImage = ref(false) const busyPackage = ref(false) +const busyRevert = ref(false) +const detectedVersion = ref('') +const versionWarning = ref('') +// Versions already on the record, so saving an unchanged form does not try to +// re-create one and trip the duplicate check. +const existingVersions = ref([]) const PACKAGE_ACCEPT = '.exe,.msi,.msp,.zip,.7z,.cab,.iso,.appx,.msix,.ps1,.bat,.txt,.pdf' const MAX_PACKAGE_BYTES = 500 * 1024 * 1024 @@ -286,6 +404,60 @@ async function removePackage() { } } +// Strips the application back to a bare catalogue record. Distinct from Delete +// on the list: the row, name and knowledgebase links survive, so this is the +// action for "stop deploying this" rather than "this never existed". Still +// irreversible, so it uses the same typed-OK gate the backend enforces. +async function revertToRecord() { + const answer = prompt( + 'Remove all policy associations and uploaded executables? This action ' + + 'cannot be reversed. Type OK to proceed') + if ((answer || '').trim().toUpperCase() !== 'OK') return + + busyRevert.value = true + error.value = '' + try { + const response = await applicationsApi.revertToRecord( + route.params.id, (answer || '').trim()) + const app = response.data.data + // Re-seed from the server rather than guessing which fields it cleared. + // The image is deliberately untouched: revert removes executables and + // policy associations, not the record's icon. + form.value.installpath = app.installpath || '' + form.value.isrequired = app.isrequired + // Revert drops every AppVersion row, so the field must not keep showing a + // version that no longer exists. + form.value.version = '' + uploadedPackage.value = '' + pendingPackage.value = null + detectedVersion.value = '' + versionWarning.value = '' + } catch (err) { + error.value = apiError(err, 'Failed to revert the application') + } finally { + busyRevert.value = false + } +} + +// Records the typed version against the application, if it is new. Creating a +// version that already exists is not an error worth failing the save over - +// re-saving a form without touching the field is the normal case, and the +// backend answers 409, which would otherwise surface as "failed to save" on a +// save that in fact succeeded. +async function saveVersion(appid) { + const version = (form.value.version || '').trim() + if (!version) return + if (existingVersions.value.some(v => v.version === version)) return + + try { + await applicationsApi.createVersion(appid, { version }) + existingVersions.value = [{ version }, ...existingVersions.value] + } catch (err) { + if (err.response?.status === 409) return + throw err + } +} + // The image field holds either a served URL (new) or a bare filename from the // classic site, which is still rendered out of /images/applications/. function imageSrcFor(value) { @@ -314,14 +486,24 @@ onMounted(async () => { islicenced: app.islicenced || false, isprinter: app.isprinter || false, isrequired: app.isrequired || false, + ismonitorable: app.ismonitorable || false, + monitorlaunchpath: app.monitorlaunchpath || '', + monitorprocessname: app.monitorprocessname || '', ishidden: app.ishidden || false, applicationlink: app.applicationlink || '', documentationpath: app.documentationpath || '', installpath: app.installpath || '', image: app.image || '', - applicationnotes: app.applicationnotes || '' + applicationnotes: app.applicationnotes || '', + // The detail payload already carries versions newest-first, so the + // current one comes free rather than costing another request. + version: (app.versions && app.versions.length) + ? (app.versions[0].version || '') + : '' } + existingVersions.value = app.versions || [] imagePreview.value = imageSrcFor(app.image) + wasRequired.value = app.isrequired || false if ((app.installpath || '').startsWith('/api/applications/package/')) { uploadedPackage.value = app.installpath.split('/').pop() } @@ -336,6 +518,27 @@ onMounted(async () => { async function saveApplication() { error.value = '' + + if (showsPolicyWarning.value && !acknowledgePolicy.value) { + error.value = 'Please acknowledge that this will install the application ' + + 'on all matched PCs before saving.' + return + } + + // Mirrors _validate_monitorable on the server. Checked here too so the + // operator is told which field is missing while they are still looking at it, + // rather than after a round trip. + if (form.value.ismonitorable) { + if (!(form.value.monitorprocessname || '').trim()) { + error.value = 'A monitorable application needs a process name.' + return + } + if (!(form.value.monitorlaunchpath || '').trim()) { + error.value = 'A monitorable application needs a launch path.' + return + } + } + saving.value = true try { @@ -347,12 +550,22 @@ async function saveApplication() { islicenced: form.value.islicenced, isprinter: form.value.isprinter, isrequired: form.value.isrequired, + ismonitorable: form.value.ismonitorable, + // Cleared when the box is unticked so a later re-tick cannot silently + // revive a stale process name the operator can no longer see. + monitorlaunchpath: form.value.ismonitorable + ? (form.value.monitorlaunchpath || null) + : null, + monitorprocessname: form.value.ismonitorable + ? (form.value.monitorprocessname || null) + : null, ishidden: form.value.ishidden, applicationlink: form.value.applicationlink || null, documentationpath: form.value.documentationpath || null, installpath: form.value.installpath || null, image: form.value.image || null, - applicationnotes: form.value.applicationnotes || null + applicationnotes: form.value.applicationnotes || null, + acknowledgepolicy: showsPolicyWarning.value } let appid = route.params.id @@ -372,12 +585,33 @@ async function saveApplication() { } if (pendingPackage.value) { packageProgress.value = 0 - await applicationsApi.uploadPackage(appid, pendingPackage.value, - percent => { packageProgress.value = percent }) + const uploaded = await applicationsApi.uploadPackage( + appid, pendingPackage.value, + percent => { packageProgress.value = percent }) pendingPackage.value = null packageProgress.value = null + + // An installer with no readable version resource is how duplicate + // version rows got created: the operator saved, saw no version, and + // imported the same file again. MSIs and archives are not PE files and + // never carry one, so ask rather than guess. + detectedVersion.value = uploaded.data.data.detectedversion || '' + if (detectedVersion.value && !form.value.version.trim()) { + form.value.version = detectedVersion.value + } + if (!form.value.version.trim()) { + versionWarning.value = + `No version could be read from ${uploaded.data.data.uploadedfilename}. ` + + 'Enter one in the Version field above so a later import is not ' + + 'mistaken for new software.' + // The application and installer are already saved; only the version is + // outstanding, so stay on the form rather than losing that work. + return + } } + await saveVersion(appid) + router.push('/applications') } catch (err) { console.error('Error saving application:', err) @@ -449,4 +683,39 @@ async function saveApplication() { font-size: 0.8rem; color: var(--text-light, #666); } + +.policy-warning { + margin-top: 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--warning); + border-left-width: 4px; + border-radius: 4px; + /* Derived from the real token rather than a fixed tint: a hardcoded light + value here survives the theme switch and renders light-on-light in dark + mode. --warning-bg does not exist in style.css. */ + background: color-mix(in srgb, var(--warning) 12%, var(--bg-card-solid)); + color: var(--text); +} + +.policy-warning p { + margin: 0.5rem 0 0.75rem; + font-size: 0.9rem; +} + +.policy-warning label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-weight: 600; +} + +.monitor-fields { + margin-top: 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-left: 4px solid var(--warning); + border-radius: 4px; + background: color-mix(in srgb, var(--warning) 6%, var(--bg-card-solid)); +} diff --git a/plugins/applications/frontend/views/ApplicationsList.vue b/plugins/applications/frontend/views/ApplicationsList.vue index d8a5e011..76bfaae9 100644 --- a/plugins/applications/frontend/views/ApplicationsList.vue +++ b/plugins/applications/frontend/views/ApplicationsList.vue @@ -44,9 +44,6 @@ ⬇ - - 🔗 - @@ -73,6 +70,13 @@ > View + @@ -98,14 +102,21 @@ diff --git a/plugins/computers/frontend/views/PCTypesList.vue b/plugins/computers/frontend/views/PCTypesList.vue index 5afef7e4..b21e6ccc 100644 --- a/plugins/computers/frontend/views/PCTypesList.vue +++ b/plugins/computers/frontend/views/PCTypesList.vue @@ -107,8 +107,9 @@ onMounted(() => loadData()) async function loadData() { loading.value = true try { - const response = await computersApi.types.listAll({ active: false }) - pcTypes.value = response.data.data || [] + // listAll() pages past the server's 100-row cap and returns the ROWS, not + // an axios response - there is no .data to unwrap. + pcTypes.value = await computersApi.types.listAll({ active: false }) } catch (err) { console.error('Error loading PC types:', err) } finally { diff --git a/plugins/computers/frontend/views/PCsList.vue b/plugins/computers/frontend/views/PCsList.vue index 18a2b14a..f4fa5c44 100644 --- a/plugins/computers/frontend/views/PCsList.vue +++ b/plugins/computers/frontend/views/PCsList.vue @@ -33,6 +33,7 @@ Asset Tag + Alias Hostname Serial Number PC Type @@ -45,6 +46,7 @@ {{ item.assetnumber }} + {{ item.name || '-' }} {{ item.computer?.hostname || '-' }} {{ item.serialnumber || '-' }} {{ item.computer?.computertypename || '-' }} diff --git a/plugins/computers/frontend/views/ScopeNodeEditor.vue b/plugins/computers/frontend/views/ScopeNodeEditor.vue index 439a43ed..f93d8e65 100644 --- a/plugins/computers/frontend/views/ScopeNodeEditor.vue +++ b/plugins/computers/frontend/views/ScopeNodeEditor.vue @@ -2,7 +2,8 @@
+
+ +
+ Bulk Agent Update utility coming soon. Until then, agents update through + the existing installer push, and an endpoint behind the published build is + flagged on its own detail page. +
+ @@ -87,10 +103,13 @@ Status + Alias Hostname Machine PC Type Health + Agent Version + Credential Logged-in User Uptime Last Reported @@ -110,6 +129,7 @@ {{ statusLabel(item.status) }} + {{ item.alias || '-' }} {{ item.hostname || '-' }} +
+
{{ migrationError }}
+ +
+ +
+ +
+
+
+
+ + {{ migrationCounts.enrolled || 0 }} of {{ migrationTotal }} enrolled + +
+ +
+ + +
+ +
+ {{ selected.length }} selected + + +
+ +
+
Loading...
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + HostnameStateAgent versionLast reportedBlockers
No computers match.
+ + {{ row.hostname || '-' }}{{ stateLabel(row.state) }}{{ row.agentversion || '-' }}{{ formatDate(row.lastreportedat) }} + {{ (row.blockers || []).join(', ') || '-' }} + + Retry after fixing trust + +
+
+ +
+ +

Waves

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WaveCreatedBySizeQueuedEnrolledFailedProgress
No waves yet.
{{ wave.waveid }}{{ formatDate(wave.createdat) }}{{ wave.createdby || '-' }}{{ wave.size }}{{ wave.queued }}{{ wave.enrolled }}{{ wave.failed }} +
+
+
+ {{ wave.done ? 'Done' : 'In progress' }} +
+
+
+
+ + +
+
{{ enrollmentError }}
+ +
+

Settings

+ + + + +
+ +
+
+ {{ enrollmentCounts.enrolled || 0 }} + Enrolled +
+
+ {{ enrollmentCounts.notenrolled || 0 }} + Not enrolled +
+
+ {{ enrollmentCounts.pending || 0 }} + Pending requests +
+
+ +

Requests

+
+
+ + + + + + + + + + + + + + + + + + + +
HostnameSerialDevice idReasonStatusSourceLast seen
No enrollment requests.
+
+
+ +

Computers

+
+ + +
+
+
Loading...
+
+ + + + + + + + + + + + + + + + + + + +
HostnameSerialStateCredentialIssuedLast usedVia
No computers match.
+
+ +
+
+
+ + + + + diff --git a/plugins/endpoints/frontend/views/ServerCertSettings.vue b/plugins/endpoints/frontend/views/ServerCertSettings.vue new file mode 100644 index 00000000..6132e3e7 --- /dev/null +++ b/plugins/endpoints/frontend/views/ServerCertSettings.vue @@ -0,0 +1,640 @@ + + + + + diff --git a/plugins/endpoints/frontend/views/SystemScriptsSettings.vue b/plugins/endpoints/frontend/views/SystemScriptsSettings.vue new file mode 100644 index 00000000..4a042eae --- /dev/null +++ b/plugins/endpoints/frontend/views/SystemScriptsSettings.vue @@ -0,0 +1,443 @@ + + + + + diff --git a/plugins/endpoints/frontend/views/agentBootstrapInstall.spec.js b/plugins/endpoints/frontend/views/agentBootstrapInstall.spec.js new file mode 100644 index 00000000..89120405 --- /dev/null +++ b/plugins/endpoints/frontend/views/agentBootstrapInstall.spec.js @@ -0,0 +1,210 @@ +/** + * One-line install block on the endpoints agent key page. + * + * The token is shown exactly once, so the two things worth pinning are that the + * command rendered is the one the server returned, and that reloading the page + * does NOT put a token back on screen - the list read carries no secret, and a + * field that repopulated from it would be lying about what is recoverable. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises, RouterLinkStub } from '@vue/test-utils' + +vi.mock('@/api', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } +})) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) + +import api from '@/api' +import AgentKeySettings from './AgentKeySettings.vue' + +const TOKENS_URL = '/endpoints/siteagent/bootstrap/tokens' +const SECRET = 'sdbb_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG' +const SCRIPT_URL = + `https://shopdb.test/shopdb/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET}` +// Download then run: Defender kills the `irm ... | iex` shape before it runs. +const TEMP = 'C:\\Windows\\Temp\\shopdb-bootstrap.ps1' +const COMMAND = + 'powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest ' + + `-UseBasicParsing '${SCRIPT_URL}' -OutFile '${TEMP}'; & '${TEMP}' }` + + ` finally { Remove-Item '${TEMP}' -Force -ErrorAction SilentlyContinue }"` + +const MINTED_TOKEN = { + tokenid: 7, + tokenprefix: 'sdbb_012345', + createdat: '2026-09-15T10:00:00Z', + expiresat: '2026-09-16T10:00:00Z', + maxuses: 25, + usecount: 0, + remaining: 25, + revokedat: null, + state: 'active' +} + +function listResponse(tokens) { + return { data: { data: { tokens } } } +} + +async function render(tokens = []) { + api.get.mockImplementation((path) => { + if (path === TOKENS_URL) return Promise.resolve(listResponse(tokens)) + if (path === '/endpoints/agentkey') { + return Promise.resolve({ data: { data: { key: null, envkeyfallback: false } } }) + } + return Promise.resolve({ data: { data: {} } }) + }) + const wrapper = mount(AgentKeySettings, { + global: { stubs: { RouterLink: RouterLinkStub } } + }) + await flushPromises() + return wrapper +} + +function generateButton(wrapper) { + return wrapper.findAll('button') + .find((button) => button.text() === 'Generate install command') +} + +describe('AgentKeySettings one-line install', () => { + beforeEach(() => { + api.get.mockReset() + api.post.mockReset() + api.delete.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('says the PC waits for approval and that managed bays use GE-Enforce', async () => { + const wrapper = await render() + const block = wrapper.find('.bootstrap-block').text() + expect(block).toContain('One-line install') + expect(block).toMatch(/waits for approval in\s+Settings > Endpoints > Enrollment/) + expect(block).toContain('GE-Enforce') + expect(block).toContain('elevated') + }) + + it('generate posts the hours and maximum uses', async () => { + api.post.mockResolvedValue({ + data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } } + }) + const wrapper = await render() + + await wrapper.find('#bootstrap-hours').setValue(48) + await wrapper.find('#bootstrap-maxuses').setValue(5) + await generateButton(wrapper).trigger('click') + await flushPromises() + + expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 48, maxuses: 5 }) + }) + + it('defaults to 24 hours and 25 uses', async () => { + api.post.mockResolvedValue({ + data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } } + }) + const wrapper = await render() + + expect(wrapper.find('#bootstrap-hours').element.value).toBe('24') + expect(wrapper.find('#bootstrap-maxuses').element.value).toBe('25') + + await generateButton(wrapper).trigger('click') + await flushPromises() + expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 24, maxuses: 25 }) + }) + + it('renders the returned command, the once-only warning and the expiry', async () => { + api.post.mockResolvedValue({ + data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } } + }) + const wrapper = await render() + await generateButton(wrapper).trigger('click') + await flushPromises() + + const field = wrapper.find('#bootstrap-command') + expect(field.exists()).toBe(true) + expect(field.element.value).toBe(COMMAND) + expect(field.element.value).toContain(SECRET) + expect(field.attributes('readonly')).toBeDefined() + const block = wrapper.find('.bootstrap-block').text() + expect(block).toContain('The token is shown once and cannot be recovered') + expect(block).toContain('25 of') + expect(block).toContain('uses remaining') + }) + + it('offers the same token as a Download .ps1 link', async () => { + api.post.mockResolvedValue({ + data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } } + }) + const wrapper = await render() + expect(wrapper.find('.bootstrap-download').exists()).toBe(false) + + await generateButton(wrapper).trigger('click') + await flushPromises() + + const link = wrapper.find('.bootstrap-download') + expect(link.attributes('href')).toBe(SCRIPT_URL) + expect(link.attributes('download')).toBe('shopdb-bootstrap.ps1') + expect(wrapper.find('#bootstrap-command').element.value).not.toContain(' | iex') + }) + + it('copies the command to the clipboard', async () => { + const writeText = vi.fn(() => Promise.resolve()) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + api.post.mockResolvedValue({ + data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } } + }) + const wrapper = await render() + await generateButton(wrapper).trigger('click') + await flushPromises() + + const copy = wrapper.findAll('button').find((button) => button.text() === 'Copy') + await copy.trigger('click') + await flushPromises() + + expect(writeText).toHaveBeenCalledWith(COMMAND) + expect(wrapper.find('.bootstrap-block').text()).toContain('Copied') + }) + + it('lists live tokens and revoke posts to that token', async () => { + api.post.mockResolvedValue({ data: { data: { token: MINTED_TOKEN } } }) + const wrapper = await render([MINTED_TOKEN]) + + expect(api.get).toHaveBeenCalledWith(TOKENS_URL) + const rows = wrapper.findAll('.bootstrap-block tbody tr') + expect(rows).toHaveLength(1) + expect(rows[0].text()).toContain('sdbb_012345') + expect(rows[0].text()).toContain('0 / 25') + + const revoke = wrapper.findAll('button').find((button) => button.text() === 'Revoke') + await revoke.trigger('click') + await flushPromises() + + expect(api.post).toHaveBeenCalledWith(`${TOKENS_URL}/7/revoke`) + }) + + it('offers no Revoke on a token that is already spent', async () => { + const wrapper = await render([ + { ...MINTED_TOKEN, state: 'revoked', usecount: 2 } + ]) + expect(wrapper.findAll('button').find((button) => button.text() === 'Revoke')) + .toBeUndefined() + }) + + it('does not show the secret again after a reload', async () => { + api.post.mockResolvedValue({ + data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } } + }) + const first = await render() + await generateButton(first).trigger('click') + await flushPromises() + expect(first.find('#bootstrap-command').exists()).toBe(true) + + // The reload sees the token in the list read, which carries no secret. + const reloaded = await render([MINTED_TOKEN]) + expect(reloaded.find('#bootstrap-command').exists()).toBe(false) + expect(reloaded.text()).not.toContain(SECRET) + expect(reloaded.text()).not.toContain('The token is shown once') + // The row is still there, by prefix only. + expect(reloaded.find('.bootstrap-block tbody tr').text()).toContain('sdbb_012345') + }) +}) diff --git a/plugins/endpoints/frontend/views/agentKeySettings.spec.js b/plugins/endpoints/frontend/views/agentKeySettings.spec.js new file mode 100644 index 00000000..526d4fc6 --- /dev/null +++ b/plugins/endpoints/frontend/views/agentKeySettings.spec.js @@ -0,0 +1,133 @@ +/** + * Endpoints (shared key) settings page. + * + * The shared key only matters to PCs that have not enrolled, so the page must + * say how far enrollment has got, and the regenerate and revoke confirms must + * name how many PCs the change actually reaches. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises, RouterLinkStub } from '@vue/test-utils' + +vi.mock('@/api', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } +})) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) + +import api from '@/api' +import AgentKeySettings from './AgentKeySettings.vue' + +const KEY = { tokenprefix: 'abcd', createddate: null, lastusedat: null } + +function enrollmentPayload(settings = { sharedkeyallowed: true, autoenroll: true }) { + return { + data: { + data: { + settings, + counts: { enrolled: 30, notenrolled: 8, pendingcomputers: 2, pending: 3 }, + computers: [], + requests: [ + { requestid: 1, status: 'pending', reason: 'serial mismatch' }, + { requestid: 2, status: 'pending', reason: 'awaiting approval' }, + { requestid: 3, status: 'pending', reason: 'reenroll blocked' }, + { requestid: 4, status: 'issued', reason: null } + ] + } + } + } +} + +async function render(payload = enrollmentPayload()) { + api.get.mockImplementation((path) => { + if (path === '/endpoints/agentkey') { + return Promise.resolve({ data: { data: { key: KEY, envkeyfallback: false } } }) + } + if (path === '/endpoints/enrollment') return Promise.resolve(payload) + return Promise.resolve({ data: { data: {} } }) + }) + api.post.mockResolvedValue({ data: { data: { key: KEY } } }) + api.delete.mockResolvedValue({ data: { data: { key: null } } }) + const wrapper = mount(AgentKeySettings, { global: { stubs: { RouterLink: RouterLinkStub } } }) + await flushPromises() + return wrapper +} + +describe('AgentKeySettings shared key section', () => { + beforeEach(() => { + api.get.mockReset() + api.post.mockReset() + api.delete.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('titles the section for the per-PC model', async () => { + const wrapper = await render() + expect(wrapper.text()).toContain('Shared key (used until PCs enroll)') + expect(wrapper.text()).not.toContain('Key management') + expect(wrapper.text()).toMatch(/Enrolled PCs ignore it/) + expect(wrapper.text()).toMatch(/At cutover/) + }) + + it('shows enrollment progress from one small enrollment read', async () => { + const wrapper = await render() + expect(api.get).toHaveBeenCalledWith('/endpoints/enrollment', { params: { perpage: 1 } }) + const summary = wrapper.find('.enrollment-summary').text() + expect(summary).toContain('30 of 40 PCs enrolled') + expect(summary).toContain('10 still on the shared key') + // A blocked PC asking again is not a request anyone has to decide. + expect(summary).toContain('2 pending enrollment requests') + const link = wrapper.findComponent(RouterLinkStub) + expect(link.props('to')).toEqual({ name: 'endpoints-enrollment-settings' }) + }) + + it('regenerate confirm names the PCs that need the new key', async () => { + const confirmSpy = vi.fn(() => true) + vi.stubGlobal('confirm', confirmSpy) + const wrapper = await render() + const button = wrapper.findAll('button').find(b => b.text() === 'Regenerate key') + await button.trigger('click') + await flushPromises() + expect(confirmSpy.mock.calls[0][0]).toMatch(/10 PCs not enrolled will need the new key/) + expect(confirmSpy.mock.calls[0][0]).toMatch(/Enrolled PCs are not affected/) + expect(api.post).toHaveBeenCalledWith('/endpoints/agentkey/regenerate') + }) + + it('revoke confirm names the PCs that lose access', async () => { + const confirmSpy = vi.fn(() => true) + vi.stubGlobal('confirm', confirmSpy) + const wrapper = await render() + const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key') + await button.trigger('click') + await flushPromises() + expect(confirmSpy.mock.calls[0][0]).toMatch(/10 PCs not enrolled will lose access/) + expect(api.delete).toHaveBeenCalledWith('/endpoints/agentkey') + }) + + it('sends nothing when revoke is declined', async () => { + vi.stubGlobal('confirm', vi.fn(() => false)) + const wrapper = await render() + const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key') + await button.trigger('click') + await flushPromises() + expect(api.delete).not.toHaveBeenCalled() + }) + + it('still manages the key when the enrollment read fails', async () => { + api.get.mockImplementation((path) => { + if (path === '/endpoints/enrollment') return Promise.reject(new Error('403')) + if (path === '/endpoints/agentkey') { + return Promise.resolve({ data: { data: { key: KEY, envkeyfallback: false } } }) + } + return Promise.resolve({ data: { data: {} } }) + }) + vi.stubGlobal('confirm', vi.fn(() => true)) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const wrapper = mount(AgentKeySettings, { global: { stubs: { RouterLink: RouterLinkStub } } }) + await flushPromises() + expect(wrapper.find('.enrollment-summary').exists()).toBe(false) + const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key') + expect(button.exists()).toBe(true) + }) +}) diff --git a/plugins/endpoints/frontend/views/endpointsList.spec.js b/plugins/endpoints/frontend/views/endpointsList.spec.js new file mode 100644 index 00000000..ca34dce9 --- /dev/null +++ b/plugins/endpoints/frontend/views/endpointsList.spec.js @@ -0,0 +1,99 @@ +/** + * Fleet list Credential column. + * + * The state is computed by the list route so that filtering by it survives + * pagination; the page must send the filter to the server rather than narrowing + * the rows it already has, and must render the server's value, not derive one. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { ref } from 'vue' + +vi.mock('@/api', () => ({ + endpointsApi: { + list: vi.fn(), + dashboardSummary: vi.fn(), + dashboardStability: vi.fn(), + enqueueCommand: vi.fn() + }, + computersApi: { types: { listAll: vi.fn(() => Promise.resolve([])) } }, + locationsApi: { listAll: vi.fn(() => Promise.resolve([])) } +})) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ isAdmin: true, hasPermission: () => true }) +})) +vi.mock('@/composables/toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }) +})) +vi.mock('@/composables/listQuery', () => ({ + useListQuery: () => ({ + page: ref(1), search: ref(''), setPage: vi.fn(), setSearch: vi.fn() + }) +})) +vi.mock('@/components/PaginationBar.vue', () => ({ + default: { name: 'PaginationBar', template: '
' } +})) + +import { endpointsApi } from '@/api' +import EndpointsList from './EndpointsList.vue' + +function row(assetid, hostname, credentialstate) { + return { + assetid, hostname, computerid: assetid, status: 'reporting', machines: [], + health: null, credentialstate + } +} + +beforeEach(() => { + vi.clearAllMocks() + endpointsApi.dashboardSummary.mockResolvedValue({ data: { data: null } }) + endpointsApi.dashboardStability.mockResolvedValue({ data: { data: [] } }) + endpointsApi.list.mockResolvedValue({ + data: { + data: [ + row(1, 'PC-ENROLLED', 'enrolled'), + row(2, 'PC-PENDING', 'pending'), + row(3, 'PC-BLOCKED', 'reenrollblocked'), + row(4, 'PC-SHARED', 'sharedkey'), + row(5, 'PC-SILENT', 'notreporting') + ], + meta: { pagination: { totalpages: 1 } } + } + }) +}) + +describe('EndpointsList credential column', () => { + it('renders a chip per server state', async () => { + const wrapper = mount(EndpointsList) + await flushPromises() + + const chips = wrapper.findAll('.credential-chip').map(chip => chip.text()) + expect(chips).toEqual([ + 'Enrolled', 'Pending', 'Re-enroll blocked', 'Shared key (not enrolled)', + 'Not reporting' + ]) + expect(wrapper.find('.credential-chip.reenrollblocked').exists()).toBe(true) + expect(wrapper.findAll('th').map(th => th.text())).toContain('Credential') + }) + + it('sends the credential filter to the server', async () => { + const wrapper = mount(EndpointsList) + await flushPromises() + + const select = wrapper.findAll('select.fleet-filter') + .find(element => element.text().includes('All credentials')) + await select.setValue('sharedkey') + await flushPromises() + + const params = endpointsApi.list.mock.calls.at(-1)[0] + expect(params.credentialstate).toBe('sharedkey') + expect(params.perpage).toBe(20) + }) + + it('offers no credential actions on the list', async () => { + const wrapper = mount(EndpointsList) + await flushPromises() + expect(wrapper.text()).not.toMatch(/Revoke|Allow re-enrollment/) + }) +}) diff --git a/plugins/endpoints/frontend/views/enrollmentSettings.spec.js b/plugins/endpoints/frontend/views/enrollmentSettings.spec.js new file mode 100644 index 00000000..98b5ec84 --- /dev/null +++ b/plugins/endpoints/frontend/views/enrollmentSettings.spec.js @@ -0,0 +1,345 @@ +/** + * Enrollment settings page (per-PC credentials). + * + * Pins the wire shapes the page sends, because the server contract is exact: + * a wave is {"next": n} or {"assetids": [...]}, an approval carries the chosen + * assetid, and turning the shared key off must tell the admin how many PCs are + * not enrolled yet before anything is sent. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +vi.mock('@/api', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn() }, + computersApi: { list: vi.fn() } +})) +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ isAdmin: true, hasPermission: () => true }) +})) +vi.mock('@/composables/toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }) +})) +vi.mock('@/components/PaginationBar.vue', () => ({ + default: { name: 'PaginationBar', template: '
' } +})) + +import api, { computersApi } from '@/api' +import EnrollmentSettings from './EnrollmentSettings.vue' + +function migrationPayload() { + return { + data: { + data: { + target: '1.20.0', + counts: { enrolled: 40, pending: 2, upgrading: 3, ready: 30, blocked: 5, notreporting: 20 }, + computers: [ + { assetid: 11, hostname: 'PC-READY', agentversion: '1.19.2', lastreportedat: null, state: 'ready', blockers: [] }, + { assetid: 12, hostname: 'PC-BLOCKED', agentversion: '1.18.0', lastreportedat: null, state: 'blocked', + blockers: ['offline', 'no serial on record'] }, + { assetid: 13, hostname: 'PC-TRUST', agentversion: '1.19.2', lastreportedat: null, state: 'blocked', + blockers: ['server certificate not trusted by this PC'] }, + { assetid: 14, hostname: 'PC-TRUST-OFFLINE', agentversion: '1.19.2', lastreportedat: null, state: 'blocked', + blockers: ['offline', 'server certificate not trusted by this PC'] } + ], + waves: [ + { waveid: 3, createdat: null, createdby: 'admin', size: 25, queued: 25, enrolled: 10, failed: 1, done: false } + ] + }, + meta: { pagination: { totalpages: 1 } } + } + } +} + +function enrollmentPayload() { + return { + data: { + data: { + settings: { sharedkeyallowed: true, autoenroll: true, + maxpendingenrollments: 500 }, + counts: { enrolled: 40, notenrolled: 58, pendingcomputers: 1, pending: 2 }, + computers: [ + { assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled', + credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto', + reenrollblocked: false, reenrollblockedreason: null }, + { assetid: 22, hostname: 'PC-ROLLEDBACK', serialnumber: 'S2', state: 'revoked', + credentialprefix: 'sdbe_hijklmn', issuedat: null, lastusedat: null, enrolledvia: 'auto', + reenrollblocked: true, reenrollblockedreason: 'rollback' } + ], + requests: [ + { requestid: 45, hostname: 'PC-NEW', serialnumber: 'S9', deviceid: null, reason: 'serial mismatch', + status: 'pending', matchedassetid: 77, requestedat: null, lastseenat: null, sourceip: null } + ] + }, + meta: { pagination: { totalpages: 1 } } + } + } +} + +async function render() { + api.get.mockImplementation(path => Promise.resolve( + path === '/endpoints/enrollment/migration' ? migrationPayload() : enrollmentPayload())) + api.post.mockResolvedValue({ data: { data: { waveid: 4, queued: [11], skipped: [] } } }) + api.put.mockResolvedValue({ data: { data: {} } }) + const wrapper = mount(EnrollmentSettings) + await flushPromises() + return wrapper +} + +async function openCredentials(wrapper) { + const tab = wrapper.findAll('.enroll-tab').find(button => button.text() === 'Credentials') + await tab.trigger('click') + await flushPromises() +} + +describe('EnrollmentSettings', () => { + beforeEach(() => { + api.get.mockReset() + api.post.mockReset() + api.put.mockReset() + computersApi.list.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renders the migration counts and enrolled progress', async () => { + const wrapper = await render() + expect(wrapper.find('[data-count="enrolled"]').text()).toBe('40') + expect(wrapper.find('[data-count="ready"]').text()).toBe('30') + expect(wrapper.find('[data-count="blocked"]').text()).toBe('5') + expect(wrapper.find('[data-count="notreporting"]').text()).toBe('20') + expect(wrapper.find('.progress-text').text()).toContain('40 of 100 enrolled') + }) + + it('shows every blocker for a blocked PC', async () => { + const wrapper = await render() + const row = wrapper.findAll('.migration-row').find(r => r.text().includes('PC-BLOCKED')) + expect(row.find('.blockers').text()).toBe('offline, no serial on record') + }) + + it('only offers a checkbox on ready rows', async () => { + const wrapper = await render() + const rows = wrapper.findAll('.migration-row') + expect(rows[0].find('input[type="checkbox"]').exists()).toBe(true) + expect(rows[1].find('input[type="checkbox"]').exists()).toBe(false) + }) + + it('offers a trust-only blocked PC for retry, not one with another blocker too', async () => { + const wrapper = await render() + const rows = wrapper.findAll('.migration-row') + const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE')) + const mixed = rows.find(r => r.text().includes('PC-TRUST-OFFLINE')) + expect(trust.find('input[type="checkbox"]').exists()).toBe(true) + expect(trust.find('.retry-hint').text()).toBe('Retry after fixing trust') + expect(mixed.find('input[type="checkbox"]').exists()).toBe(false) + expect(mixed.find('.retry-hint').exists()).toBe(false) + }) + + it('includes a trust-blocked PC in Upgrade selected', async () => { + vi.stubGlobal('confirm', vi.fn(() => true)) + const wrapper = await render() + const rows = wrapper.findAll('.migration-row') + await rows[0].find('input[type="checkbox"]').setValue(true) + const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE')) + await trust.find('input[type="checkbox"]').setValue(true) + await wrapper.find('.upgrade-selected').trigger('click') + await flushPromises() + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11, 13] }) + }) + + it('posts {"next":25} for Upgrade next 25 after a confirm naming the count', async () => { + const confirmSpy = vi.fn(() => true) + vi.stubGlobal('confirm', confirmSpy) + const wrapper = await render() + await wrapper.find('.upgrade-next').trigger('click') + await flushPromises() + expect(confirmSpy).toHaveBeenCalledTimes(1) + expect(confirmSpy.mock.calls[0][0]).toMatch(/Upgrade 25 PC\(s\) to endpoint agent 1\.20\.0/) + expect(confirmSpy.mock.calls[0][0]).toMatch(/enrolls automatically/) + expect(confirmSpy.mock.calls[0][0]).toMatch( + /older than 1\.20 must trust the server certificate before they can download the upgrade/) + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { next: 25 }) + }) + + it('sends nothing when the wave confirm is declined', async () => { + vi.stubGlobal('confirm', vi.fn(() => false)) + const wrapper = await render() + await wrapper.find('.upgrade-next').trigger('click') + await flushPromises() + expect(api.post).not.toHaveBeenCalled() + }) + + it('posts the selected assetids for Upgrade selected', async () => { + vi.stubGlobal('confirm', vi.fn(() => true)) + const wrapper = await render() + await wrapper.findAll('.migration-row')[0].find('input[type="checkbox"]').setValue(true) + await wrapper.find('.upgrade-selected').trigger('click') + await flushPromises() + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11] }) + }) + + it('approves a request with the matched asset by default', async () => { + vi.stubGlobal('confirm', vi.fn(() => true)) + const wrapper = await render() + await openCredentials(wrapper) + await wrapper.find('.approve-open').trigger('click') + expect(wrapper.find('.asset-picker').element.value).toBe('77') + await wrapper.find('.approve-confirm').trigger('click') + await flushPromises() + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', { assetid: 77 }) + }) + + // Approving a one-off PC onto a computer recorded under another hostname is + // refused by the server, because the PC authenticates by hostname afterwards. + // The page has to show that message and let the admin answer it, not swallow + // it as "could not approve". + function hostnameConflict() { + return { + response: { + status: 409, + data: { + data: { + error: { + code: 'CONFLICT', + message: 'That computer is recorded as PC-OLD, but the request comes from PC-NEW.', + details: { + hostnameconflict: true, + computerhostname: 'PC-OLD', + requesthostname: 'PC-NEW' + } + } + } + } + } + } + } + + it('offers to set the hostname when approve comes back 409, then re-posts', async () => { + const confirmSpy = vi.fn(() => true) + vi.stubGlobal('confirm', confirmSpy) + const wrapper = await render() + await openCredentials(wrapper) + api.post.mockReset() + api.post.mockRejectedValueOnce(hostnameConflict()) + api.post.mockResolvedValue({ data: { data: { hostnameresult: 'overwritten' } } }) + + await wrapper.find('.approve-open').trigger('click') + await wrapper.find('.approve-confirm').trigger('click') + await flushPromises() + + expect(api.post.mock.calls).toEqual([ + ['/endpoints/enrollment/requests/45/approve', { assetid: 77 }], + ['/endpoints/enrollment/requests/45/approve', { assetid: 77, sethostname: true }] + ]) + const asked = confirmSpy.mock.calls.at(-1)[0] + expect(asked).toContain('recorded as PC-OLD') + expect(asked).toContain('Set the hostname on this PC?') + }) + + it('sends nothing more when the hostname question is declined', async () => { + vi.stubGlobal('confirm', vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false)) + const wrapper = await render() + await openCredentials(wrapper) + api.post.mockReset() + api.post.mockRejectedValue(hostnameConflict()) + + await wrapper.find('.approve-open').trigger('click') + await wrapper.find('.approve-confirm').trigger('click') + await flushPromises() + + expect(api.post).toHaveBeenCalledTimes(1) + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', + { assetid: 77 }) + }) + + it('rejects a request after confirm', async () => { + vi.stubGlobal('confirm', vi.fn(() => true)) + const wrapper = await render() + await openCredentials(wrapper) + await wrapper.find('.reject').trigger('click') + await flushPromises() + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/reject') + }) + + it('un-enrolls through the revoke route', async () => { + const wrapper = await render() + await openCredentials(wrapper) + await wrapper.find('.revoke').trigger('click') + expect(api.post).not.toHaveBeenCalled() + expect(wrapper.find('.revoke-row').text()).toContain('Keep this PC from enrolling again') + expect(wrapper.find('.revoke-block').element.checked).toBe(false) + await wrapper.find('.revoke-confirm').trigger('click') + await flushPromises() + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke') + }) + + it('sends blockreenroll with the reason when the block box is ticked', async () => { + const wrapper = await render() + await openCredentials(wrapper) + await wrapper.find('.revoke').trigger('click') + await wrapper.find('.revoke-block').setValue(true) + await wrapper.find('.revoke-reason').setValue(' rollback ') + await wrapper.find('.revoke-confirm').trigger('click') + await flushPromises() + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke', + { blockreenroll: true, reason: 'rollback' }) + }) + + it('shows the block chip and allows re-enrollment after confirm', async () => { + const confirmSpy = vi.fn(() => true) + vi.stubGlobal('confirm', confirmSpy) + const wrapper = await render() + await openCredentials(wrapper) + const rows = wrapper.findAll('.credential-row') + const blocked = rows.find(r => r.text().includes('PC-ROLLEDBACK')) + const enrolled = rows.find(r => r.text().includes('PC-ENROLLED')) + expect(blocked.find('.block-chip').text()).toBe('Re-enroll blocked') + expect(enrolled.find('.block-chip').exists()).toBe(false) + expect(enrolled.find('.allow-reenroll').exists()).toBe(false) + await blocked.find('.allow-reenroll').trigger('click') + await flushPromises() + expect(confirmSpy).toHaveBeenCalledTimes(1) + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/22/allow-reenroll') + }) + + it('shows the not-enrolled count before turning the shared key off', async () => { + const promptSpy = vi.fn(() => 'OFF') + vi.stubGlobal('prompt', promptSpy) + const wrapper = await render() + await openCredentials(wrapper) + await wrapper.find('.toggle-sharedkey').setValue(false) + await flushPromises() + expect(promptSpy).toHaveBeenCalledTimes(1) + expect(promptSpy.mock.calls[0][0]).toMatch(/58 PC\(s\) are not enrolled yet/) + expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings', + { sharedkeyallowed: false, autoenroll: true }) + }) + + it('leaves the shared key on when the confirmation is not typed', async () => { + vi.stubGlobal('prompt', vi.fn(() => 'yes')) + const wrapper = await render() + await openCredentials(wrapper) + const toggle = wrapper.find('.toggle-sharedkey') + await toggle.setValue(false) + await flushPromises() + expect(api.put).not.toHaveBeenCalled() + expect(toggle.element.checked).toBe(true) + }) + + it('saves a new pending request limit', async () => { + const wrapper = await render() + await openCredentials(wrapper) + const field = wrapper.find('.input-maxpending') + expect(field.element.value).toBe('500') + await field.setValue(25) + await field.trigger('change') + await flushPromises() + expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings', + { maxpendingenrollments: 25 }) + }) + + // The negative case is proved server side (tests/test_plugins/ + // test_endpoints_enroll_limits.py: PUT with -1 is a 400). Asserting it here + // would only be asserting what vue-test-utils does to a number input. +}) diff --git a/plugins/endpoints/frontend/views/serverCertSettings.spec.js b/plugins/endpoints/frontend/views/serverCertSettings.spec.js new file mode 100644 index 00000000..853317e6 --- /dev/null +++ b/plugins/endpoints/frontend/views/serverCertSettings.spec.js @@ -0,0 +1,98 @@ +/** + * Server Certificate live check. + * + * A PC pins the certificate the site presents, which a proxy or IIS binding can + * make different from the file this page manages. The page must show the + * presented pin, warn plainly when it differs, and let the admin recheck. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +vi.mock('@/api', () => ({ + default: { get: vi.fn(), post: vi.fn() } +})) + +import api from '@/api' +import ServerCertSettings from './ServerCertSettings.vue' + +const FILEPIN = 'a'.repeat(64) +const OTHERPIN = 'b'.repeat(64) + +function certificate() { + return { + data: { + status: 'success', + data: { + subject_cn: 'shopdb.example.test', issuer: 'shopdb.example.test', + not_before: null, not_after: null, thumbprint: 'AA:BB', + spkisha256: FILEPIN, sha1thumbprint: 'CC', san_list: [], + expires_in_days: 300, is_self_signed: true + } + } + } +} + +async function render(live) { + api.get.mockImplementation((path) => Promise.resolve( + path === '/endpoints/settings/server-cert/live' + ? { data: { status: 'success', data: live } } + : certificate())) + const wrapper = mount(ServerCertSettings) + await flushPromises() + return wrapper +} + +describe('ServerCertSettings live check', () => { + beforeEach(() => { + api.get.mockReset() + }) + + it('shows the presented pin in green when it matches the file', async () => { + const wrapper = await render({ + configured: true, url: 'https://shopdb.example.test', presentedspkisha256: FILEPIN, + presentedsha1thumbprint: 'CC', matchesfile: true, error: null + }) + const check = wrapper.find('.live-check') + expect(check.find('.live-match').text()).toContain(FILEPIN) + expect(check.text()).toContain('Matches the pin of the certificate file above') + expect(check.find('.live-warning').exists()).toBe(false) + }) + + it('warns in red with both pins when the site presents another certificate', async () => { + const wrapper = await render({ + configured: true, url: 'https://shopdb.example.test', presentedspkisha256: OTHERPIN, + presentedsha1thumbprint: 'DD', matchesfile: false, error: null + }) + const warning = wrapper.find('.live-warning') + expect(warning.exists()).toBe(true) + expect(warning.text()).toContain( + 'PCs will be pinned to the certificate they actually receive; use the presented pin') + expect(warning.text()).toContain(FILEPIN) + expect(warning.text()).toContain(OTHERPIN) + expect(wrapper.find('.live-mismatch').exists()).toBe(true) + }) + + it('says when no site address is configured', async () => { + const wrapper = await render({ configured: false }) + expect(wrapper.find('.live-unconfigured').exists()).toBe(true) + }) + + it('shows the check error', async () => { + const wrapper = await render({ + configured: true, url: 'https://shopdb.example.test', presentedspkisha256: null, + presentedsha1thumbprint: null, matchesfile: false, error: 'Could not read the certificate' + }) + expect(wrapper.find('.live-error').text()).toContain('Could not read the certificate') + }) + + it('rechecks on demand', async () => { + const wrapper = await render({ configured: false }) + const before = api.get.mock.calls.filter( + ([path]) => path === '/endpoints/settings/server-cert/live').length + await wrapper.find('.live-recheck').trigger('click') + await flushPromises() + const after = api.get.mock.calls.filter( + ([path]) => path === '/endpoints/settings/server-cert/live').length + expect(after).toBe(before + 1) + }) +}) diff --git a/plugins/endpoints/frontend/views/systemScriptsSettings.spec.js b/plugins/endpoints/frontend/views/systemScriptsSettings.spec.js new file mode 100644 index 00000000..d86dde65 --- /dev/null +++ b/plugins/endpoints/frontend/views/systemScriptsSettings.spec.js @@ -0,0 +1,124 @@ +/** + * System Scripts settings page. + * + * Two things are worth pinning here and nothing else is: + * + * 1. The page must NOT offer a run context. Scripts run as SYSTEM because the + * agent is a service with no user session; exposing a picker would let + * someone author a script that reads as "run as the logged-on user" and + * silently does not. + * 2. The SYSTEM warning must be present on both the list and the add/edit + * form. Someone pasting a script into the modal may never have read the + * page copy above it. + * + * Asserted against the file text, matching configurationPolicyEditable.spec.js. + */ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const here = dirname(fileURLToPath(import.meta.url)) +const source = readFileSync(join(here, 'SystemScriptsSettings.vue'), 'utf8') +const routes = readFileSync(join(here, '..', 'routes.js'), 'utf8') + +describe('SystemScriptsSettings', () => { + it('warns that scripts run as SYSTEM on the list page and in the form', () => { + const warnings = source.match(/runs? as SYSTEM/g) || [] + expect(warnings.length).toBeGreaterThanOrEqual(2) + }) + + it('does not offer a run context picker', () => { + expect(source).not.toMatch(/runcontext/i) + }) + + it('does not send a runcontext in its save payload', () => { + const payload = source.slice(source.indexOf('const payload = {')) + expect(payload.slice(0, 200)).not.toMatch(/runcontext/i) + }) + + it('flags a script with no body as unrunnable', () => { + expect(source).toMatch(/hasbody/) + expect(source).toMatch(/cannot run/i) + }) + + it('is gated on the standalone script permission, not admin', () => { + const route = routes.slice(routes.indexOf("'settings/endpoints/scripts'")) + expect(route).toMatch(/requiresPermission: 'endpoints\.runscript'/) + expect(route.slice(0, 300)).not.toMatch(/requiresAdmin/) + }) + + it('offers an optional working directory and sends it on save', () => { + expect(source).toMatch(/form\.workingdirectory/) + const payload = source.slice(source.indexOf('const payload = {')) + expect(payload.slice(0, 300)).toMatch(/workingdirectory/) + }) + + it('shows the staging default rather than an empty cell', () => { + // A blank cell reads as "unknown". The folder is the difference between a + // corporate tool being found and the script failing further in, so the + // list has to state the default out loud. + expect(source).toMatch(/Agent staging folder/) + }) + + it('offers an error mode and sends it on save', () => { + expect(source).toMatch(/form\.errormode/) + const payload = source.slice(source.indexOf('const payload = {')) + expect(payload.slice(0, 300)).toMatch(/errormode/) + }) + + it('defaults a new script to the permissive mode', () => { + // Strict changes how a body behaves partway through. Defaulting to it + // would silently alter every script authored from here on, so a new + // script has to start where the old ones already are. + const empty = source.slice(source.indexOf('function emptyForm')) + expect(empty.slice(0, 300)).toMatch(/errormode: 'default'/) + }) + + it('names both error modes by their consequence, not by their flag', () => { + // "default" and "strict" mean nothing to the person choosing. What they + // need to know is which one can report success having done nothing. + expect(source).toMatch(/Continue - report success if the script reaches the end/) + expect(source).toMatch(/Stop on the first error and fail the command/) + }) + + it('explains the non-terminating error trap that justifies the choice', () => { + // The whole feature exists because PowerShell exits zero after an error. + // If the page never says so, the dropdown looks like pedantry and stays + // on the setting that produces false green. + expect(source).toMatch(/non-terminating/i) + expect(source).toMatch(/exits? zero/i) + }) + + it('offers the explainer collapsed so it does not bury the page', () => { + expect(source).toMatch(/
/) + expect(source).toMatch(/How does this work\?/) + expect(source).not.toMatch(/
/) + }) + + it('collapses the long per-field prose in the form', () => { + // The modal is already tall enough to scroll. Three paragraphs of standing + // explanation between the inputs pushes Save off screen and gets skimmed + // past, which is the opposite of what the copy is for. + const helps = source.match(/
/g) || [] + expect(helps.length).toBeGreaterThanOrEqual(2) + expect(source).toMatch(/How this works<\/summary>/) + }) + + it('styles itself from defined theme tokens only', () => { + // --surface and --text-primary are not defined by this app's theme, so a + // var() fallback silently rendered a white panel on the dark background. + // A fallback colour is the bug: it looks correct in light mode and only + // shows up as a bright block for dark-mode users. + const styles = source.slice(source.indexOf(' diff --git a/plugins/geenforce/frontend/views/GeEnforceLayout.vue b/plugins/geenforce/frontend/views/GeEnforceLayout.vue deleted file mode 100644 index c69135e0..00000000 --- a/plugins/geenforce/frontend/views/GeEnforceLayout.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - - - diff --git a/plugins/geenforce/frontend/views/GeEnforceSettings.vue b/plugins/geenforce/frontend/views/GeEnforceSettings.vue deleted file mode 100644 index 1cd40402..00000000 --- a/plugins/geenforce/frontend/views/GeEnforceSettings.vue +++ /dev/null @@ -1,142 +0,0 @@ - - - - - diff --git a/plugins/geenforce/frontend/views/ManifestEditor.vue b/plugins/geenforce/frontend/views/ManifestEditor.vue deleted file mode 100644 index d63928e2..00000000 --- a/plugins/geenforce/frontend/views/ManifestEditor.vue +++ /dev/null @@ -1,1074 +0,0 @@ - - - - - diff --git a/plugins/geenforce/frontend/views/entryForm.js b/plugins/geenforce/frontend/views/entryForm.js deleted file mode 100644 index 7384f639..00000000 --- a/plugins/geenforce/frontend/views/entryForm.js +++ /dev/null @@ -1,183 +0,0 @@ -// Pure, framework-free helpers for the GE-Enforce manifest editor. -// -// ManifestEditor.vue imports these directly (the component no longer keeps its -// own copies), so the unit tests in entryForm.spec.js exercise the shipped -// code path. Change the editor logic HERE. -// -// Everything here is a plain function of its inputs. No Vue, no reactivity, -// no network. That is the whole point - deterministic logic we can pin down. - -export const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry'] -export const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary'] -export const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile', - 'ValueMatches', 'pnputil', 'Always'] -export const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot'] - -// Plain-language description of each detection method, shown under the Detection -// method dropdown so a first-time site admin understands what "present" means -// for the method they picked. Key '' is the no-detection case. -export const DETECTION_METHOD_HINTS = { - '': 'No detection rule: the action runs every cycle.', - Registry: 'Already correct if the registry value at Detection path/name exists (and equals Detection value when one is set).', - File: 'Already correct if the file at Detection path exists.', - FileVersion: 'Already correct if the file at Detection path is at Detection value or newer. This target feeds the Compliance panel.', - Hash: 'Already correct if the file at Detection path matches the SHA256 in Detection value. Re-copies when the file changed.', - MarkerFile: 'Already correct if the marker file at Detection path exists. Installs once, then the marker suppresses reruns.', - ValueMatches: 'Already correct if the registry value at Detection path/name equals Detection value exactly.', - pnputil: 'Already correct if a driver matching Detection pattern is staged in the Windows driver store. For INF entries.', - Always: 'Never counts as present, so the action runs every cycle. Same effect as no detection rule.', -} - -// Description for the currently selected detection method, or '' if unknown. -export function detectionMethodHint(method) { - return DETECTION_METHOD_HINTS[method || ''] || '' -} - -// One blank entry form, matching the shape ManifestEditor seeds for a new entry. -export function blankEntry() { - return { Type: 'MSI', DetectionMethod: '', RegType: 'String', - inuseBehavior: '', inuseProcesses: [], appid: null, - PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false } -} - -// Split a comma list into trimmed non-empty parts. -export function splitList(value) { - return (value || '').split(',').map(item => item.trim()).filter(Boolean) -} - -// Turn an edit-form object into the API body sent to create/update an entry. -// -// Rules that matter (and that the specs pin): -// - appid is always present (null unlinks). It is shopdb metadata; the backend -// keeps it OFF the manifest JSON, so it is NOT one of the manifest scalars. -// - empty/undefined/null scalars are dropped. -// - Registry DWord/QWord values become real numbers; other reg types stay strings. -// - comma lists become arrays, and are dropped when empty. -// - preinstall flags only appear when truthy. -// - InUseCheck carries per-process Name, optional ExePath, optional numeric timeout. -export function buildEntryPayload(form) { - // appid is shopdb metadata, always sent (null unlinks); the backend keeps it - // off the manifest JSON. - const out = { Name: form.Name, Type: form.Type, appid: form.appid ?? null } - const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source', - 'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath', - 'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion', - 'LogFile', 'ApplyMode', 'UpdateWindow', '_comment'] - for (const key of scalars) { - if (form[key] !== undefined && form[key] !== '' && form[key] !== null) out[key] = form[key] - } - if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod - if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec - if (form.Type === 'Registry' && form.RegValue !== undefined && form.RegValue !== '') { - out.RegValue = ['DWord', 'QWord'].includes(form.RegType) ? Number(form.RegValue) : form.RegValue - } - const pctypes = splitList(form.PCTypes) - if (pctypes.length) out.PCTypes = pctypes - const hostnames = splitList(form.TargetHostnames) - if (hostnames.length) out.TargetHostnames = hostnames - const machinenumbers = splitList(form.TargetMachineNumbers) - if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers - for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) { - if (form[flag]) out[flag] = true - } - if (form.inuseBehavior) { - out.InUseCheck = { - Behavior: form.inuseBehavior, - Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => { - const processData = { Name: process.name } - if (process.exepath) processData.ExePath = process.exepath - if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) { - processData.GracefulCloseTimeoutSec = Number(process.timeout) - } - return processData - }), - } - } - return out -} - -// Which entry types the type dropdown offers, given the scope phase. Preinstall -// supports MSI/EXE only, but keeps a current out-of-set value visible. -export function availableEntryTypes(isPreinstall, currentType) { - if (!isPreinstall) return ENTRY_TYPES - const allowed = ['MSI', 'EXE'] - return currentType && !allowed.includes(currentType) ? [...allowed, currentType] : allowed -} - -// Which detection methods the dropdown offers. Preinstall supports Registry/File -// only, but keeps a current out-of-set value visible. -export function availableDetectionMethods(isPreinstall, currentMethod) { - if (!isPreinstall) return DETECTION_METHODS - const allowed = ['Registry', 'File'] - return currentMethod && !allowed.includes(currentMethod) ? [...allowed, currentMethod] : allowed -} - -// Which targeting gates a scope actually surfaces by default (before "Show all"). -export function targetingGates(scope) { - if (!scope) { - return { pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false } - } - const name = (scope.scopename || '').toLowerCase() - const entries = scope.entries || [] - const fleetwide = scope.iscommon || scope.phase === 'preinstall' || name === 'common' - return { - pctypes: fleetwide || entries.some(e => (e.PCTypes || []).length), - cmmversion: /cmm/.test(name) || entries.some(e => e._CmmVersion), - // Data-driven (a loose name match would wrongly flag 'nocollections'). - machinenumbers: entries.some(e => (e.TargetMachineNumbers || []).length), - hostnames: entries.some(e => (e.TargetHostnames || []).length), - } -} - -// The contextual hint under the Targeting header. -export function targetingHint(scope) { - if (!scope) return '' - if (scope.iscommon || scope.scopename === 'common') { - return 'Fleet-wide manifest: use PC types to target which types get this entry.' - } - if (scope.phase === 'preinstall') { - return 'Preinstall manifest: use PC types to target; preinstall flags apply here.' - } - return `This manifest already runs only on ${scope.scopename} PCs. ` - + 'Targeting below narrows within that (version, bay, or subtype).' -} - -// One-line summary of what a scope installs (installer entries only). -export function scopeSummary(scope) { - if (!scope || !scope.entries || !scope.entries.length) return '' - const installers = scope.entries - .filter(e => ['MSI', 'EXE', 'CMD', 'BAT'].includes(e.Type)) - .map(e => e.Name) - const shown = installers.slice(0, 6).join(', ') - const more = installers.length > 6 ? `, +${installers.length - 6} more` : '' - const apps = installers.length ? `Installs ${shown}${more}. ` : '' - return `${apps}${scope.entries.length} entries; runs after common.` -} - -// Verb table for describeEntry. -export const ACTION_VERB = { - MSI: 'Installs', EXE: 'Installs', CMD: 'Runs', BAT: 'Runs', - PS1: 'Runs a script for', INF: 'Installs a driver for', - File: 'Copies a file for', Registry: 'Sets a registry value for', -} - -// Plain-English one-liner: what an entry does, when it self-heals, who it hits. -export function describeEntry(entry) { - let text = `${ACTION_VERB[entry.Type] || 'Applies'} ${entry.Name}` - const method = entry.DetectionMethod - if (!method || method === 'Always') text += '; runs every cycle' - else if (method === 'FileVersion') text += `; reinstalls unless version is ${entry.DetectionValue || 'set'}` - else if (method === 'Hash') text += '; re-copies if the file changed' - else if (method === 'MarkerFile') text += '; installs once' - else text += '; reinstalls if not detected' - if (entry._CmmVersion) text += `; CMM ${entry._CmmVersion} bays only` - else if (entry.TargetMachineNumbers && entry.TargetMachineNumbers.length) { - text += `; ${entry.TargetMachineNumbers.length} specific bay(s)` - } else if (entry.TargetHostnames && entry.TargetHostnames.length) { - text += '; specific hostname(s)' - } else if (entry.PCTypes && entry.PCTypes.length) { - text += `; ${entry.PCTypes.length} PC type(s)` - } - if (entry.appname) text += `; tracked: ${entry.appname}` - return text -} diff --git a/plugins/geenforce/frontend/views/entryForm.spec.js b/plugins/geenforce/frontend/views/entryForm.spec.js deleted file mode 100644 index bd299230..00000000 --- a/plugins/geenforce/frontend/views/entryForm.spec.js +++ /dev/null @@ -1,321 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - blankEntry, - splitList, - buildEntryPayload, - availableEntryTypes, - availableDetectionMethods, - targetingGates, - targetingHint, - scopeSummary, - describeEntry, - detectionMethodHint, - ENTRY_TYPES, - DETECTION_METHODS, -} from './entryForm.js' - -// These specs pin the deterministic logic that turns the manifest editor form -// into an API body, and the various display helpers. This is the code most -// likely to silently regress (numeric coercion, dropped-vs-kept fields, the -// appid-must-never-be-a-manifest-scalar rule). - -describe('splitList', () => { - it('trims, drops empties, and returns an array', () => { - expect(splitList('a, b ,, c ')).toEqual(['a', 'b', 'c']) - }) - it('returns an empty array for null/empty', () => { - expect(splitList('')).toEqual([]) - expect(splitList(null)).toEqual([]) - expect(splitList(undefined)).toEqual([]) - }) -}) - -describe('buildEntryPayload - appid handling', () => { - it('always emits appid as a top-level key, defaulting to null', () => { - const out = buildEntryPayload({ Name: 'x', Type: 'MSI' }) - expect(out.appid).toBeNull() - expect('appid' in out).toBe(true) - }) - it('passes a linked appid through unchanged', () => { - const out = buildEntryPayload({ Name: 'x', Type: 'MSI', appid: 41 }) - expect(out.appid).toBe(41) - }) - it('never lets appid become a manifest scalar (it is metadata, not a manifest key)', () => { - // appid lives at the top level as metadata; it must not appear nested in - // any manifest sub-structure. Guard against a future refactor smuggling it - // into e.g. InUseCheck or a detection block. - const out = buildEntryPayload({ - Name: 'x', Type: 'Registry', appid: 7, RegType: 'DWord', RegValue: '1', - DetectionMethod: 'Registry', inuseBehavior: 'ForceClose', - inuseProcesses: [{ name: 'p', exepath: '', timeout: null }], - }) - // Only the top-level appid key carries it. - const serialized = JSON.stringify({ ...out, appid: undefined }) - expect(serialized.includes('appid')).toBe(false) - expect(serialized.toLowerCase().includes('"appid"')).toBe(false) - }) -}) - -describe('buildEntryPayload - scalar filtering', () => { - it('drops empty-string, null, and undefined scalars', () => { - const out = buildEntryPayload({ - Name: 'x', Type: 'MSI', Installer: '', InstallArgs: null, - Script: undefined, LogFile: 'setup.log', - }) - expect('Installer' in out).toBe(false) - expect('InstallArgs' in out).toBe(false) - expect('Script' in out).toBe(false) - expect(out.LogFile).toBe('setup.log') - }) - it('keeps the underscore-prefixed scalars (_CmmVersion, _comment)', () => { - const out = buildEntryPayload({ - Name: 'x', Type: 'MSI', _CmmVersion: '2019', _comment: 'note', - }) - expect(out._CmmVersion).toBe('2019') - expect(out._comment).toBe('note') - }) - it('only emits DetectionMethod when set', () => { - expect('DetectionMethod' in buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: '' })).toBe(false) - expect(buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: 'FileVersion' }).DetectionMethod).toBe('FileVersion') - }) - it('drops a zero/falsy WaitTimeoutSec but keeps a real one', () => { - expect('WaitTimeoutSec' in buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 0 })).toBe(false) - expect(buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 300 }).WaitTimeoutSec).toBe(300) - }) -}) - -describe('buildEntryPayload - RegValue coercion', () => { - it('coerces DWord to a Number', () => { - const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '1' }) - expect(out.RegValue).toBe(1) - expect(typeof out.RegValue).toBe('number') - }) - it('coerces QWord to a Number', () => { - const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'QWord', RegValue: '42' }) - expect(out.RegValue).toBe(42) - }) - it('leaves String reg values as strings', () => { - const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'String', RegValue: 'hello' }) - expect(out.RegValue).toBe('hello') - expect(typeof out.RegValue).toBe('string') - }) - it('does not emit RegValue for a non-Registry type', () => { - const out = buildEntryPayload({ Name: 'x', Type: 'MSI', RegType: 'DWord', RegValue: '1' }) - expect('RegValue' in out).toBe(false) - }) - it('drops an empty RegValue even for Registry', () => { - const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '' }) - expect('RegValue' in out).toBe(false) - }) -}) - -describe('buildEntryPayload - comma lists to arrays', () => { - it('splits PCTypes / TargetHostnames / TargetMachineNumbers', () => { - const out = buildEntryPayload({ - Name: 'x', Type: 'MSI', - PCTypes: 'gea-shopfloor-cmm, gea-shopfloor-collections', - TargetHostnames: 'host-a, host-b', - TargetMachineNumbers: '0101, 0102', - }) - expect(out.PCTypes).toEqual(['gea-shopfloor-cmm', 'gea-shopfloor-collections']) - expect(out.TargetHostnames).toEqual(['host-a', 'host-b']) - expect(out.TargetMachineNumbers).toEqual(['0101', '0102']) - }) - it('omits the array keys when the list is empty', () => { - const out = buildEntryPayload({ Name: 'x', Type: 'MSI', PCTypes: '', TargetHostnames: ' ' }) - expect('PCTypes' in out).toBe(false) - expect('TargetHostnames' in out).toBe(false) - }) -}) - -describe('buildEntryPayload - preinstall flags', () => { - it('emits only the truthy flags', () => { - const out = buildEntryPayload({ - Name: 'x', Type: 'MSI', - PreEnrollment: true, KillAfterDetection: false, PCTypesStrict: true, - }) - expect(out.PreEnrollment).toBe(true) - expect(out.PCTypesStrict).toBe(true) - expect('KillAfterDetection' in out).toBe(false) - }) -}) - -describe('buildEntryPayload - InUseCheck processes', () => { - it('carries Name, optional ExePath, and a numeric timeout per process', () => { - const out = buildEntryPayload({ - Name: 'x', Type: 'MSI', inuseBehavior: 'CloseAndReopen', - inuseProcesses: [ - { name: 'pcdmis', exepath: 'C:\\pcd\\pcdmis.exe', timeout: 30 }, - { name: 'nodmis', exepath: '', timeout: null }, - ], - }) - expect(out.InUseCheck.Behavior).toBe('CloseAndReopen') - expect(out.InUseCheck.Processes).toEqual([ - { Name: 'pcdmis', ExePath: 'C:\\pcd\\pcdmis.exe', GracefulCloseTimeoutSec: 30 }, - { Name: 'nodmis' }, - ]) - }) - it('drops processes with no name', () => { - const out = buildEntryPayload({ - Name: 'x', Type: 'MSI', inuseBehavior: 'ForceClose', - inuseProcesses: [{ name: '', exepath: 'x', timeout: 5 }, { name: 'keep', timeout: 5 }], - }) - expect(out.InUseCheck.Processes).toEqual([{ Name: 'keep', GracefulCloseTimeoutSec: 5 }]) - }) - it('coerces a string timeout to a number', () => { - const out = buildEntryPayload({ - Name: 'x', Type: 'MSI', inuseBehavior: 'Defer', - inuseProcesses: [{ name: 'p', exepath: '', timeout: '15' }], - }) - expect(out.InUseCheck.Processes[0].GracefulCloseTimeoutSec).toBe(15) - }) - it('emits no InUseCheck when there is no behavior', () => { - const out = buildEntryPayload({ Name: 'x', Type: 'MSI', inuseBehavior: '' }) - expect('InUseCheck' in out).toBe(false) - }) -}) - -describe('buildEntryPayload - a blank new entry round-trips', () => { - it('produces a minimal body from blankEntry (name/type + null appid + default RegType)', () => { - const out = buildEntryPayload({ ...blankEntry(), Name: 'Fresh' }) - // Type MSI, appid null. RegType is default String but non-Registry, so it - // is still carried as a scalar (it is in the scalar list). Confirm the shape. - expect(out.Name).toBe('Fresh') - expect(out.Type).toBe('MSI') - expect(out.appid).toBeNull() - expect('InUseCheck' in out).toBe(false) - expect('PCTypes' in out).toBe(false) - }) -}) - -describe('availableEntryTypes', () => { - it('returns the full list for a non-preinstall scope', () => { - expect(availableEntryTypes(false, 'File')).toEqual(ENTRY_TYPES) - }) - it('restricts to MSI/EXE for preinstall', () => { - expect(availableEntryTypes(true, 'MSI')).toEqual(['MSI', 'EXE']) - }) - it('keeps an out-of-set current value visible in preinstall', () => { - expect(availableEntryTypes(true, 'Registry')).toEqual(['MSI', 'EXE', 'Registry']) - }) -}) - -describe('availableDetectionMethods', () => { - it('returns the full list for a non-preinstall scope', () => { - expect(availableDetectionMethods(false, 'Hash')).toEqual(DETECTION_METHODS) - }) - it('restricts to Registry/File for preinstall', () => { - expect(availableDetectionMethods(true, 'File')).toEqual(['Registry', 'File']) - }) - it('keeps an out-of-set current value visible in preinstall', () => { - expect(availableDetectionMethods(true, 'FileVersion')).toEqual(['Registry', 'File', 'FileVersion']) - }) -}) - -describe('targetingGates', () => { - it('defaults to pctypes-only when there is no scope', () => { - expect(targetingGates(null)).toEqual({ - pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false, - }) - }) - it('shows pctypes for a fleetwide/common/preinstall scope', () => { - expect(targetingGates({ scopename: 'common', entries: [] }).pctypes).toBe(true) - expect(targetingGates({ scopename: 'x', phase: 'preinstall', entries: [] }).pctypes).toBe(true) - expect(targetingGates({ scopename: 'x', iscommon: true, entries: [] }).pctypes).toBe(true) - }) - it('shows the cmm gate for a cmm-named scope', () => { - expect(targetingGates({ scopename: 'gea-shopfloor-cmm', entries: [] }).cmmversion).toBe(true) - }) - it('does not name-match machinenumbers (data-driven only)', () => { - // 'nocollections' must not trip a loose name match; machinenumbers is purely - // data-driven off the entries. - const gates = targetingGates({ scopename: 'nocollections', entries: [] }) - expect(gates.machinenumbers).toBe(false) - }) - it('surfaces gates from entry data', () => { - const gates = targetingGates({ - scopename: 'per-type', entries: [ - { PCTypes: ['a'] }, { TargetMachineNumbers: ['0101'] }, { TargetHostnames: ['h'] }, - ], - }) - expect(gates.pctypes).toBe(true) - expect(gates.machinenumbers).toBe(true) - expect(gates.hostnames).toBe(true) - }) -}) - -describe('targetingHint', () => { - it('is empty with no scope', () => { - expect(targetingHint(null)).toBe('') - }) - it('describes a fleet-wide common manifest', () => { - expect(targetingHint({ scopename: 'common' })).toMatch(/Fleet-wide/) - }) - it('describes a preinstall manifest', () => { - expect(targetingHint({ scopename: 'x', phase: 'preinstall' })).toMatch(/Preinstall/) - }) - it('names a per-type scope', () => { - expect(targetingHint({ scopename: 'gea-shopfloor-cmm' })).toMatch(/gea-shopfloor-cmm/) - }) -}) - -describe('scopeSummary', () => { - it('is empty with no entries', () => { - expect(scopeSummary({ entries: [] })).toBe('') - expect(scopeSummary(null)).toBe('') - }) - it('lists installer names and the entry count', () => { - const summary = scopeSummary({ - entries: [ - { Type: 'MSI', Name: 'eDNC' }, - { Type: 'Registry', Name: 'reg' }, - ], - }) - expect(summary).toBe('Installs eDNC. 2 entries; runs after common.') - }) - it('truncates past six installers with a +N more', () => { - const entries = Array.from({ length: 8 }, (_, i) => ({ Type: 'MSI', Name: `app${i}` })) - const summary = scopeSummary({ entries }) - expect(summary).toMatch(/\+2 more/) - }) -}) - -describe('describeEntry', () => { - it('describes an MSI with FileVersion detection using the expected version', () => { - const text = describeEntry({ Type: 'MSI', Name: 'PC-DMIS', DetectionMethod: 'FileVersion', DetectionValue: '2019 R1' }) - expect(text).toBe('Installs PC-DMIS; reinstalls unless version is 2019 R1') - }) - it('says runs every cycle for Always / no detection', () => { - expect(describeEntry({ Type: 'CMD', Name: 'x' })).toBe('Runs x; runs every cycle') - expect(describeEntry({ Type: 'CMD', Name: 'x', DetectionMethod: 'Always' })).toBe('Runs x; runs every cycle') - }) - it('appends a CMM gate note', () => { - const text = describeEntry({ Type: 'MSI', Name: 'x', _CmmVersion: '2016' }) - expect(text).toMatch(/CMM 2016 bays only/) - }) - it('appends the tracked app name when present', () => { - const text = describeEntry({ Type: 'MSI', Name: 'x', appname: 'PC-DMIS' }) - expect(text).toMatch(/tracked: PC-DMIS/) - }) - it('falls back to Applies for an unknown type', () => { - expect(describeEntry({ Type: 'Weird', Name: 'x' })).toMatch(/^Applies x/) - }) -}) - -describe('detectionMethodHint', () => { - it('has a non-empty hint for every detection method', () => { - for (const method of DETECTION_METHODS) { - expect(detectionMethodHint(method).length).toBeGreaterThan(0) - } - }) - it('describes the no-detection case for empty/undefined', () => { - expect(detectionMethodHint('')).toMatch(/every cycle/) - expect(detectionMethodHint(undefined)).toMatch(/every cycle/) - }) - it('ties FileVersion to the compliance panel', () => { - expect(detectionMethodHint('FileVersion')).toMatch(/Compliance/) - }) - it('returns empty string for an unknown method', () => { - expect(detectionMethodHint('Nonsense')).toBe('') - }) -}) diff --git a/plugins/geenforce/importer.py b/plugins/geenforce/importer.py deleted file mode 100644 index 3e50004a..00000000 --- a/plugins/geenforce/importer.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Parse on-share manifest.json / preinstall.json into draft DB rows. - -The reverse of serializer.py. `build_scope` turns a parsed manifest dict into a -ManifestScope with its ordered entries and child rows (not yet committed). -`discover_share` walks a GE-Enforce share root and yields (scopename, phase, -manifest_dict) for common + every gea-shopfloor-* runtime manifest, skipping -`.bak` variants. `load_preinstall` reads a preinstall.json. - -Import is a rebuild: the caller replaces a scope's draft rows so re-running is -idempotent. -""" - -import json -import os - -from .models import ( - ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname, - ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess, -) -from .serializer import _SCALAR_FIELDS, _BOOL_FLAGS - -# manifest key -> model attr (reverse of the serializer's scalar map). -_KEY_TO_ATTR = {mk: attr for attr, mk in _SCALAR_FIELDS} -_FLAG_TO_ATTR = {mk: attr for attr, mk in _BOOL_FLAGS} - - -# Scalar model attributes that a populate resets (so an update clears fields the -# new payload omits). name/entrytype are required and set explicitly. -_RESETTABLE_ATTRS = [attr for _, attr in _SCALAR_FIELDS - if attr not in ('name', 'entrytype')] + ['comment', 'regvalue'] - - -def populate_entry(entry, entry_dict): - """Set every field + child on an existing (or new) ManifestEntry from one - Applications[] entry dict. Existing children are replaced. Returns entry.""" - entry.name = entry_dict.get('Name') - entry.entrytype = entry_dict.get('Type') - entry.comment = entry_dict.get('_comment') - for attr in _RESETTABLE_ATTRS: - if attr in ('comment', 'regvalue'): - continue - setattr(entry, attr, None) - for key, attr in _KEY_TO_ATTR.items(): - if attr in ('name', 'entrytype'): - continue - if key in entry_dict and entry_dict[key] is not None: - setattr(entry, attr, entry_dict[key]) - # RegValue stored as its raw JSON literal so DWord vs string typing survives. - entry.regvalue = (json.dumps(entry_dict['RegValue']) - if 'RegValue' in entry_dict else None) - # Payload transport (http/inline share-less delivery); default smb. Kept out - # of the scalar map so smb entries stay bare (conditional emit + parity). - entry.payloadsource = entry_dict.get('PayloadSource') or 'smb' - entry.payloadsha256 = entry_dict.get('PayloadSha256') - entry.payloadref = entry_dict.get('PayloadRef') - for key, attr in _FLAG_TO_ATTR.items(): - setattr(entry, attr, bool(entry_dict.get(key))) - # Multi-value filters -> child rows (replace, preserve order). - entry.pctypes = [ManifestEntryPcType(sortorder=i, pctypevalue=value) - for i, value in enumerate(entry_dict.get('PCTypes') or [])] - entry.hostnames = [ManifestEntryHostname(sortorder=i, hostnamepattern=value) - for i, value in enumerate(entry_dict.get('TargetHostnames') or [])] - entry.machinenumbers = [ - ManifestEntryMachineNumber(sortorder=i, machinenumber=str(value)) - for i, value in enumerate(entry_dict.get('TargetMachineNumbers') or [])] - # InUseCheck object + Processes[]. - inuse = entry_dict.get('InUseCheck') - if inuse: - check = ManifestInUseCheck(behavior=inuse.get('Behavior')) - for i, proc in enumerate(inuse.get('Processes') or []): - check.processes.append(ManifestInUseCheckProcess( - sortorder=i, - processname=proc.get('Name'), - exepath=proc.get('ExePath'), - gracefulclosetimeoutsec=proc.get('GracefulCloseTimeoutSec'))) - entry.inusecheck = check - else: - entry.inusecheck = None - return entry - - -def build_entry(entry_dict, sortorder): - """Build a new ManifestEntry (+ children) from one Applications[] entry.""" - entry = ManifestEntry(sortorder=sortorder) - return populate_entry(entry, entry_dict) - - -def build_scope(scopename, phase, manifest_dict, iscommon=False): - """Build a ManifestScope (+ entries) from a parsed manifest dict.""" - scope = ManifestScope( - scopename=scopename, - phase=phase, - manifestversion=str(manifest_dict.get('Version', '1.0')), - topcomment=manifest_dict.get('_comment'), - site=manifest_dict.get('Site'), - iscommon=iscommon) - for i, entry_dict in enumerate(manifest_dict.get('Applications') or []): - scope.entries.append(build_entry(entry_dict, i)) - return scope - - -def discover_share(shareroot): - """Yield (scopename, phase, manifest_dict) for runtime manifests on a share. - - Reads `common/manifest.json`, `display/manifest.json`, and every - `gea-shopfloor-*/manifest.json`. Skips `.bak` / `.pre-*.bak` variants - (only the exact `manifest.json`). - """ - for name in sorted(os.listdir(shareroot)): - if name not in ('common', 'display') \ - and not name.startswith('gea-shopfloor-'): - continue - path = os.path.join(shareroot, name, 'manifest.json') - if not os.path.isfile(path): - continue - with open(path) as handle: - manifest = json.load(handle) - yield name, 'runtime', manifest - - -def load_manifest_file(path): - """Parse a single manifest.json / preinstall.json file.""" - with open(path) as handle: - return json.load(handle) diff --git a/plugins/geenforce/manifest.json b/plugins/geenforce/manifest.json deleted file mode 100644 index 956e5648..00000000 --- a/plugins/geenforce/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "geenforce", - "display_name": "GE-Enforce", - "version": "0.1.0", - "description": "GE-Enforce manifest store. Owns imaging PC-type scopes and their install manifests (apps, scripts, files, registry, version gates) as shopdb data, served to the GE-Enforce client as JSON. Requires GE-Enforce lib >= 2.6 on target PCs (the _CmmVersion gate).", - "author": "ShopDB Team", - "dependencies": [], - "core_version": ">=0.18.0,<1.0.0", - "api_prefix": "/api/geenforce", - "default_enabled": false, - "provides": { - "features": ["manifest-store", "imaging-pc-types"] - } -} diff --git a/plugins/geenforce/migrations/env.py b/plugins/geenforce/migrations/env.py deleted file mode 100644 index bb8998d8..00000000 --- a/plugins/geenforce/migrations/env.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Alembic environment for the GE-Enforce plugin migration chain. - -Delegates to the shared runner in shopdb.plugins.alembic_template, which filters -the metadata to this plugin's tables and drives Alembic against the per-plugin -version table alembic_version_geenforce (ADR-008). This plugin is NEW: its 0001 -baseline really CREATES its tables, because the core chain never built them. -""" -import os - -os.environ['PLUGIN_NAME'] = 'geenforce' - -from shopdb.plugins.alembic_template import run_migrations # noqa: E402 - -run_migrations() diff --git a/plugins/geenforce/migrations/script.py.mako b/plugins/geenforce/migrations/script.py.mako deleted file mode 100644 index 2c015630..00000000 --- a/plugins/geenforce/migrations/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} diff --git a/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py b/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py deleted file mode 100644 index e232aacb..00000000 --- a/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py +++ /dev/null @@ -1,246 +0,0 @@ -"""geenforce plugin baseline (real create). - -This plugin was built after the ADR-008 ownership cutover, so this baseline -actually CREATES the plugin's tables (the core chain never knew about them). It -runs from `flask plugin install geenforce` / `flask plugin upgrade-all` after -`flask db upgrade` builds the core schema. All foreign keys are intra-plugin, so -tables are created parent-first. computertypeid / measuringtooltypeid / -publishedby are SOFT references (plain columns, no FK) to optional plugins/core. -""" -from alembic import op - -from shopdb.plugins.alembic_template import ( - create_index_if_missing, - create_table_if_missing, -) -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'geenforce0001baseline' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - create_table_if_missing( - 'manifestscopes', - sa.Column('scopeid', sa.Integer(), nullable=False), - sa.Column('scopename', sa.String(length=64), nullable=False), - sa.Column('phase', sa.String(length=16), nullable=False), - sa.Column('computertypeid', sa.Integer(), nullable=True), - sa.Column('measuringtooltypeid', sa.Integer(), nullable=True), - sa.Column('manifestversion', sa.String(length=16), nullable=False), - sa.Column('description', sa.String(length=255), nullable=True), - sa.Column('topcomment', sa.Text(), nullable=True), - sa.Column('site', sa.String(length=100), nullable=True), - sa.Column('iscommon', sa.Boolean(), nullable=False), - sa.Column('createddate', sa.DateTime(), nullable=False), - sa.Column('modifieddate', sa.DateTime(), nullable=False), - sa.Column('isactive', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('scopeid'), - sa.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'), - ) - create_table_if_missing( - 'manifestentries', - sa.Column('entryid', sa.Integer(), nullable=False), - sa.Column('scopeid', sa.Integer(), nullable=False), - sa.Column('sortorder', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=128), nullable=False), - sa.Column('entrytype', sa.String(length=16), nullable=False), - sa.Column('comment', sa.Text(), nullable=True), - sa.Column('installer', sa.String(length=255), nullable=True), - sa.Column('installargs', sa.Text(), nullable=True), - sa.Column('scriptpath', sa.String(length=255), nullable=True), - sa.Column('scriptargs', sa.String(length=255), nullable=True), - sa.Column('sourcepath', sa.String(length=255), nullable=True), - sa.Column('destination', sa.String(length=255), nullable=True), - sa.Column('regpath', sa.String(length=255), nullable=True), - sa.Column('regname', sa.String(length=128), nullable=True), - sa.Column('regvalue', sa.Text(), nullable=True), - sa.Column('regtype', sa.String(length=16), nullable=True), - sa.Column('payloadsource', sa.String(length=8), nullable=False), - sa.Column('payloadref', sa.String(length=512), nullable=True), - sa.Column('payloadsha256', sa.String(length=64), nullable=True), - sa.Column('detectionmethod', sa.String(length=16), nullable=True), - sa.Column('detectionpath', sa.String(length=255), nullable=True), - sa.Column('detectionname', sa.String(length=128), nullable=True), - sa.Column('detectionvalue', sa.String(length=255), nullable=True), - sa.Column('detectionpattern', sa.String(length=255), nullable=True), - sa.Column('cmmversion', sa.String(length=16), nullable=True), - sa.Column('logfile', sa.String(length=255), nullable=True), - sa.Column('waittimeoutsec', sa.Integer(), nullable=True), - sa.Column('applymode', sa.String(length=16), nullable=True), - sa.Column('updatewindow', sa.String(length=11), nullable=True), - sa.Column('appid', sa.Integer(), nullable=True), - sa.Column('preenrollment', sa.Boolean(), nullable=False), - sa.Column('killafterdetection', sa.Boolean(), nullable=False), - sa.Column('pctypesstrict', sa.Boolean(), nullable=False), - sa.Column('createddate', sa.DateTime(), nullable=False), - sa.Column('modifieddate', sa.DateTime(), nullable=False), - sa.Column('isactive', sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'], - ondelete='CASCADE'), - sa.PrimaryKeyConstraint('entryid'), - sa.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'), - ) - create_index_if_missing('idx_entry_scope', 'manifestentries', ['scopeid']) - create_index_if_missing('idx_entry_scope_order', 'manifestentries', - ['scopeid', 'sortorder']) - create_table_if_missing( - 'manifestentrypctypes', - sa.Column('entrypctypeid', sa.Integer(), nullable=False), - sa.Column('entryid', sa.Integer(), nullable=False), - sa.Column('sortorder', sa.Integer(), nullable=False), - sa.Column('pctypevalue', sa.String(length=64), nullable=False), - sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], - ondelete='CASCADE'), - sa.PrimaryKeyConstraint('entrypctypeid'), - ) - create_index_if_missing('idx_pctype_entry', 'manifestentrypctypes', ['entryid']) - create_table_if_missing( - 'manifestentryhostnames', - sa.Column('entryhostnameid', sa.Integer(), nullable=False), - sa.Column('entryid', sa.Integer(), nullable=False), - sa.Column('sortorder', sa.Integer(), nullable=False), - sa.Column('hostnamepattern', sa.String(length=64), nullable=False), - sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], - ondelete='CASCADE'), - sa.PrimaryKeyConstraint('entryhostnameid'), - ) - create_index_if_missing('idx_hostname_entry', 'manifestentryhostnames', ['entryid']) - create_table_if_missing( - 'manifestentrymachinenumbers', - sa.Column('entrymachinenumberid', sa.Integer(), nullable=False), - sa.Column('entryid', sa.Integer(), nullable=False), - sa.Column('sortorder', sa.Integer(), nullable=False), - sa.Column('machinenumber', sa.String(length=16), nullable=False), - sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], - ondelete='CASCADE'), - sa.PrimaryKeyConstraint('entrymachinenumberid'), - ) - create_index_if_missing('idx_machinenumber_entry', 'manifestentrymachinenumbers', - ['entryid']) - create_table_if_missing( - 'manifestinusechecks', - sa.Column('inusecheckid', sa.Integer(), nullable=False), - sa.Column('entryid', sa.Integer(), nullable=False), - sa.Column('behavior', sa.String(length=20), nullable=False), - sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], - ondelete='CASCADE'), - sa.PrimaryKeyConstraint('inusecheckid'), - sa.UniqueConstraint('entryid'), - ) - create_table_if_missing( - 'manifestinusecheckprocesses', - sa.Column('inusecheckprocessid', sa.Integer(), nullable=False), - sa.Column('inusecheckid', sa.Integer(), nullable=False), - sa.Column('sortorder', sa.Integer(), nullable=False), - sa.Column('processname', sa.String(length=64), nullable=False), - sa.Column('exepath', sa.String(length=255), nullable=True), - sa.Column('gracefulclosetimeoutsec', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['inusecheckid'], - ['manifestinusechecks.inusecheckid'], - ondelete='CASCADE'), - sa.PrimaryKeyConstraint('inusecheckprocessid'), - ) - create_index_if_missing('idx_process_check', 'manifestinusecheckprocesses', - ['inusecheckid']) - create_table_if_missing( - 'manifestpublishedversions', - sa.Column('publishedversionid', sa.Integer(), nullable=False), - sa.Column('scopeid', sa.Integer(), nullable=False), - sa.Column('versionnumber', sa.Integer(), nullable=False), - sa.Column('manifestjson', sa.Text(length=16777215), nullable=False), - sa.Column('publishedat', sa.DateTime(), nullable=False), - sa.Column('publishedby', sa.Integer(), nullable=True), - sa.Column('iscurrent', sa.Boolean(), nullable=False), - sa.Column('notes', sa.String(length=255), nullable=True), - sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'], - ondelete='CASCADE'), - sa.PrimaryKeyConstraint('publishedversionid'), - sa.UniqueConstraint('scopeid', 'versionnumber', - name='uq_published_scope_version'), - ) - create_index_if_missing('idx_published_scope', 'manifestpublishedversions', - ['scopeid']) - create_table_if_missing( - 'manifestpayloads', - sa.Column('payloadid', sa.Integer(), nullable=False), - sa.Column('entryid', sa.Integer(), nullable=False), - sa.Column('filename', sa.String(length=255), nullable=False), - sa.Column('contenttype', sa.String(length=128), nullable=True), - sa.Column('payloadbytes', sa.LargeBinary(length=16777215), nullable=False), - sa.Column('payloadsha256', sa.String(length=64), nullable=False), - sa.Column('uploadedat', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], - ondelete='CASCADE'), - sa.PrimaryKeyConstraint('payloadid'), - ) - create_index_if_missing('idx_payload_entry', 'manifestpayloads', ['entryid']) - create_table_if_missing( - 'manifestenforcementreports', - sa.Column('reportid', sa.Integer(), nullable=False), - sa.Column('hostname', sa.String(length=100), nullable=False), - sa.Column('scopename', sa.String(length=64), nullable=False), - sa.Column('phase', sa.String(length=16), nullable=False), - sa.Column('appliedversion', sa.Integer(), nullable=True), - sa.Column('enforcerversion', sa.String(length=20), nullable=True), - sa.Column('installedcount', sa.Integer(), nullable=False), - sa.Column('skippedcount', sa.Integer(), nullable=False), - sa.Column('failedcount', sa.Integer(), nullable=False), - sa.Column('filteredcount', sa.Integer(), nullable=False), - sa.Column('status', sa.String(length=16), nullable=False), - sa.Column('lastcheckin', sa.DateTime(), nullable=True), - sa.Column('receivedat', sa.DateTime(), nullable=False), - sa.Column('iscurrent', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('reportid'), - ) - create_index_if_missing('idx_report_host', 'manifestenforcementreports', - ['hostname']) - create_index_if_missing('idx_report_current', 'manifestenforcementreports', - ['iscurrent']) - create_index_if_missing('idx_report_host_scope', 'manifestenforcementreports', - ['hostname', 'scopename', 'phase']) - create_table_if_missing( - 'manifestenforcementresults', - sa.Column('resultid', sa.Integer(), nullable=False), - sa.Column('reportid', sa.Integer(), nullable=False), - sa.Column('entryname', sa.String(length=128), nullable=False), - sa.Column('action', sa.String(length=16), nullable=False), - sa.Column('selfhealed', sa.Boolean(), nullable=False), - sa.Column('exitcode', sa.Integer(), nullable=True), - sa.Column('message', sa.Text(), nullable=True), - sa.ForeignKeyConstraint(['reportid'], - ['manifestenforcementreports.reportid'], - ondelete='CASCADE'), - sa.PrimaryKeyConstraint('resultid'), - ) - create_index_if_missing('idx_result_report', 'manifestenforcementresults', - ['reportid']) - create_table_if_missing( - 'pctypealiases', - sa.Column('aliasid', sa.Integer(), nullable=False), - sa.Column('aliasgroup', sa.Integer(), nullable=False), - sa.Column('aliasname', sa.String(length=64), nullable=False), - sa.PrimaryKeyConstraint('aliasid'), - sa.UniqueConstraint('aliasgroup', 'aliasname', - name='uq_alias_group_name'), - ) - create_index_if_missing('idx_alias_group', 'pctypealiases', ['aliasgroup']) - - -def downgrade(): - op.drop_table('pctypealiases') - op.drop_table('manifestenforcementresults') - op.drop_table('manifestenforcementreports') - op.drop_table('manifestpayloads') - op.drop_table('manifestpublishedversions') - op.drop_table('manifestinusecheckprocesses') - op.drop_table('manifestinusechecks') - op.drop_table('manifestentrymachinenumbers') - op.drop_table('manifestentryhostnames') - op.drop_table('manifestentrypctypes') - op.drop_table('manifestentries') - op.drop_table('manifestscopes') diff --git a/plugins/geenforce/migrations/versions/0002_manifest_blobs.py b/plugins/geenforce/migrations/versions/0002_manifest_blobs.py deleted file mode 100644 index 31081846..00000000 --- a/plugins/geenforce/migrations/versions/0002_manifest_blobs.py +++ /dev/null @@ -1,47 +0,0 @@ -"""geenforce: manifestblobs (content-addressed http payload store) - -Big installers reach share-less (Intune/local-account) PCs by HTTPS download -instead of SMB. The bytes live on disk at /geenforce/payloads/; -this table is the registry (dedup by content hash). Idempotent guard. - -Revision ID: geenforce0002blobs -Revises: geenforce0001baseline -Create Date: 2026-07-21 - -""" -from alembic import op - -from shopdb.plugins.alembic_template import ( - create_index_if_missing, - create_table_if_missing, -) -import sqlalchemy as sa - - -revision = 'geenforce0002blobs' -down_revision = 'geenforce0001baseline' -branch_labels = None -depends_on = None - - -def upgrade(): - bind = op.get_bind() - insp = sa.inspect(bind) - if 'manifestblobs' in insp.get_table_names(): - return - create_table_if_missing( - 'manifestblobs', - sa.Column('sha256', sa.String(length=64), nullable=False), - sa.Column('filename', sa.String(length=255), nullable=False), - sa.Column('contenttype', sa.String(length=128), nullable=True), - sa.Column('sizebytes', sa.BigInteger(), nullable=False), - sa.Column('createdat', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('sha256'), - ) - - -def downgrade(): - bind = op.get_bind() - insp = sa.inspect(bind) - if 'manifestblobs' in insp.get_table_names(): - op.drop_table('manifestblobs') diff --git a/plugins/geenforce/migrations/versions/0003_report_subtype.py b/plugins/geenforce/migrations/versions/0003_report_subtype.py deleted file mode 100644 index e0ef5757..00000000 --- a/plugins/geenforce/migrations/versions/0003_report_subtype.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Record the display subtype a PC reports for itself. - -A kiosk knows whether it is a Dashboard, Lobby or 3DPrintRoom - the dispatcher -reads C:\\Enrollment\\display-type.txt to pick its target - but it never told -ShopDB, so the reports table had to infer it from the DashboardDefault -fqdn/ip mapping, which is blank unless someone added a row per kiosk. Reported -by the device beats inferred from a mapping table, the same way enforcerversion -already works. - -Revision ID: geenforce0003subtype -Revises: geenforce0002blobs -""" -from alembic import op -import sqlalchemy as sa - -revision = 'geenforce0003subtype' -down_revision = 'geenforce0002blobs' -branch_labels = None -depends_on = None - - -def _columns(bind): - insp = sa.inspect(bind) - if 'manifestenforcementreports' not in insp.get_table_names(): - return None - return {c['name'] for c in insp.get_columns('manifestenforcementreports')} - - -def upgrade(): - # Guarded, like network0003prefix. On a FRESH database the table is built - # from the SQLAlchemy models, which already declare this column, so an - # unconditional add fails with "duplicate column name" - and this chain has - # been re-running its head on MySQL (see the missing commit in - # shopdb/plugins/alembic_template.py), so a redeploy hit the same wall. - columns = _columns(op.get_bind()) - if columns is None or 'subtype' in columns: - return - op.add_column('manifestenforcementreports', - sa.Column('subtype', sa.String(length=50), nullable=True)) - - -def downgrade(): - columns = _columns(op.get_bind()) - if columns is None or 'subtype' not in columns: - return - op.drop_column('manifestenforcementreports', 'subtype') diff --git a/plugins/geenforce/migrations/versions/0004_scope_minlibversion.py b/plugins/geenforce/migrations/versions/0004_scope_minlibversion.py deleted file mode 100644 index e449eed4..00000000 --- a/plugins/geenforce/migrations/versions/0004_scope_minlibversion.py +++ /dev/null @@ -1,49 +0,0 @@ -"""A scope declares the client library it needs, separately from its own version. - -The publish gate exists to refuse a manifest the fleet's GE-Enforce library -cannot read correctly. It was comparing reported library versions against -`manifestversion` - the manifest's own 'Version' field, which for a share -imported manifest happens to share the library's numbering but for a -code-authored scope is whatever the author chose (seed_display_scope writes -'2.0'). Every kiosk runs something newer than that, so the gate passed on the -one scope most in need of it. - -`minlibversion` says the thing the gate actually wants to know. NULL means -derive it from what the manifest uses, so existing scopes need no edit. - -Revision ID: geenforce0004minlib -Revises: geenforce0003subtype -""" -from alembic import op -import sqlalchemy as sa - -revision = 'geenforce0004minlib' -down_revision = 'geenforce0003subtype' -branch_labels = None -depends_on = None - -TABLE = 'manifestscopes' - - -def _columns(bind): - insp = sa.inspect(bind) - if TABLE not in insp.get_table_names(): - return None - return {c['name'] for c in insp.get_columns(TABLE)} - - -def upgrade(): - # Guarded like geenforce0003subtype and network0003prefix: on a fresh - # database the table is built from the models, which already declare this. - columns = _columns(op.get_bind()) - if columns is None or 'minlibversion' in columns: - return - op.add_column(TABLE, sa.Column('minlibversion', sa.String(length=16), - nullable=True)) - - -def downgrade(): - columns = _columns(op.get_bind()) - if columns is None or 'minlibversion' not in columns: - return - op.drop_column(TABLE, 'minlibversion') diff --git a/plugins/geenforce/models/__init__.py b/plugins/geenforce/models/__init__.py deleted file mode 100644 index 1c1f79f2..00000000 --- a/plugins/geenforce/models/__init__.py +++ /dev/null @@ -1,46 +0,0 @@ -"""GE-Enforce plugin models.""" - -from .manifest import ( - ManifestScope, - ManifestEntry, - ManifestEntryPcType, - ManifestEntryHostname, - ManifestEntryMachineNumber, - ManifestInUseCheck, - ManifestInUseCheckProcess, - ManifestPublishedVersion, - ManifestPayload, - ManifestBlob, - ManifestEnforcementReport, - ManifestEnforcementResult, - PcTypeAlias, - PHASES, - ENTRY_TYPES, - REG_TYPES, - PAYLOAD_SOURCES, - DETECTION_METHODS, - APPLY_MODES, - INUSE_BEHAVIORS, -) - -__all__ = [ - 'ManifestScope', - 'ManifestEntry', - 'ManifestEntryPcType', - 'ManifestEntryHostname', - 'ManifestEntryMachineNumber', - 'ManifestInUseCheck', - 'ManifestInUseCheckProcess', - 'ManifestPublishedVersion', - 'ManifestPayload', - 'ManifestEnforcementReport', - 'ManifestEnforcementResult', - 'PcTypeAlias', - 'PHASES', - 'ENTRY_TYPES', - 'REG_TYPES', - 'PAYLOAD_SOURCES', - 'DETECTION_METHODS', - 'APPLY_MODES', - 'INUSE_BEHAVIORS', -] diff --git a/plugins/geenforce/models/manifest.py b/plugins/geenforce/models/manifest.py deleted file mode 100644 index 68f92a80..00000000 --- a/plugins/geenforce/models/manifest.py +++ /dev/null @@ -1,385 +0,0 @@ -"""GE-Enforce manifest-store models. - -The manifest that GE-Enforce runs on each PC becomes shopdb data. A -`ManifestScope` is one imaging PC type (a `gea-shopfloor-*` scope, the `common` -fleet-wide scope, or the single flat `preinstall` scope). Each scope owns an -ordered list of `ManifestEntry` rows - one per Applications[] entry in the -on-share manifest.json - plus child rows for the multi-value targeting filters -and the nested InUseCheck. - -Design choices (see the ge-enforce-plugin proposal in the project wiki): -- ONE wide entries table with an `entrytype` discriminator and nullable - per-type columns. Not STI, not a JSON blob: the whole fleet is ~64 entries, - so sparse columns are free and every field stays queryable + plain-SQL - readable by IT. -- Array order IS execution order, so `sortorder` is a real column and the - contract; config-restore entries sit after their installer on purpose. -- `computertypeid` / `measuringtooltypeid` are SOFT references (plain Integer, - no DB foreign key) to other plugins' tables, which may not be installed. -- Published manifests are immutable snapshots that freeze the rendered JSON - document (`ManifestPublishedVersion`); the draft (`ManifestEntry`) is never - served to a client. -""" - -from shopdb.api import db, BaseModel - - -# Allowed enumerations, validated in the API/service layer (stored as strings -# for portability, matching the rest of the codebase). -PHASES = ('runtime', 'preinstall') -ENTRY_TYPES = ('MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry') -REG_TYPES = ('String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary') -PAYLOAD_SOURCES = ('smb', 'http', 'inline') -DETECTION_METHODS = ('Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile', - 'ValueMatches', 'pnputil', 'Always') -APPLY_MODES = ('Nightly', 'Immediate', 'ImmediateReboot') -INUSE_BEHAVIORS = ('Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot') - - -class ManifestScope(BaseModel): - """One imaging PC type / manifest scope (the working/draft head).""" - __tablename__ = 'manifestscopes' - - scopeid = db.Column(db.Integer, primary_key=True) - # 'common', 'gea-shopfloor-cmm', 'preinstall', ... - scopename = db.Column(db.String(64), nullable=False) - # 'runtime' (per-pctype manifest.json) or 'preinstall' (one flat manifest) - phase = db.Column(db.String(16), nullable=False, default='runtime') - # Soft refs (no FK): the plugins that own these tables are optional. - computertypeid = db.Column(db.Integer, nullable=True) - measuringtooltypeid = db.Column(db.Integer, nullable=True) - manifestversion = db.Column(db.String(16), nullable=False, default='1.0') - # Lowest GE-Enforce client library that may enforce this scope, e.g. '2.6'. - # - # SEPARATE FROM manifestversion, which is the manifest's own 'Version' field - # and is whatever its author wrote - seed_display_scope picks '2.0'. The - # publish gate used manifestversion as the library floor, so for every - # code-authored scope it compared the fleet against a number that says - # nothing about library features, and passed. NULL means "derive it", see - # service.required_libversion. - minlibversion = db.Column(db.String(16), nullable=True) - description = db.Column(db.String(255), nullable=True) - # The manifest-level '_comment' (documentation), preserved verbatim so - # export-to-share round-trips it. Excluded from behavioral parity. - topcomment = db.Column(db.Text, nullable=True) - iscommon = db.Column(db.Boolean, nullable=False, default=False) - # Preinstall manifests carry a top-level 'Site' field. - site = db.Column(db.String(100), nullable=True) - - entries = db.relationship( - 'ManifestEntry', back_populates='scope', - cascade='all, delete-orphan', order_by='ManifestEntry.sortorder', - lazy='selectin') - publishedversions = db.relationship( - 'ManifestPublishedVersion', back_populates='scope', - cascade='all, delete-orphan', - order_by='ManifestPublishedVersion.versionnumber', lazy='dynamic') - - __table_args__ = ( - db.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'), - ) - - def __repr__(self): - return f"" - - -class ManifestEntry(BaseModel): - """One Applications[] entry (draft). Wide table, entrytype discriminator.""" - __tablename__ = 'manifestentries' - - entryid = db.Column(db.Integer, primary_key=True) - scopeid = db.Column( - db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'), - nullable=False, index=True) - sortorder = db.Column(db.Integer, nullable=False, default=0) - name = db.Column(db.String(128), nullable=False) - entrytype = db.Column(db.String(16), nullable=False) - comment = db.Column(db.Text, nullable=True) # manifest '_comment' - - # Type-specific payload references (nullable; validated per entrytype). - installer = db.Column(db.String(255), nullable=True) - installargs = db.Column(db.Text, nullable=True) # can run long - scriptpath = db.Column(db.String(255), nullable=True) # 'Script' - scriptargs = db.Column(db.String(255), nullable=True) # 'Args' - sourcepath = db.Column(db.String(255), nullable=True) # 'Source' - destination = db.Column(db.String(255), nullable=True) - regpath = db.Column(db.String(255), nullable=True) - regname = db.Column(db.String(128), nullable=True) - # Raw JSON literal preserved verbatim (1 vs "1"); DWord typing depends on it. - regvalue = db.Column(db.Text, nullable=True) - regtype = db.Column(db.String(16), nullable=True) - - # Payload transport + integrity (integrity hash is SEPARATE from detection). - payloadsource = db.Column(db.String(8), nullable=False, default='smb') - payloadref = db.Column(db.String(512), nullable=True) - payloadsha256 = db.Column(db.String(64), nullable=True) - - # Detection (decides whether the action fires / self-heals). - detectionmethod = db.Column(db.String(16), nullable=True) - detectionpath = db.Column(db.String(255), nullable=True) - detectionname = db.Column(db.String(128), nullable=True) - detectionvalue = db.Column(db.String(255), nullable=True) - detectionpattern = db.Column(db.String(255), nullable=True) - - # Gates + control. - cmmversion = db.Column(db.String(16), nullable=True) # '_CmmVersion' - logfile = db.Column(db.String(255), nullable=True) - waittimeoutsec = db.Column(db.Integer, nullable=True) - applymode = db.Column(db.String(16), nullable=True) # inert in engine today - updatewindow = db.Column(db.String(11), nullable=True) # 'HH:MM-HH:MM', inert - - # Optional CURATED link to a core Applications catalog row (soft ref, no FK - # to keep the plugin decoupled). shopdb metadata only - NOT part of the - # manifest JSON the engine reads, so it never affects enforcement or parity. - appid = db.Column(db.Integer, nullable=True) - - # Preinstall-only flags. - preenrollment = db.Column(db.Boolean, nullable=False, default=False) - killafterdetection = db.Column(db.Boolean, nullable=False, default=False) - pctypesstrict = db.Column(db.Boolean, nullable=False, default=False) - - scope = db.relationship('ManifestScope', back_populates='entries') - pctypes = db.relationship( - 'ManifestEntryPcType', back_populates='entry', - cascade='all, delete-orphan', order_by='ManifestEntryPcType.sortorder', - lazy='selectin') - hostnames = db.relationship( - 'ManifestEntryHostname', back_populates='entry', - cascade='all, delete-orphan', order_by='ManifestEntryHostname.sortorder', - lazy='selectin') - machinenumbers = db.relationship( - 'ManifestEntryMachineNumber', back_populates='entry', - cascade='all, delete-orphan', - order_by='ManifestEntryMachineNumber.sortorder', lazy='selectin') - inusecheck = db.relationship( - 'ManifestInUseCheck', back_populates='entry', uselist=False, - cascade='all, delete-orphan', lazy='selectin') - - __table_args__ = ( - db.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'), - db.Index('idx_entry_scope_order', 'scopeid', 'sortorder'), - ) - - def __repr__(self): - return f"" - - -class ManifestEntryPcType(db.Model): - """One value of an entry's PCTypes filter (verbatim, incl. '*' and aliases).""" - __tablename__ = 'manifestentrypctypes' - - entrypctypeid = db.Column(db.Integer, primary_key=True) - entryid = db.Column( - db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), - nullable=False, index=True) - sortorder = db.Column(db.Integer, nullable=False, default=0) - pctypevalue = db.Column(db.String(64), nullable=False) - - entry = db.relationship('ManifestEntry', back_populates='pctypes') - - -class ManifestEntryHostname(db.Model): - """One value of an entry's TargetHostnames filter (wildcards kept verbatim).""" - __tablename__ = 'manifestentryhostnames' - - entryhostnameid = db.Column(db.Integer, primary_key=True) - entryid = db.Column( - db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), - nullable=False, index=True) - sortorder = db.Column(db.Integer, nullable=False, default=0) - hostnamepattern = db.Column(db.String(64), nullable=False) - - entry = db.relationship('ManifestEntry', back_populates='hostnames') - - -class ManifestEntryMachineNumber(db.Model): - """One value of an entry's TargetMachineNumbers filter.""" - __tablename__ = 'manifestentrymachinenumbers' - - entrymachinenumberid = db.Column(db.Integer, primary_key=True) - entryid = db.Column( - db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), - nullable=False, index=True) - sortorder = db.Column(db.Integer, nullable=False, default=0) - machinenumber = db.Column(db.String(16), nullable=False) - - entry = db.relationship('ManifestEntry', back_populates='machinenumbers') - - -class ManifestInUseCheck(db.Model): - """The InUseCheck object on an entry (0..1), with its Processes[] children.""" - __tablename__ = 'manifestinusechecks' - - inusecheckid = db.Column(db.Integer, primary_key=True) - entryid = db.Column( - db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), - nullable=False, unique=True) - behavior = db.Column(db.String(20), nullable=False) - - entry = db.relationship('ManifestEntry', back_populates='inusecheck') - processes = db.relationship( - 'ManifestInUseCheckProcess', back_populates='inusecheck', - cascade='all, delete-orphan', - order_by='ManifestInUseCheckProcess.sortorder', lazy='selectin') - - -class ManifestInUseCheckProcess(db.Model): - """One process in an InUseCheck's Processes[] list.""" - __tablename__ = 'manifestinusecheckprocesses' - - inusecheckprocessid = db.Column(db.Integer, primary_key=True) - inusecheckid = db.Column( - db.Integer, - db.ForeignKey('manifestinusechecks.inusecheckid', ondelete='CASCADE'), - nullable=False, index=True) - sortorder = db.Column(db.Integer, nullable=False, default=0) - processname = db.Column(db.String(64), nullable=False) - exepath = db.Column(db.String(255), nullable=True) - # Null = engine default (10); do not bake the default into the row. - gracefulclosetimeoutsec = db.Column(db.Integer, nullable=True) - - inusecheck = db.relationship('ManifestInUseCheck', back_populates='processes') - - -class ManifestPublishedVersion(db.Model): - """Immutable published snapshot: the frozen rendered JSON document. - - The client is always served the `iscurrent` snapshot for a scope, never the - live draft. Rollback flips `iscurrent`. Freezing the text (not row-mirroring) - makes immutability structural and rollback a one-flag change. - """ - __tablename__ = 'manifestpublishedversions' - - publishedversionid = db.Column(db.Integer, primary_key=True) - scopeid = db.Column( - db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'), - nullable=False, index=True) - versionnumber = db.Column(db.Integer, nullable=False) - # MEDIUMTEXT on MySQL (a full scope with comments can exceed 64 KB TEXT). - manifestjson = db.Column(db.Text(length=16777215), nullable=False) - publishedat = db.Column(db.DateTime, nullable=False) - publishedby = db.Column(db.Integer, nullable=True) # soft ref to users - iscurrent = db.Column(db.Boolean, nullable=False, default=False) - notes = db.Column(db.String(255), nullable=True) - - scope = db.relationship('ManifestScope', back_populates='publishedversions') - - __table_args__ = ( - db.UniqueConstraint('scopeid', 'versionnumber', - name='uq_published_scope_version'), - ) - - -class ManifestPayload(db.Model): - """Inline payload bytes for payloadsource='inline' (small scripts/configs).""" - __tablename__ = 'manifestpayloads' - - payloadid = db.Column(db.Integer, primary_key=True) - entryid = db.Column( - db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), - nullable=False, index=True) - filename = db.Column(db.String(255), nullable=False) - contenttype = db.Column(db.String(128), nullable=True) - payloadbytes = db.Column(db.LargeBinary(length=16777215), nullable=False) - payloadsha256 = db.Column(db.String(64), nullable=False) - uploadedat = db.Column(db.DateTime, nullable=False) - - -class ManifestEnforcementReport(db.Model): - """One enforcement cycle reported by a PC (observed state). - - Each cycle the client POSTs its result for a scope: the published version it - actually applied (so shopdb knows whether the PC RECEIVED the latest update), - the enforcer version, and the installed/skipped/failed/filtered counts. The - latest report per (hostname, scopename, phase) carries `iscurrent`; older - ones are history. Pairs desired state (the manifest) with observed state. - """ - __tablename__ = 'manifestenforcementreports' - - reportid = db.Column(db.Integer, primary_key=True) - hostname = db.Column(db.String(100), nullable=False, index=True) - scopename = db.Column(db.String(64), nullable=False) - phase = db.Column(db.String(16), nullable=False, default='runtime') - # Published version the client actually ran; compare to the scope's current - # published version to see whether this PC received the latest manifest. - appliedversion = db.Column(db.Integer, nullable=True) - enforcerversion = db.Column(db.String(20), nullable=True) - # What the PC says it IS within its scope (a display's Dashboard / Lobby / - # 3DPrintRoom). Reported by the device, which reads it from its own - # enrollment file - not inferred from a mapping table that may have no row - # for this host. - subtype = db.Column(db.String(50), nullable=True) - installedcount = db.Column(db.Integer, nullable=False, default=0) - skippedcount = db.Column(db.Integer, nullable=False, default=0) - failedcount = db.Column(db.Integer, nullable=False, default=0) - filteredcount = db.Column(db.Integer, nullable=False, default=0) - # 'ok' | 'failed' (any failure) | 'selfhealed' (drift corrected, no failure). - status = db.Column(db.String(16), nullable=False, default='ok') - lastcheckin = db.Column(db.DateTime, nullable=True) # PC-reported time - receivedat = db.Column(db.DateTime, nullable=False) # server time - iscurrent = db.Column(db.Boolean, nullable=False, default=True, index=True) - - results = db.relationship( - 'ManifestEnforcementResult', back_populates='report', - cascade='all, delete-orphan', lazy='selectin') - - __table_args__ = ( - db.Index('idx_report_host_scope', 'hostname', 'scopename', 'phase'), - ) - - -class ManifestEnforcementResult(db.Model): - """One entry's outcome within an enforcement cycle (self-heal detail).""" - __tablename__ = 'manifestenforcementresults' - - resultid = db.Column(db.Integer, primary_key=True) - reportid = db.Column( - db.Integer, - db.ForeignKey('manifestenforcementreports.reportid', ondelete='CASCADE'), - nullable=False, index=True) - entryname = db.Column(db.String(128), nullable=False) - # 'installed' (action fired - a self-heal when it should already be present), - # 'skipped' (detected present), 'failed', 'filtered'. - action = db.Column(db.String(16), nullable=False) - # True when this install was a drift correction (self-heal), not a first - # install. Client-supplied; defaults to whether the action installed. - selfhealed = db.Column(db.Boolean, nullable=False, default=False) - exitcode = db.Column(db.Integer, nullable=True) - message = db.Column(db.Text, nullable=True) # warning / error text - - report = db.relationship('ManifestEnforcementReport', back_populates='results') - - -class PcTypeAlias(db.Model): - """Mirror of the engine lib's PCTypes alias graph (Install-FromManifest.ps1). - - Rows sharing an `aliasgroup` are one alias set. Server-side resolve/validate - only; the engine lib stays the single source of truth (never inverted). - """ - __tablename__ = 'pctypealiases' - - aliasid = db.Column(db.Integer, primary_key=True) - aliasgroup = db.Column(db.Integer, nullable=False, index=True) - aliasname = db.Column(db.String(64), nullable=False) - - __table_args__ = ( - db.UniqueConstraint('aliasgroup', 'aliasname', name='uq_alias_group_name'), - ) - - -class ManifestBlob(db.Model): - """Content-addressed payload blob for http-delivered installers. - - Bytes live on disk at /geenforce/payloads/ (deduped by - content, so a payload shared by many entries is stored once). Manifest - entries reference a blob by payloadsha256; the client fetches it from - GET /api/geenforce/payload/ over HTTPS and verifies the hash. This - is how big installers reach share-less (Intune/local-account) PCs. - """ - __tablename__ = 'manifestblobs' - - sha256 = db.Column(db.String(64), primary_key=True) - filename = db.Column(db.String(255), nullable=False) - contenttype = db.Column(db.String(128), nullable=True) - sizebytes = db.Column(db.BigInteger, nullable=False) - createdat = db.Column(db.DateTime, nullable=False) diff --git a/plugins/geenforce/parity.py b/plugins/geenforce/parity.py deleted file mode 100644 index ba7ca5ad..00000000 --- a/plugins/geenforce/parity.py +++ /dev/null @@ -1,106 +0,0 @@ -"""P1 behavioral-parity harness (Gate A). - -Proves the model is lossless WITHOUT byte-diffing: for each manifest, import it -into in-memory rows and render it back out, then check that - - 1. every entry is field-for-field identical (engine-relevant fields only, - dropping _comment and key order), in the same order, AND - 2. the same entries fire in the same order for a set of machine-profile - fixtures, using the same filter logic the engine uses (filters.py). - -Runs with no database (build_scope produces detached ORM objects whose -relationship lists are read directly), so it is cheap to re-run and wrap in CI. -Output is one readable line per scope. -""" - -import json -import os - -from .importer import build_scope -from .serializer import scope_to_manifest, canonical_entry -from .filters import applicable_entry_names - -_FIXTURES_PATH = os.path.join(os.path.dirname(__file__), 'parityfixtures.json') - - -def load_fixtures(): - with open(_FIXTURES_PATH) as handle: - return json.load(handle) - - -def check_scope(scopename, phase, original, fixtures): - """Compare an original manifest against its import+export round-trip.""" - rebuilt = scope_to_manifest(build_scope(scopename, phase, original)) - orig_apps = original.get('Applications') or [] - rebuilt_apps = rebuilt.get('Applications') or [] - - # Check 0: scope-level fields (Version, Site) round-trip. - firstdiff = None - for key in ('Version', 'Site'): - if str(original.get(key) or '') != str(rebuilt.get(key) or ''): - firstdiff = (f'scope field {key}: ' - f'{original.get(key)!r} vs {rebuilt.get(key)!r}') - break - - scope_ok = firstdiff is None - - # Check 1: field-identical, order-preserving. - identical = 0 - if firstdiff is None and len(orig_apps) != len(rebuilt_apps): - firstdiff = (f'entry count {len(orig_apps)} vs {len(rebuilt_apps)}') - for i in range(min(len(orig_apps), len(rebuilt_apps))): - co = canonical_entry(orig_apps[i]) - cr = canonical_entry(rebuilt_apps[i]) - if co == cr: - identical += 1 - elif firstdiff is None: - name = orig_apps[i].get('Name', f'#{i}') - diffkeys = sorted( - k for k in set(co) | set(cr) if co.get(k) != cr.get(k)) - firstdiff = f'entry "{name}" differs on {diffkeys}' - - # Check 2: same entries fire in the same order for each profile. Inject the - # scope phase so PCTypesStrict is honored only for preinstall (see filters). - profiles_same = 0 - for base_profile in fixtures: - profile = {**base_profile, 'phase': phase} - if (applicable_entry_names(orig_apps, profile) - == applicable_entry_names(rebuilt_apps, profile)): - profiles_same += 1 - elif firstdiff is None: - firstdiff = f'filter mismatch for profile {profile.get("label")}' - - passed = (scope_ok - and identical == len(orig_apps) == len(rebuilt_apps) - and profiles_same == len(fixtures)) - return { - 'scopename': scopename, - 'phase': phase, - 'entries_total': len(orig_apps), - 'entries_identical': identical, - 'profiles_total': len(fixtures), - 'profiles_same': profiles_same, - 'passed': passed, - 'firstdiff': firstdiff, - } - - -def run_parity(manifests, fixtures=None): - """Run parity over a list of (scopename, phase, manifest_dict). Returns - (results, all_passed).""" - fixtures = fixtures if fixtures is not None else load_fixtures() - results = [check_scope(name, phase, manifest, fixtures) - for name, phase, manifest in manifests] - return results, all(r['passed'] for r in results) - - -def format_result(result): - """One human-readable line for a scope result.""" - status = 'PASS' if result['passed'] else 'FAIL' - line = (f"scope {result['scopename']:<28} " - f"entries {result['entries_identical']}/{result['entries_total']} identical " - f"profiles {result['profiles_same']}/{result['profiles_total']} same-fire " - f"{status}") - if not result['passed'] and result['firstdiff']: - line += f"\n -> {result['firstdiff']}" - return line diff --git a/plugins/geenforce/parityfixtures.json b/plugins/geenforce/parityfixtures.json deleted file mode 100644 index 2ec3045b..00000000 --- a/plugins/geenforce/parityfixtures.json +++ /dev/null @@ -1,20 +0,0 @@ -[ - {"label": "collections-okuma-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0615", "machinenumber": "3201", "cmmversion": ""}, - {"label": "collections-mtconnect-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC7501", "machinenumber": "7501", "cmmversion": ""}, - {"label": "collections-other-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0001", "machinenumber": "0001", "cmmversion": ""}, - {"label": "nocollections", "pctype": "gea-shopfloor-nocollections", "subtype": "", "hostname": "WJPC2000", "machinenumber": "", "cmmversion": ""}, - {"label": "common-timeclock", "pctype": "gea-shopfloor-common", "subtype": "", "hostname": "WJPC3000", "machinenumber": "", "cmmversion": ""}, - {"label": "cmm-2016", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM01", "machinenumber": "9101", "cmmversion": "2016"}, - {"label": "cmm-2019", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM02", "machinenumber": "9102", "cmmversion": "2019"}, - {"label": "cmm-2026", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM11", "machinenumber": "9111", "cmmversion": "2026"}, - {"label": "cmm-noversion-legacy", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM99", "machinenumber": "9199", "cmmversion": ""}, - {"label": "keyence-vr6000", "pctype": "Keyence", "subtype": "vr6000", "hostname": "WJKEY01", "machinenumber": "8001", "cmmversion": ""}, - {"label": "keyence-vr3000", "pctype": "Keyence", "subtype": "vr3000", "hostname": "WJKEY02", "machinenumber": "8002", "cmmversion": ""}, - {"label": "genspect", "pctype": "gea-shopfloor-genspect", "subtype": "", "hostname": "WJGEN01", "machinenumber": "8100", "cmmversion": ""}, - {"label": "heattreat", "pctype": "gea-shopfloor-heattreat", "subtype": "", "hostname": "WJHT01", "machinenumber": "8200", "cmmversion": ""}, - {"label": "waxtrace", "pctype": "gea-shopfloor-waxtrace", "subtype": "", "hostname": "WJWAX01", "machinenumber": "8300", "cmmversion": ""}, - {"label": "partmarker", "pctype": "gea-shopfloor-partmarker", "subtype": "", "hostname": "WJPM01", "machinenumber": "8400", "cmmversion": ""}, - {"label": "display", "pctype": "gea-shopfloor-display", "subtype": "", "hostname": "WJDISP01", "machinenumber": "", "cmmversion": ""}, - {"label": "legacy-standard-machine", "pctype": "Standard", "subtype": "Machine", "hostname": "WJLEG01", "machinenumber": "3202", "cmmversion": ""}, - {"label": "legacy-cmm-name", "pctype": "CMM", "subtype": "", "hostname": "WJLEG02", "machinenumber": "9102", "cmmversion": "2019"} -] diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py deleted file mode 100644 index 2cd3124e..00000000 --- a/plugins/geenforce/plugin.py +++ /dev/null @@ -1,299 +0,0 @@ -"""GE-Enforce manifest-store plugin. - -Owns the imaging-PC-type scopes and their install manifests as shopdb data -(see the ge-enforce-plugin proposal in the project wiki). This is the P0/P1 foundation: models, -the alias-graph seed, and the client-facing manifest endpoint. Authoring UI and -client cutover come in later phases. -""" - -import json -import logging -from pathlib import Path -from typing import List, Dict, Optional, Type - -import click -from flask import Flask, Blueprint - -from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.api import db - -from .api import geenforce_bp -from .models import ( - ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname, - ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess, - ManifestPublishedVersion, ManifestPayload, ManifestBlob, - ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias, -) -from .filters import ALIAS_GROUPS - -logger = logging.getLogger(__name__) - - -class GeEnforcePlugin(BasePlugin): - """Desired-state manifest store for the GE-Enforce client.""" - - def __init__(self): - self._manifest = self._load_manifest() - - def _load_manifest(self) -> Dict: - manifest_path = Path(__file__).parent / 'manifest.json' - if manifest_path.exists(): - with open(manifest_path, 'r') as handle: - return json.load(handle) - return {} - - @property - def meta(self) -> PluginMeta: - return PluginMeta( - name=self._manifest.get('name', 'geenforce'), - version=self._manifest.get('version', '0.1.0'), - description=self._manifest.get('description', 'GE-Enforce manifest store'), - author=self._manifest.get('author', 'ShopDB Team'), - dependencies=self._manifest.get('dependencies', []), - core_version=self._manifest.get('core_version', '>=0.7.0,<1.0.0'), - api_prefix=self._manifest.get('api_prefix', '/api/geenforce'), - ) - - def get_blueprint(self) -> Optional[Blueprint]: - return geenforce_bp - - def get_models(self) -> List[Type]: - return [ - ManifestScope, ManifestEntry, ManifestEntryPcType, - ManifestEntryHostname, ManifestEntryMachineNumber, - ManifestInUseCheck, ManifestInUseCheckProcess, - ManifestPublishedVersion, ManifestPayload, ManifestBlob, - ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias, - ] - - def get_dashboard_widgets(self) -> List[Dict]: - """Dashboard cards this plugin contributes. - - DATA AND SHAPE, not a component name. The older widget contract named a - Vue component per widget, which cannot survive a lean build - a - plugin's component may never be staged into the frontend bundle - and - is why five plugins declared widgets pointing at components nobody ever - wrote. Core owns a small set of generic renderers; a plugin says what - to show and how to link it. Same lesson ADR-010 already applied to - asset panels. - - `empty: hide` matters as much as the data. A card that reports "nothing - wrong" every day teaches people to stop reading the page, which is - exactly how a fleet log reached 3,234 lines with 17 that mattered. - """ - return [ - { - 'id': 'geenforce-failures', - 'viewall': '/geenforce', - 'title': 'Enforcement failures', - 'shortname': 'Enforcement', - 'endpoint': '/api/geenforce/dashboard/failures', - 'render': 'exceptions', - 'severity': 'critical', - 'permission': 'geenforce.manage', - 'empty': 'hide', - 'position': 10, - 'map': { - 'title': 'hostname', - 'detail': 'entryname', - 'meta': [{'key': 'message'}, {'key': 'exitcode', - 'label': 'exit'}], - 'link': '/pcs/{computerid}', - 'timestamp': 'receivedat', - }, - }, - ] - - def get_permissions(self) -> List: - """RBAC permissions this plugin owns (edit vs ship are split).""" - return [ - ('geenforce.view', 'View imaging PC types and manifests', - 'geenforce'), - ('geenforce.manage', 'Edit imaging PC types and manifest drafts', - 'geenforce'), - ('geenforce.publish', 'Publish, roll back, and export manifests', - 'geenforce'), - ('geenforce.fetch', 'Fetch published manifests (client service token)', - 'geenforce'), - ('geenforce.report', 'Report enforcement results (client service token)', - 'geenforce'), - ] - - def get_navigation_items(self) -> List[Dict]: - """Top-level sidebar section (GE-Enforce is a large operational surface, - not a mere setting). The tabbed shell hosts Manifests + Reports.""" - return [ - { - 'name': 'GE-Enforce', - 'icon': 'shield', - 'route': '/geenforce/manifests', - 'position': 46, - # The READ permission: managing implies it, and it is the one a - # site can hand to guests. - 'permission': ['geenforce.view', 'geenforce.manage'], - 'section': 'fleet', - }, - ] - - def get_settings_cards(self) -> List[Dict]: - """Admin config for GE-Enforce lives in the Settings rail.""" - return [ - { - 'group': 'GE-Enforce', - 'to': '/settings/geenforce', - 'icon': 'shield', - 'title': 'GE-Enforce Settings', - 'description': 'Client IP allowlist for token-less fleet access', - 'position': 46, - }, - ] - - def get_settings_defaults(self) -> List[Dict]: - """How long a PC may go quiet before the fleet table calls it stale.""" - return [ - { - 'key': 'geenforce_reportstaleminutes', - 'value': '30', - 'valuetype': 'integer', - 'category': 'geenforce', - 'description': 'Minutes without an enforcement report before a ' - 'PC is shown as stale. A report records how the ' - 'LAST cycle went, so a PC that stops reporting ' - 'keeps whatever status it last sent - it reads ' - 'as healthy while it is switched off. Set to 0 ' - 'to disable the check.', - # Server-side only: nothing on a PC reads this, so it stays off - # the public settings surface. - 'public': False, - }, - ] - - def init_app(self, app: Flask, db_instance) -> None: - logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})") - - def on_install(self, app: Flask) -> None: - with app.app_context(): - self._seed_aliases() - db.session.commit() - logger.info("GE-Enforce plugin installed") - - def _seed_aliases(self) -> None: - """Seed pctypealiases from the engine lib's alias graph (idempotent).""" - for group_index, group in enumerate(ALIAS_GROUPS): - for aliasname in group: - exists = PcTypeAlias.query.filter_by( - aliasgroup=group_index, aliasname=aliasname).first() - if not exists: - db.session.add(PcTypeAlias( - aliasgroup=group_index, aliasname=aliasname)) - - def get_cli_commands(self) -> List: - """CLI: parity (Gate A), import-share, export-share, publish.""" - - @click.group('geenforce') - def geenforce_cli(): - """GE-Enforce manifest-store commands.""" - - @geenforce_cli.command('parity') - @click.option('--shareroot', required=True, - help='GE-Enforce share root (contains common/, ' - 'gea-shopfloor-*/).') - @click.option('--preinstall', default=None, - help='Optional path to a preinstall.json to include.') - def parity_cmd(shareroot, preinstall): - """Prove import+export is behaviorally lossless (Gate A). DB-free.""" - from .importer import discover_share, load_manifest_file - from .parity import run_parity, format_result - - manifests = list(discover_share(shareroot)) - if preinstall: - manifests.append( - ('preinstall', 'preinstall', load_manifest_file(preinstall))) - results, ok = run_parity(manifests) - for result in results: - click.echo(format_result(result)) - click.echo(f"RESULT: {'PASS' if ok else 'FAIL'} " - f"({len(results)} scopes)") - raise SystemExit(0 if ok else 1) - - @geenforce_cli.command('import-share') - @click.option('--shareroot', required=True) - @click.option('--preinstall', default=None) - @click.option('--scope', default=None, - help='Import only this scope name (else all).') - def import_share_cmd(shareroot, preinstall, scope): - """Import on-share manifests into draft rows (idempotent rebuild).""" - from flask import current_app - from .importer import discover_share, load_manifest_file - from .service import replace_scope_draft - - with current_app.app_context(): - sources = list(discover_share(shareroot)) - if preinstall: - sources.append(('preinstall', 'preinstall', - load_manifest_file(preinstall))) - count = 0 - for name, phase, manifest in sources: - if scope and name != scope: - continue - replace_scope_draft(name, phase, manifest) - count += 1 - db.session.commit() - click.echo(f"Imported {count} scope(s).") - - @geenforce_cli.command('publish') - @click.argument('scopename') - @click.option('--phase', default='runtime') - @click.option('--notes', default=None) - @click.option('--force', is_flag=True, - help='Publish even when reporting PCs run an older ' - 'enforcer lib than the manifest requires.') - def publish_cmd(scopename, phase, notes, force): - """Freeze the current draft of a scope into a published snapshot.""" - from flask import current_app - from .service import publish_scope, LibVersionTooOldError - - with current_app.app_context(): - try: - version = publish_scope(scopename, phase, notes=notes, - force=force) - except LibVersionTooOldError as error: - click.echo(click.style(f'REFUSED: {error}', fg='red')) - raise SystemExit(1) - db.session.commit() - click.echo(f"Published {scopename}/{phase} as v{version}.") - - @geenforce_cli.command('export-share') - @click.argument('scopename') - @click.option('--shareroot', required=True) - @click.option('--phase', default='runtime') - def export_share_cmd(scopename, shareroot, phase): - """Write a scope's published JSON to the share (with history backup).""" - from flask import current_app - from .service import export_scope_to_share - - with current_app.app_context(): - path = export_scope_to_share(scopename, phase, shareroot) - click.echo(f"Exported to {path}.") - - @geenforce_cli.command('add-payload') - @click.argument('filepath') - @click.option('--contenttype', default=None) - def add_payload_cmd(filepath, contenttype): - """Store a file in the content-addressed payload store for HTTPS - delivery, and print its sha256. Set an entry's PayloadSource=http + - PayloadSha256 to serve it from GET /api/geenforce/payload/.""" - import os as _os - from flask import current_app - from .service import store_blob - - with current_app.app_context(): - with open(filepath, 'rb') as handle: - raw = handle.read() - sha = store_blob(raw, _os.path.basename(filepath), contenttype) - db.session.commit() - click.echo(f"stored {len(raw)} bytes") - click.echo(f"PayloadSha256: {sha}") - click.echo(f"URL: /api/geenforce/payload/{sha}") - - return [geenforce_cli] diff --git a/plugins/geenforce/seed_display_scope.py b/plugins/geenforce/seed_display_scope.py deleted file mode 100644 index 82919867..00000000 --- a/plugins/geenforce/seed_display_scope.py +++ /dev/null @@ -1,943 +0,0 @@ -"""Author the gea-shopfloor-display runtime scope programmatically. - -Displays are Intune/Entra-joined, credential-less kiosk PCs that pull their -manifest over HTTPS (no SMB share). The kiosk engine and the kiosk browser are -BAKED INTO THE DISPLAY IMAGE, so this scope does not ship any EXE payloads; it -heals POLICY / CONFIG drift only, plus one dispatcher that points the kiosk at -the right target for the display subtype. - -What this scope contains: -- Four Registry drift-heal entries that re-assert the Microsoft Edge kiosk - relaunch policies from the imaging script 09-Setup-Display.ps1 (so a display - that loses those policies self-heals on the next enforce cycle without a - keyboard or mouse on site). -- One inline PS1 dispatcher that reads C:\\Enrollment\\display-type.txt and - launches the kiosk target for the subtype. The display-type -> target map is - a data-driven table (DISPLAY_TYPE_TARGETS) so the targets are easy to edit. - -Self-sufficient: the display scope carries EVERYTHING a display enforces and -does NOT inherit the fleet-wide 'common' scope. Displays run the enforcer with -common-merge off (the client default), so common's SMB-backed fleet entries -never reach a share-less display. This keeps the display path simple and needs -no common-payload repackaging. - -Authoring path mirrors how every other scope is created: build a manifest dict -and hand it to service.replace_scope_draft (the same call the import-share CLI -uses), then attach the inline dispatcher payload and optionally publish. Re- -running replace_scope_draft is an idempotent draft rebuild. -""" - -import hashlib -import os - -from shopdb.api import db, DISPLAY_ROLE_PATHS - -from . import service - - -SCOPE_NAME = 'gea-shopfloor-display' -SCOPE_PHASE = 'runtime' -SCOPE_VERSION = '2.0' - -# Edge kiosk relaunch policy key. Values below mirror 09-Setup-Display.ps1 -# exactly so the imaging-time state and the enforced state never disagree. -EDGE_POLICY_PATH = 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge' -RELAUNCH_WINDOW_JSON = ( - '{"entries":[{"start":{"hour":2,"minute":0},"duration_mins":120}]}') - -# Data-driven display-type -> kiosk target map. The value of -# C:\Enrollment\display-type.txt selects the row; the target is a route the -# kiosk browser opens against the local kiosk base URL. Keys are matched -# case-insensitively by the dispatcher. -# -# NOT a copy any more: this IS core's DISPLAY_ROLE_PATHS, read through the -# contract surface. It used to be a second map with the same routes under -# different names, which is how a display could report a role core could not -# store. Retarget a subtype in shopdb/core/models/dashboarddefault.py. -# -# TODO-confirm: 3DPrintRoom points at the printedparts /parts-kiosk route as a -# PLACEHOLDER. Confirm the real 3D-print-room kiosk target with the floor team -# before this scope is published to production displays. -DISPLAY_TYPE_TARGETS = DISPLAY_ROLE_PATHS - -DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1' - -# The enforce client module itself, delivered as an enforced entry so a client -# change no longer means hands on every kiosk. Path matches where -# Install-ShopdbKiosk.ps1 puts it, so the installer stays the BOOTSTRAP and this -# becomes the update path. -CLIENT_MODULE_FILENAME = 'ShopdbEnforceClient.psm1' -CLIENT_MODULE_DEST = r'C:\ProgramData\GE-Enforce\ShopdbEnforceClient.psm1' - -# One-shot: apply a pending Edge update and bounce the kiosk browser. The marker -# path carries a DATE. That is the whole re-arm mechanism: change the date and -# every display runs it once more; leave it alone and each runs it exactly once. -FORCE_EDGE_UPDATE_FILENAME = 'Invoke-EdgeForceUpdate.ps1' -FORCE_EDGE_UPDATE_MARKER = ( - r'C:\ProgramData\ShopDB\markers\edge-force-update-2026-08-12.done') - - -def _relaunch_window_targets_comment(): - """Human note that lists the data-driven targets, for the manifest comment.""" - pairs = ', '.join(f'{name}={route}' - for name, route in DISPLAY_TYPE_TARGETS.items()) - return pairs - - -def _site_base_url(): - """This site's public base URL from settings, or '' when it is unset. - - Wrapped because the script builders are also called with no application - context (the seed tests build the text and check it compiles), and a - settings lookup there raises rather than returning a default. - """ - try: - from shopdb.api import Setting - return (Setting.get('site_base_url', '') or '').strip().rstrip('/') - except Exception: - return '' - - -def build_dispatcher_script(): - """Return the inline dispatcher PowerShell as text. - - The display-type -> target map is emitted as a hashtable at the top of the - script (generated from DISPLAY_TYPE_TARGETS) so the on-PC script and the - manifest metadata agree and both stay easy to edit. - """ - table_lines = [] - for display_type, route in DISPLAY_TYPE_TARGETS.items(): - table_lines.append(f" '{display_type}' = '{route}'") - table_body = ';\n'.join(table_lines) - - # Baked in at seed time because the emitted script runs on a kiosk with no - # route back to the database. Blank when the site has not set it, and the - # dispatcher refuses rather than guessing - see the comment on $KioskBaseUrl. - kiosk_base_url = _site_base_url() - - return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher. -# -# Reads C:\\Enrollment\\display-type.txt and ensures an all-users Startup -# shortcut that launches Edge in kiosk fullscreen at the route mapped for that -# subtype. The kiosk browser is baked into the display image; this only points -# the launch at the right target and heals the shortcut on a URL/type change. -# -# WHY a Startup shortcut, not Start-Process: this runs as SYSTEM under the -# enforce task. SYSTEM has no interactive desktop, so Start-Process msedge.exe -# opens INVISIBLY in session 0. A shortcut in the all-users Startup folder is -# instead launched by the auto-login user at logon, in their own visible -# session. Idempotent: prior ShopDB kiosk shortcuts are cleared first, then the -# one for the resolved target is written, so a display-type/URL change self-heals. -# -# The DisplayTypeTargets table below is the single source of truth for the -# subtype -> route map. Edit a row to retarget a subtype. - -$ErrorActionPreference = 'Continue' - -# --- Data-driven subtype -> kiosk route map --- -$DisplayTypeTargets = @{{ -{table_body} -}} - -# Base URL the kiosk browser opens; the route from the table is appended. Read -# from HKLM (the value the GE-Enforce client is configured with) so it stays -# site-agnostic. The fallback is THIS site's site_base_url setting, baked in -# when the scope was seeded - it used to be one particular plant's host, which -# meant a kiosk anywhere else with no registry value quietly opened a server it -# has no business reaching. -$KioskBaseUrl = '{kiosk_base_url}' -try {{ - $shopdbConfig = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name BaseUrl -ErrorAction Stop - if ($shopdbConfig.BaseUrl) {{ $KioskBaseUrl = ([string]$shopdbConfig.BaseUrl).TrimEnd('/') }} -}} catch {{}} -if (-not $KioskBaseUrl) {{ - Write-Host 'no ShopDB base URL: set HKLM:\\SOFTWARE\\GE\\ShopDB BaseUrl, or set site_base_url in Settings and re-publish the display scope.' - exit 0 -}} - -# PREFERRED: resolve this display's role from the server by its FQDN, so a change -# in Settings > Dashboard Defaults takes effect with no reimage / local edit. The -# FQDN is F. (GE device naming); domain from HKLM -# DisplayFqdnDomain or the default. display-role is a PUBLIC endpoint (no token). -$path = $null -$serial = '' -try {{ $serial = ([string](Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber).Trim() }} catch {{}} -$fqdnDomain = 'device.geaerospace.net' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. -try {{ - $ddom = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name DisplayFqdnDomain -ErrorAction Stop - if ($ddom.DisplayFqdnDomain) {{ $fqdnDomain = ([string]$ddom.DisplayFqdnDomain).Trim().Trim('.') }} -}} catch {{}} -if ($serial) {{ - $fqdn = ('F' + $serial + '.' + $fqdnDomain).ToLower() - try {{ - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - $roleUri = "$KioskBaseUrl/api/dashboarddefaults/display-role?fqdn=$([uri]::EscapeDataString($fqdn))" - $resp = Invoke-RestMethod -Uri $roleUri -TimeoutSec 20 -ErrorAction Stop - if ($resp -and $resp.data -and $resp.data.path) {{ - $path = [string]$resp.data.path - Write-Host "server role for $fqdn -> $path" - }} - }} catch {{ Write-Host "display-role lookup failed for $fqdn : $($_.Exception.Message)" }} -}} - -# FALLBACK: the local display-type.txt map (offline, or no server mapping yet). -if (-not $path) {{ - $displayTypeFile = 'C:\\Enrollment\\display-type.txt' - if (Test-Path -LiteralPath $displayTypeFile) {{ - $displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim() - if (-not [string]::IsNullOrWhiteSpace($displayType)) {{ - $matchedKey = $DisplayTypeTargets.Keys | Where-Object {{ $_ -ieq $displayType }} | Select-Object -First 1 - if ($matchedKey) {{ $path = [string]$DisplayTypeTargets[$matchedKey]; Write-Host "local display-type '$displayType' -> $path" }} - }} - }} -}} -if (-not $path) {{ - Write-Host 'No display role from server or display-type.txt; nothing to configure.' - return -}} - -$kioskUrl = "$KioskBaseUrl$path" - -# Resolve msedge.exe (baked into the display image). -$edge = $null -foreach ($p in @("${{env:ProgramFiles(x86)}}\\Microsoft\\Edge\\Application\\msedge.exe", - "$env:ProgramFiles\\Microsoft\\Edge\\Application\\msedge.exe")) {{ - if (Test-Path -LiteralPath $p) {{ $edge = $p; break }} -}} -if (-not $edge) {{ Write-Host 'msedge.exe not found; cannot write kiosk startup shortcut.'; return }} - -$startup = Join-Path $env:ProgramData 'Microsoft\\Windows\\Start Menu\\Programs\\Startup' -if (-not (Test-Path $startup)) {{ New-Item -ItemType Directory -Path $startup -Force | Out-Null }} -$shell = New-Object -ComObject WScript.Shell - -# Clear leftover kiosk launchers from THIS or PRIOR installs so two kiosks do -# not fight: our own 'ShopDB Kiosk*.lnk', ANY .lnk that launches Edge in --kiosk -# mode, and any .url pointing at a shopdb kiosk page. Unrelated Startup items are -# left alone. -Get-ChildItem -LiteralPath $startup -Filter '*.lnk' -ErrorAction SilentlyContinue | ForEach-Object {{ - $drop = $false - if ($_.Name -like 'ShopDB Kiosk*' -or - $_.Name -like 'GE Aerospace Dashboard*' -or - $_.Name -like 'GE Aerospace Lobby*') {{ $drop = $true }} - else {{ - try {{ - $existing = $shell.CreateShortcut($_.FullName) - $isBrowser = $existing.TargetPath -match '(?i)(msedge|chrome)\\.exe$' - # -kiosk matches both '-kiosk' and '--kiosk'. Also catch any browser - # shortcut pointing at a shopdb kiosk URL (incl. the dead - # shopfloor-dashboard route + --app variants). - # The host comes from $KioskBaseUrl, not from a literal: this used - # to name one site's server, so at any other site a legacy shortcut - # pointing at that site's own ShopDB was not recognised and never - # cleaned up. - $kioskHost = '' - try {{ $kioskHost = ([uri]$KioskBaseUrl).Host }} catch {{}} - $kioskArgs = ($existing.Arguments -match '(?i)-kiosk') -or ` - ($kioskHost -and $existing.Arguments -like "*$kioskHost*") -or ` - ($existing.Arguments -match '(?i)/shopdb/|shopfloor-dashboard') - if ($isBrowser -and $kioskArgs) {{ $drop = $true }} - }} catch {{}} - }} - if ($drop) {{ Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }} -}} -Get-ChildItem -LiteralPath $startup -Filter '*.url' -ErrorAction SilentlyContinue | ForEach-Object {{ - try {{ - $body = Get-Content -LiteralPath $_.FullName -Raw -ErrorAction Stop - if ($body -match '/shopdb/(tv|shopfloor|parts-kiosk)' -or $body -match 'shopfloor-dashboard') {{ - Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue - }} - }} catch {{}} -}} - -# Legacy autostarts the OLD Dashboard/Lobby Inno installers planted keep -# relaunching the dead old URL (-> 404 white screen) and BEAT our shortcut. They -# hide in several places: the 32-bit Inno installer's HKLM Run write is -# WOW64-redirected into Wow6432Node (invisible to 64-bit tooling), and copies -# can sit in per-user hives, RunOnce, or the policy Run key. Sweep ALL of them -# by legacy NAME and by VALUE (any Run entry whose data points at the old URLs), -# across the native + Wow6432Node views and every loaded user hive, plus every -# per-user + common Startup folder. -$legacyNames = @('GE Aerospace Dashboard','GE Aerospace Lobby Display') -$oldUrlPattern = 'tv-dashboard|shopfloor-dashboard' - -$regRoots = @('HKLM:\\SOFTWARE', 'HKLM:\\SOFTWARE\\Wow6432Node') -if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {{ - New-PSDrive -Name HKU -PSProvider Registry -Root HKEY_USERS -ErrorAction SilentlyContinue | Out-Null -}} -try {{ - Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction Stop | - Where-Object {{ $_.PSChildName -notmatch '_Classes$' }} | - ForEach-Object {{ - $regRoots += "HKU:\\$($_.PSChildName)\\SOFTWARE" - $regRoots += "HKU:\\$($_.PSChildName)\\SOFTWARE\\Wow6432Node" - }} -}} catch {{}} - -$runSubkeys = @( - 'Microsoft\\Windows\\CurrentVersion\\Run', - 'Microsoft\\Windows\\CurrentVersion\\RunOnce', - 'Microsoft\\Windows\\CurrentVersion\\Policies\\Explorer\\Run' -) -foreach ($root in $regRoots) {{ - foreach ($sub in $runSubkeys) {{ - $key = Join-Path $root $sub - if (-not (Test-Path -LiteralPath $key)) {{ continue }} - $props = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue - if (-not $props) {{ continue }} - foreach ($prop in $props.PSObject.Properties) {{ - if ($prop.Name -like 'PS*') {{ continue }} - if (($legacyNames -contains $prop.Name) -or ("$($prop.Value)" -match $oldUrlPattern)) {{ - Remove-ItemProperty -LiteralPath $key -Name $prop.Name -Force -ErrorAction SilentlyContinue - Write-Host "removed legacy autostart $key -> $($prop.Name)" - }} - }} - }} -}} - -$startupDirs = @($startup, (Join-Path $env:Public 'Desktop')) -Get-ChildItem 'C:\\Users' -Directory -ErrorAction SilentlyContinue | ForEach-Object {{ - $startupDirs += (Join-Path $_.FullName 'AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup') -}} -foreach ($dir in ($startupDirs | Select-Object -Unique)) {{ - if (-not (Test-Path -LiteralPath $dir)) {{ continue }} - Get-ChildItem -LiteralPath $dir -Filter '*.lnk' -ErrorAction SilentlyContinue | ForEach-Object {{ - $drop = ($_.Name -like 'GE Aerospace *') - if (-not $drop) {{ - try {{ - $existing = $shell.CreateShortcut($_.FullName) - if ("$($existing.Arguments)" -match $oldUrlPattern) {{ $drop = $true }} - }} catch {{}} - }} - if ($drop) {{ - Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue - Write-Host "removed legacy startup shortcut $($_.FullName)" - }} - }} -}} -try {{ - Get-CimInstance Win32_Process -Filter "Name = 'msedge.exe'" -ErrorAction SilentlyContinue | - Where-Object {{ $_.CommandLine -match 'tv-dashboard|shopfloor-dashboard' }} | - ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; Write-Host "killed old-URL kiosk Edge pid $($_.ProcessId)" }} -}} catch {{}} - -# remove a stale VBS launcher from an earlier build (kiosk now uses a direct -# Edge shortcut again). Harmless if absent. -$staleVbs = Join-Path (Join-Path $env:ProgramData 'ShopDB') 'Start-ShopdbKiosk.vbs' -if (Test-Path -LiteralPath $staleVbs) {{ Remove-Item -LiteralPath $staleVbs -Force -ErrorAction SilentlyContinue }} - -$edgeArgs = "--kiosk `"$kioskUrl`" --edge-kiosk-type=fullscreen" -$lnkPath = Join-Path $startup 'ShopDB Kiosk.lnk' -$lnk = $shell.CreateShortcut($lnkPath) -$lnk.TargetPath = $edge -$lnk.Arguments = $edgeArgs -$lnk.WorkingDirectory = Split-Path -Parent $edge -$lnk.WindowStyle = 3 -$lnk.Description = "ShopDB kiosk display: $kioskUrl" -$lnk.Save() -Write-Host "display-type '$displayType' -> kiosk Startup shortcut for $kioskUrl" -""" - - -WATCHDOG_FILENAME = 'Register-KioskWatchdog.ps1' - -# Where the watchdog stages the script the scheduled task actually runs. The -# task must run in the INTERACTIVE session, so its action cannot be an inline -# payload executed by the SYSTEM enforce task. -WATCHDOG_TASKNAME = 'ShopDB Kiosk Watchdog' -WATCHDOG_SCRIPTPATH = r'C:\ProgramData\ShopDB\Watch-ShopdbKiosk.ps1' -WATCHDOG_INTERVAL_MIN = 2 - - -def build_watchdog_script(): - """Return the inline watchdog-installer PowerShell as text. - - WHY THIS EXISTS: the kiosk is launched by an all-users Startup shortcut, - which runs ONCE at logon. Nothing supervises the browser afterwards, so when - Edge exits - an update, a crash, someone closing the window - the display - stays dead until the next logon or reboot. - - The Edge RelaunchNotification=2 policy was intended to cover the update - case, but it does not: that policy drives Edge's own update-restart flow, - which depends on session restore to come back to where it was. Kiosk mode - (--kiosk) restores no session and shows no notification UI, so Edge honours - the close and never the relaunch. - - This entry runs as SYSTEM under the enforce task and cannot launch a visible - browser itself (session 0 has no desktop - the same reason the dispatcher - writes a Startup shortcut). So it REGISTERS a scheduled task that runs in - the interactive user's session and relaunches the kiosk when it is gone. - - Idempotent: the staged script and the task are rewritten only when they - differ from what is wanted, so a steady-state cycle costs one file read. - """ - # The script the task runs. Kept as a separate literal so the outer f-string - # does not have to escape its braces. - watched = r'''# Watch-ShopdbKiosk.ps1 -- relaunch the kiosk browser if it has gone. -# -# Runs in the INTERACTIVE user session (see the watchdog task), every couple of -# minutes. Reads the same Startup shortcut the dispatcher writes, so the target -# URL has exactly one source of truth: retarget a display by editing the -# dispatcher, and this follows without change. - -$ErrorActionPreference = 'Continue' - -$logDir = 'C:\Logs\ShopDB' -$logFile = Join-Path $logDir 'kiosk-watchdog.log' -$stamp = Join-Path $env:TEMP 'shopdb-kiosk-lastlaunch.txt' - -if (-not (Test-Path $logDir)) { - New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null -} - -function Log { - param([string]$Message) - # Bounded: this runs every couple of minutes forever on a PC nobody logs - # into, so an unbounded log would be the only thing that ever fills the disk. - try { - if ((Test-Path $logFile) -and ((Get-Item $logFile).Length -gt 512KB)) { - $keep = Get-Content $logFile -Tail 200 -EA SilentlyContinue - Set-Content -Path $logFile -Value $keep -EA SilentlyContinue - } - } catch { } - Add-Content -Path $logFile -EA SilentlyContinue ` - -Value ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message) -} - -# REFUSE to run as SYSTEM. MainWindowHandle is session-scoped: a SYSTEM caller -# reads 0 for a perfectly healthy kiosk in the user's session, so this script -# would judge it dead, kill it and relaunch - every single cycle. The scheduled -# task uses an interactive Users principal precisely so this cannot happen, and -# this guard makes a mis-registered task fail loudly instead of thrashing the -# display. -if ([Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { - Log 'refusing to run as SYSTEM: window state is not readable across sessions.' - return -} - -# The kiosk shortcut is the source of truth for target + arguments. -$startup = Join-Path $env:ProgramData 'Microsoft\Windows\Start Menu\Programs\StartUp' -$lnkPath = Join-Path $startup 'ShopDB Kiosk.lnk' -if (-not (Test-Path -LiteralPath $lnkPath)) { - # Not a kiosk display, or the dispatcher has not run yet. Do NOT invent a - # browser launch here - that would put Edge on a PC that never asked for it. - return -} - -try { - $shell = New-Object -ComObject WScript.Shell - $lnk = $shell.CreateShortcut($lnkPath) - $target = $lnk.TargetPath - $arguments = $lnk.Arguments -} catch { - Log "could not read $lnkPath : $_" - return -} -if (-not $target -or -not (Test-Path -LiteralPath $target)) { - Log "shortcut target missing: $target" - return -} - -# A kiosk counts as UP only when a --kiosk process still owns a VISIBLE WINDOW. -# -# Two traps, and the obvious checks fall into one or the other. Testing "is -# msedge running" lets a stray renderer or crashpad child mask a dead kiosk. -# Testing the command line alone is what shipped first, and it is not enough -# either: after an Edge update the window can be gone while a --kiosk process -# lingers, so the watchdog saw "kiosk is up" and never relaunched - observed on -# a real display, Edge closed on the desktop but still listed in Task Manager. -# -# MainWindowHandle is the discriminator. A process with no window cannot be -# showing anything to the floor, whatever its command line says. -$kioskProcs = @() -try { - $kioskProcs = @(Get-CimInstance Win32_Process -Filter "Name = 'msedge.exe'" -EA SilentlyContinue | - Where-Object { $_.CommandLine -match '--kiosk' }) -} catch { - Log "process query failed, assuming kiosk is up: $_" - return -} - -$visible = @() -foreach ($p in $kioskProcs) { - $proc = Get-Process -Id $p.ProcessId -EA SilentlyContinue - if ($proc -and $proc.MainWindowHandle -ne 0) { $visible += $p } -} -if ($visible.Count -gt 0) { return } - -# Windowless --kiosk processes are orphans. They must be killed BEFORE -# relaunching: leaving them would keep the next cycle's check satisfied again, -# and a second browser would fight the first for the display. -if ($kioskProcs.Count -gt 0) { - Log ("found {0} windowless --kiosk process(es) - killing before relaunch" -f $kioskProcs.Count) - foreach ($p in $kioskProcs) { - try { - Stop-Process -Id $p.ProcessId -Force -EA Stop - Log (" killed pid {0}" -f $p.ProcessId) - } catch { - Log (" could not kill pid {0}: {1}" -f $p.ProcessId, $_) - } - } -} - -# Debounce. Edge takes a few seconds to present a --kiosk process, and a display -# that fails to start would otherwise get a new browser every couple of minutes. -try { - if (Test-Path $stamp) { - $last = Get-Content $stamp -First 1 -EA SilentlyContinue - if ($last) { - $age = ((Get-Date) - [datetime]$last).TotalSeconds - if ($age -lt 120) { - Log ('kiosk still absent {0:N0}s after a launch - not relaunching yet' -f $age) - return - } - } - } -} catch { } - -Log "kiosk browser not running - relaunching: $target $arguments" -try { - Set-Content -Path $stamp -Value (Get-Date -Format 'o') -EA SilentlyContinue - Start-Process -FilePath $target -ArgumentList $arguments -EA Stop - Log 'relaunched.' -} catch { - Log "relaunch FAILED: $_" -} -''' - - return f"""# Register-KioskWatchdog.ps1 -- gea-shopfloor-display kiosk watchdog. -# -# Installs a scheduled task that relaunches the kiosk browser when it is gone. -# See the manifest comment for why the Startup shortcut alone is not enough. -# -# Runs as SYSTEM under the enforce task, so it registers the task rather than -# launching anything: SYSTEM has no interactive desktop. - -$ErrorActionPreference = 'Continue' - -$taskName = '{WATCHDOG_TASKNAME}' -$scriptPath = '{WATCHDOG_SCRIPTPATH}' -$intervalMin = {WATCHDOG_INTERVAL_MIN} - -$wanted = @' -{watched} -'@ - -# --- stage the script the task runs -------------------------------------- -$dir = Split-Path -Parent $scriptPath -if (-not (Test-Path $dir)) {{ New-Item -ItemType Directory -Path $dir -Force | Out-Null }} - -$current = '' -if (Test-Path -LiteralPath $scriptPath) {{ - $current = Get-Content -LiteralPath $scriptPath -Raw -EA SilentlyContinue -}} -# Compare trimmed: Set-Content appends a trailing newline that the here-string -# does not carry, so a raw comparison never matches and the file is rewritten -# every enforce cycle forever. -if ($current.TrimEnd() -ne $wanted.TrimEnd()) {{ - Set-Content -LiteralPath $scriptPath -Value $wanted -Encoding UTF8 -Force - Write-Host "staged $scriptPath" -}} else {{ - Write-Host "watchdog script already current" -}} - -# --- register the task ---------------------------------------------------- -# Principal is the INTERACTIVE users group, not SYSTEM: the relaunched browser -# has to appear on the auto-login user's visible desktop. A SYSTEM task would -# start Edge in session 0 where nobody can see it. -$existing = Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue -if (-not $existing) {{ - try {{ - $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` - -Argument ('-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "{{0}}"' -f $scriptPath) - $atLogon = New-ScheduledTaskTrigger -AtLogOn - # -Once + -RepetitionInterval repeats indefinitely. Do NOT pass - # -RepetitionDuration [TimeSpan]::MaxValue: it serialises to an - # out-of-range Duration that Register-ScheduledTask rejects. - $repeat = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(1)) ` - -RepetitionInterval (New-TimeSpan -Minutes $intervalMin) - $principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited - $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable ` - -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 5) ` - -DontStopIfGoingOnBatteries -AllowStartIfOnBatteries - Register-ScheduledTask -TaskName $taskName -Action $action ` - -Trigger @($atLogon, $repeat) -Principal $principal -Settings $settings ` - -Force | Out-Null - Write-Host "registered '$taskName' (every $intervalMin min, interactive)" - }} catch {{ - Write-Host "FAILED to register '$taskName': $_" - }} -}} else {{ - Write-Host "'$taskName' already registered" -}} -""" - - -ALWAYSON_FILENAME = 'Set-DisplayAlwaysOn.ps1' - - -def build_alwayson_script(): - """Return the inline always-on PowerShell as text. - - Keeps a display awake 24/7 (kiosk page always visible): powercfg never-off on - AC+DC, screensaver + auto-lock disabled. Plain raw string - no table - interpolation, so no brace escaping. - """ - return r'''# Set-DisplayAlwaysOn.ps1 -- gea-shopfloor-display always-on. -# Keeps the display awake 24/7 so the kiosk page stays visible: powercfg -# never-off (AC+DC) + screensaver/auto-lock disabled. SYSTEM, every cycle. - -$ErrorActionPreference = 'Continue' - -foreach ($c in @( - 'monitor-timeout-ac 0','monitor-timeout-dc 0', - 'standby-timeout-ac 0','standby-timeout-dc 0', - 'hibernate-timeout-ac 0','hibernate-timeout-dc 0', - 'disk-timeout-ac 0','disk-timeout-dc 0')) { - try { Start-Process -FilePath powercfg -ArgumentList "/change $c" -NoNewWindow -Wait -ErrorAction Stop } catch {} -} - -# disable screensaver + secure lock for future profiles (.DEFAULT) + loaded hives. -if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) { - New-PSDrive -Name HKU -PSProvider Registry -Root HKEY_USERS -ErrorAction SilentlyContinue | Out-Null -} -$hives = @('HKU:\.DEFAULT') -try { - Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction Stop | - Where-Object { $_.PSChildName -match '^S-1-5-21' -and $_.PSChildName -notmatch '_Classes$' } | - ForEach-Object { $hives += "HKU:\$($_.PSChildName)" } -} catch {} -foreach ($h in $hives) { - $cp = "$h\Control Panel\Desktop" - try { - if (-not (Test-Path $cp)) { New-Item -Path $cp -Force -ErrorAction Stop | Out-Null } - Set-ItemProperty -Path $cp -Name ScreenSaveActive -Value '0' -ErrorAction Stop - Set-ItemProperty -Path $cp -Name ScreenSaverIsSecure -Value '0' -ErrorAction SilentlyContinue - Set-ItemProperty -Path $cp -Name ScreenSaveTimeOut -Value '0' -ErrorAction SilentlyContinue - } catch {} -} - -# never auto-lock the kiosk console. -try { - $pol = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' - if (-not (Test-Path $pol)) { New-Item -Path $pol -Force | Out-Null } - Set-ItemProperty -Path $pol -Name NoLockScreen -Value 1 -Type DWord -ErrorAction SilentlyContinue -} catch {} - -Write-Host 'kiosk always-on enforced (power never-off + screensaver/lock disabled).' -''' - - -def read_client_module(): - """The enforce client module's bytes, read from this repo. - - Shipping it as a manifest entry closes a real gap: the module is installed - once by Install-ShopdbKiosk.ps1 and never refreshed, so a client change - reached the server with the code deploy and then sat one directory away from - where kiosks fetch from, waiting for someone to re-stage the installer - bundle by hand. Displays are share-less by design; hands on every kiosk is - the wrong cost for a client change. - """ - path = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'client', CLIENT_MODULE_FILENAME) - with open(path, 'rb') as handle: - return handle.read() - - -def build_forceedgeupdate_script(): - """Return the inline one-shot Edge force-update PowerShell as text. - - WHY THIS EXISTS: a pending Edge update parks a restart prompt on every - display and RelaunchWindow defers the forced restart to 02:00-04:00, so the - prompt can sit on screen all day with nobody on site to dismiss it. - - This does not relaunch the browser itself. It cannot: the enforce task runs - as SYSTEM in session 0, where a launched browser is invisible (the same - constraint the dispatcher and the watchdog are built around). It stops Edge - and lets the EXISTING kiosk watchdog relaunch it from the Startup shortcut - within its interval, which is also what applies the staged update. - - One-shot via DetectionMethod=MarkerFile: the engine writes the marker after - a 0 exit, so this runs once per display and is skipped on every later cycle. - A FAILED run writes no marker and is retried next cycle. To force another - round later, bump the date in FORCE_EDGE_UPDATE_MARKER. - """ - return r'''# Invoke-EdgeForceUpdate.ps1 -- one-shot: apply a pending Edge update now. -# -# Runs as SYSTEM under the enforce task. Stops Edge; the kiosk watchdog -# relaunches it (that relaunch is what completes a staged update). - -$ErrorActionPreference = 'Continue' - -$updater = Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeUpdate\MicrosoftEdgeUpdate.exe' -if (Test-Path $updater) { - # /ua = update-all check; runs the same path Edge's own scheduled task uses, - # so a staged update is downloaded rather than waited for. - try { - Start-Process -FilePath $updater -ArgumentList '/ua','/installsource','scheduler' ` - -NoNewWindow -Wait -ErrorAction Stop - Write-Host 'edge updater ran' - } catch { - Write-Host "edge updater failed: $_" - } -} else { - Write-Host "edge updater not found at $updater" -} - -# Stop every Edge process, kiosk or not. The watchdog notices no --kiosk -# process and relaunches from the Startup shortcut, which is the ONE source of -# truth for the target URL. -$edge = Get-Process -Name msedge -ErrorAction SilentlyContinue -if ($edge) { - try { - $edge | Stop-Process -Force -ErrorAction Stop - Write-Host "stopped $($edge.Count) edge process(es); watchdog will relaunch the kiosk" - } catch { - # Do NOT exit non-zero here. A process that vanished between the Get and - # the Stop is a success, not a failure - failing would withhold the - # marker and re-kill Edge on every cycle from now on. - Write-Host "stop-process reported: $_" - } -} else { - Write-Host 'no edge process running; watchdog will relaunch the kiosk' -} - -exit 0 -''' - - -def _registry_drift_heal_entry(name, regname, regvalue, regtype, comment): - """One Type=Registry entry that writes a value and detects drift via - ValueMatches against that same path/name. - - DetectionValue is stored as a string; the engine string-coerces for - ValueMatches, so a DWord value of 2 detects against '2'. - """ - return { - '_comment': comment, - 'Name': name, - 'Type': 'Registry', - 'RegPath': EDGE_POLICY_PATH, - 'RegName': regname, - 'RegValue': regvalue, - 'RegType': regtype, - 'DetectionMethod': 'ValueMatches', - 'DetectionPath': EDGE_POLICY_PATH, - 'DetectionName': regname, - 'DetectionValue': str(regvalue), - } - - -def build_display_manifest(): - """Return the gea-shopfloor-display manifest dict (Applications in order). - - Four Edge kiosk relaunch-policy drift-heal entries, then the one dispatcher - entry. The dispatcher is declared PayloadSource=inline with no hash yet; the - seed attaches the real payload bytes (and its sha256) after the draft rows - exist. Kept payload-free otherwise: the kiosk engine and browser are baked - into the display image, not shipped over HTTPS. - """ - clientbytes = read_client_module() - applications = [ - { - '_comment': ( - 'The GE-Enforce client module itself. Install-ShopdbKiosk.ps1 ' - 'lays this down once at bootstrap and never refreshes it, so a ' - 'client change reached the server with the code deploy and then ' - 'sat one directory away from where kiosks fetch, waiting for ' - 'someone to re-stage the installer bundle by hand. Displays are ' - 'share-less by design; hands on every kiosk is the wrong cost ' - 'for a client change. Hash detection against the shipped bytes, ' - 'so a matching module is left alone and only a changed one is ' - 'rewritten. SELF-MODIFYING BY DESIGN: this module is what stages ' - 'payloads, but PowerShell loads it into memory at start, so ' - 'rewriting the file mid-run is harmless and takes effect on the ' - 'NEXT cycle. Pilot a client change on ONE kiosk before the fleet ' - '- a broken module cannot fetch its own replacement, and on a ' - 'share-less display that means a site visit.'), - 'Name': 'GE-Enforce client module (self-update)', - 'Type': 'File', - 'Source': CLIENT_MODULE_FILENAME, - 'Destination': CLIENT_MODULE_DEST, - 'PayloadSource': 'inline', - 'PayloadRef': CLIENT_MODULE_FILENAME, - 'DetectionMethod': 'Hash', - 'DetectionPath': CLIENT_MODULE_DEST, - 'DetectionValue': hashlib.sha256(clientbytes).hexdigest(), - }, - _registry_drift_heal_entry( - 'Edge kiosk RelaunchNotification (Required auto-restart)', - 'RelaunchNotification', 2, 'DWord', - 'RelaunchNotification=2 (Required): Edge auto-restarts after the ' - 'notification period. Displays have no operator to dismiss the ' - 'update dialog, so this is the only mode that recovers unattended. ' - 'Heals drift of the policy set at imaging by 09-Setup-Display.ps1.'), - _registry_drift_heal_entry( - 'Edge kiosk RelaunchNotificationPeriod (1 hour)', - 'RelaunchNotificationPeriod', 3600000, 'DWord', - 'Milliseconds before the forced auto-restart. 3600000 ms = 1 hour.'), - _registry_drift_heal_entry( - 'Edge kiosk RelaunchHeadsUpPeriod (1 minute)', - 'RelaunchHeadsUpPeriod', 60000, 'DWord', - 'Milliseconds of final warning before auto-restart. 60000 ms = 1 ' - 'minute.'), - _registry_drift_heal_entry( - 'Edge kiosk RelaunchWindow (02:00-04:00)', - 'RelaunchWindow', RELAUNCH_WINDOW_JSON, 'String', - 'Overnight forced-restart window (02:00 start, 120 minute ' - 'duration) so business-hour updates wait until off-hours and the ' - 'dialog stays invisible during the day.'), - { - '_comment': ( - 'Kiosk dispatcher. Reads C:\\Enrollment\\display-type.txt and ' - 'writes an all-users Startup shortcut that launches Edge kiosk ' - 'fullscreen at the subtype target. Data-driven map: ' - + _relaunch_window_targets_comment() - + '. Base URL from HKLM BaseUrl, else the WJ host. Runs as SYSTEM ' - '(no interactive desktop), so it does NOT Start-Process Edge ' - '(that opens invisibly in session 0) - the auto-login user runs ' - 'the shortcut at logon in a visible session. Delivered inline over ' - 'HTTPS (share-less displays); Always re-asserts, idempotent ' - '(rewrites only the resolved-target shortcut, self-heals a ' - 'type/URL change).'), - 'Name': 'Display kiosk dispatcher (display-type.txt)', - 'Type': 'PS1', - 'Script': DISPATCHER_FILENAME, - 'PayloadSource': 'inline', - 'PayloadRef': DISPATCHER_FILENAME, - 'DetectionMethod': 'Always', - }, - { - '_comment': ( - 'Relaunch the kiosk browser when it goes away. The Startup ' - 'shortcut the dispatcher writes runs ONCE at logon, so an Edge ' - 'update, a crash or a closed window leaves the display dead ' - 'until the next logon. RelaunchNotification=2 does not cover ' - 'it: that policy drives Edge\'s update-restart, which relies on ' - 'session restore, and --kiosk restores no session and shows no ' - 'notification UI. Registers an INTERACTIVE-session scheduled ' - 'task (SYSTEM cannot launch a visible browser) that relaunches ' - 'from the same shortcut every ' - f'{WATCHDOG_INTERVAL_MIN} minutes when no --kiosk process is ' - 'running.'), - 'Name': 'Display kiosk watchdog (relaunch Edge)', - 'Type': 'PS1', - 'Script': WATCHDOG_FILENAME, - 'PayloadSource': 'inline', - 'PayloadRef': WATCHDOG_FILENAME, - 'DetectionMethod': 'Always', - }, - { - '_comment': ( - 'ONE-SHOT. Applies a pending Edge update now instead of waiting ' - 'for the 02:00-04:00 RelaunchWindow, which can leave the update ' - 'prompt on screen all day with nobody on site to dismiss it. ' - 'Ordered AFTER the watchdog entry on purpose: this stops Edge ' - 'and relies on the watchdog to bring the kiosk back, so the ' - 'watchdog must be registered first on a display seeing both for ' - 'the first time. DetectionMethod=MarkerFile makes it run once ' - 'per display - the engine writes the marker only after a 0 exit, ' - 'so a failed run retries next cycle. Re-arm for a future update ' - 'by bumping the date in FORCE_EDGE_UPDATE_MARKER.'), - 'Name': 'Force pending Edge update and bounce the kiosk (one-shot)', - 'Type': 'PS1', - 'Script': FORCE_EDGE_UPDATE_FILENAME, - 'PayloadSource': 'inline', - 'PayloadRef': FORCE_EDGE_UPDATE_FILENAME, - 'DetectionMethod': 'MarkerFile', - 'DetectionPath': FORCE_EDGE_UPDATE_MARKER, - }, - { - '_comment': ( - 'Keep the display awake 24/7 so the kiosk page stays visible: ' - 'powercfg never-off (AC+DC) + screensaver/auto-lock disabled. ' - 'Delivered inline over HTTPS; Always re-asserts + self-heals a ' - 'power-plan drift each cycle.'), - 'Name': 'Display always-on (no sleep/blank/lock)', - 'Type': 'PS1', - 'Script': ALWAYSON_FILENAME, - 'PayloadSource': 'inline', - 'PayloadRef': ALWAYSON_FILENAME, - 'DetectionMethod': 'Always', - }, - ] - return { - 'Version': SCOPE_VERSION, - '_comment': ( - 'gea-shopfloor-display runtime scope. Heals Edge kiosk relaunch ' - 'policy drift and dispatches the kiosk to the subtype target. No ' - 'EXE payloads: kiosk engine and browser are baked into the display ' - 'image. Self-sufficient: displays do NOT inherit the common ' - 'scope.'), - 'Applications': applications, - } - - -def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'): - """Create/refresh the gea-shopfloor-display draft scope and its entries. - - Idempotent for the draft: replace_scope_draft rebuilds the draft rows, and - the inline dispatcher payload is content-addressed (a re-run stores the same - bytes to the same sha256). Set publish=True to also freeze a published - snapshot (that step is NOT idempotent: it always creates a new version). - - Commits the session. Returns a summary dict: - {scopeid, entrycount, entrytypes, dispatchersha256, publishedversion}. - """ - manifest = build_display_manifest() - scope = service.replace_scope_draft(SCOPE_NAME, SCOPE_PHASE, manifest) - # Flush so the new entries get entryids before the inline payload attaches. - db.session.flush() - - dispatcher = next(entry for entry in scope.entries - if entry.name == 'Display kiosk dispatcher (display-type.txt)') - scriptbytes = build_dispatcher_script().encode('utf-8') - payload = service.store_inline_payload( - dispatcher, DISPATCHER_FILENAME, - 'text/plain; charset=utf-8', scriptbytes) - - alwayson = next(entry for entry in scope.entries - if entry.name == 'Display always-on (no sleep/blank/lock)') - alwaysonbytes = build_alwayson_script().encode('utf-8') - alwaysonpayload = service.store_inline_payload( - alwayson, ALWAYSON_FILENAME, - 'text/plain; charset=utf-8', alwaysonbytes) - - watchdog = next(entry for entry in scope.entries - if entry.name == 'Display kiosk watchdog (relaunch Edge)') - watchdogbytes = build_watchdog_script().encode('utf-8') - watchdogpayload = service.store_inline_payload( - watchdog, WATCHDOG_FILENAME, - 'text/plain; charset=utf-8', watchdogbytes) - - clientmodule = next(entry for entry in scope.entries - if entry.name.startswith('GE-Enforce client module')) - clientbytes = read_client_module() - clientpayload = service.store_inline_payload( - clientmodule, CLIENT_MODULE_FILENAME, - 'text/plain; charset=utf-8', clientbytes) - - forceupdate = next(entry for entry in scope.entries - if entry.name.startswith('Force pending Edge update')) - forceupdatebytes = build_forceedgeupdate_script().encode('utf-8') - forceupdatepayload = service.store_inline_payload( - forceupdate, FORCE_EDGE_UPDATE_FILENAME, - 'text/plain; charset=utf-8', forceupdatebytes) - - publishedversion = None - if publish: - publishedversion = service.publish_scope( - SCOPE_NAME, SCOPE_PHASE, notes=notes) - - db.session.commit() - - return { - 'scopeid': scope.scopeid, - 'entrycount': len(scope.entries), - 'entrytypes': [entry.entrytype for entry in scope.entries], - 'dispatchersha256': payload.payloadsha256, - 'alwaysonsha256': alwaysonpayload.payloadsha256, - 'watchdogsha256': watchdogpayload.payloadsha256, - 'forceupdatesha256': forceupdatepayload.payloadsha256, - 'clientmodulesha256': clientpayload.payloadsha256, - 'publishedversion': publishedversion, - } diff --git a/plugins/geenforce/serializer.py b/plugins/geenforce/serializer.py deleted file mode 100644 index 4bdfb375..00000000 --- a/plugins/geenforce/serializer.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Rebuild manifest JSON from DB rows, and canonicalize entries for parity. - -`scope_to_manifest` renders a ManifestScope back into the exact JSON structure -GE-Enforce consumes (Version / _comment / Applications, plus Site for -preinstall). `canonical_entry` reduces an Applications[] entry (from either the -on-share file or a rebuilt scope) to only the fields the engine reads, dropping -`_comment` and key order, so the parity harness can compare behavior not bytes. -""" - -import json - -# Scalar entry fields: model attribute -> manifest key. Emitted only when set, -# so a rebuilt entry has the same sparse key presence as the original. -_SCALAR_FIELDS = [ - ('name', 'Name'), - ('entrytype', 'Type'), - ('installer', 'Installer'), - ('installargs', 'InstallArgs'), - ('scriptpath', 'Script'), - ('scriptargs', 'Args'), - ('sourcepath', 'Source'), - ('destination', 'Destination'), - ('regpath', 'RegPath'), - ('regname', 'RegName'), - ('regtype', 'RegType'), - ('detectionmethod', 'DetectionMethod'), - ('detectionpath', 'DetectionPath'), - ('detectionname', 'DetectionName'), - ('detectionvalue', 'DetectionValue'), - ('detectionpattern', 'DetectionPattern'), - ('cmmversion', '_CmmVersion'), - ('logfile', 'LogFile'), - ('waittimeoutsec', 'WaitTimeoutSec'), - ('applymode', 'ApplyMode'), - ('updatewindow', 'UpdateWindow'), -] - -# Boolean flags emitted only when True (matches how preinstall.json carries them). -_BOOL_FLAGS = [ - ('preenrollment', 'PreEnrollment'), - ('killafterdetection', 'KillAfterDetection'), - ('pctypesstrict', 'PCTypesStrict'), -] - -# The complete set of engine-relevant keys the parity check compares. -ENGINE_KEYS = ( - [mk for _, mk in _SCALAR_FIELDS] - + [mk for _, mk in _BOOL_FLAGS] - + ['PCTypes', 'TargetHostnames', 'TargetMachineNumbers', 'RegValue', 'InUseCheck'] -) - - -def entry_to_dict(entry): - """Render a ManifestEntry model back into a manifest Applications[] entry.""" - result = {} - if entry.comment: - result['_comment'] = entry.comment - for attr, key in _SCALAR_FIELDS: - value = getattr(entry, attr) - if value is not None and value != '': - result[key] = value - # RegValue: stored as a raw JSON literal, reconstitute its real type. - if entry.regvalue is not None: - result['RegValue'] = json.loads(entry.regvalue) - # Multi-value filters (only when present). - if entry.pctypes: - result['PCTypes'] = [p.pctypevalue for p in entry.pctypes] - if entry.hostnames: - result['TargetHostnames'] = [h.hostnamepattern for h in entry.hostnames] - if entry.machinenumbers: - result['TargetMachineNumbers'] = [m.machinenumber - for m in entry.machinenumbers] - for attr, key in _BOOL_FLAGS: - if getattr(entry, attr): - result[key] = True - # Payload transport for share-less (http/inline) delivery. Omitted for the - # default 'smb' source so existing share manifests round-trip unchanged; the - # client fetches GET /api/geenforce/payload/ for http/inline. - if entry.payloadsource and entry.payloadsource != 'smb': - result['PayloadSource'] = entry.payloadsource - if entry.payloadsha256: - result['PayloadSha256'] = entry.payloadsha256 - if entry.payloadref: - result['PayloadRef'] = entry.payloadref - # InUseCheck (nested object + Processes[]). - if entry.inusecheck: - procs = [] - for proc in entry.inusecheck.processes: - pd = {'Name': proc.processname} - if proc.exepath is not None: - pd['ExePath'] = proc.exepath - if proc.gracefulclosetimeoutsec is not None: - pd['GracefulCloseTimeoutSec'] = proc.gracefulclosetimeoutsec - procs.append(pd) - result['InUseCheck'] = { - 'Behavior': entry.inusecheck.behavior, - 'Processes': procs, - } - return result - - -def scope_to_manifest(scope): - """Render a ManifestScope back into a full manifest.json structure.""" - manifest = {'Version': scope.manifestversion} - if scope.topcomment: - manifest['_comment'] = scope.topcomment - if scope.site: - manifest['Site'] = scope.site - manifest['Applications'] = [entry_to_dict(e) for e in scope.entries] - return manifest - - -def scope_to_json(scope, indent=2): - """The serialized JSON text a client/export would receive.""" - return json.dumps(scope_to_manifest(scope), indent=indent) - - -def canonical_entry(entry_dict): - """Reduce an Applications[] entry to engine-relevant fields for parity. - - Drops `_comment` and key order. Normalizes InUseCheck and the process list - so two entries that behave identically compare equal regardless of source. - """ - canon = {} - for key in ENGINE_KEYS: - if key == 'InUseCheck': - continue - if key in entry_dict and entry_dict[key] is not None and entry_dict[key] != '': - canon[key] = entry_dict[key] - inuse = entry_dict.get('InUseCheck') - if inuse: - procs = [] - for proc in inuse.get('Processes', []): - pd = {'Name': proc.get('Name')} - if proc.get('ExePath') is not None: - pd['ExePath'] = proc.get('ExePath') - if proc.get('GracefulCloseTimeoutSec') is not None: - pd['GracefulCloseTimeoutSec'] = proc.get('GracefulCloseTimeoutSec') - procs.append(pd) - canon['InUseCheck'] = {'Behavior': inuse.get('Behavior'), - 'Processes': procs} - return canon diff --git a/plugins/geenforce/service.py b/plugins/geenforce/service.py deleted file mode 100644 index 8b2af4aa..00000000 --- a/plugins/geenforce/service.py +++ /dev/null @@ -1,613 +0,0 @@ -"""DB-touching operations for the manifest store. - -Kept out of the CLI and routes so both share one implementation: -- `replace_scope_draft` imports/re-imports a scope's DRAFT entries WITHOUT - touching its published-version history (re-import is idempotent + safe). -- `publish_scope` freezes the current draft into a new immutable snapshot. -- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle. -""" - -import hashlib -import json -import os -import re -import tempfile -from datetime import datetime, timezone - -from flask import current_app -from sqlalchemy import func - -from shopdb.api import db, Application, normalize_display_role - -from .models import ( - ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport, - ManifestEnforcementResult, ManifestPayload, ManifestBlob, -) -from .importer import build_entry -from .serializer import scope_to_json - - -class LibVersionTooOldError(Exception): - """Publishing this scope would reach PCs whose enforcer lib is too old. - - Carries the detail a human needs to act: what the manifest requires, the - lowest lib version the fleet reports for this scope, and which hosts are - behind. - """ - - def __init__(self, scopename, required, floor, hosts): - self.scopename = scopename - self.required = required - self.floor = floor - self.hosts = hosts - shown = ', '.join(hosts[:10]) - if len(hosts) > 10: - shown += f' (+{len(hosts) - 10} more)' - super().__init__( - f'{scopename} manifest is version {required} but {len(hosts)} ' - f'reporting PC(s) run enforcer lib {floor}: {shown}. ' - 'Push the newer lib to the fleet first, or publish with force=True ' - 'if you are certain those PCs must not receive this scope.') - - -def _canonical_subtype(value): - """Store a reported subtype under core's role vocabulary where it names one. - - A display reports the literal contents of C:\\Enrollment\\display-type.txt, - hand-edited on the machine, so casing drifts freely. Normalising on the way - in means the fleet table reads one vocabulary rather than whatever each - kiosk happened to be typed as. - - A value that names NO known role is kept VERBATIM, not dropped: an unknown - subtype is a kiosk with a typo or a role nobody told the server about, and - both are things you want to see in the table rather than have silently - blanked. Non-display scopes report no subtype at all and land as None. - """ - text = (value or '').strip() - if not text: - return None - return normalize_display_role(text) or text - - -def _utcnow(): - return datetime.now(timezone.utc).replace(tzinfo=None) - - -def parse_libversion(text): - """'2.6' -> (2, 6). Returns None for missing or unparseable values. - - None means "cannot tell", and every caller treats that as "do not claim - this PC is safe" rather than "this PC is fine" - an unreadable version is - exactly the case where guessing is expensive. - """ - if not text: - return None - parts = str(text).strip().split('.') - try: - return (int(parts[0]), int(parts[1]) if len(parts) > 1 else 0) - except (ValueError, IndexError): - return None - - -def hosts_below_libversion(scopename, phase, required): - """Hosts whose CURRENT report for this scope runs a lib older than required. - - A report with no/unparseable enforcerversion counts as behind: the field was - added with the summary-emitting engine, so its absence means an old lib. - - Returns (hosts, floor) where floor is the lowest parseable version string - seen, or None when nothing reported. - """ - wanted = parse_libversion(required) - if not wanted: - return [], None - - reports = ManifestEnforcementReport.query.filter_by( - scopename=scopename, phase=phase, iscurrent=True).all() - - behind = [] - lowest = None - for report in reports: - actual = parse_libversion(report.enforcerversion) - if actual is None or actual < wanted: - behind.append(report.hostname) - if actual is not None and (lowest is None or actual < lowest): - lowest = actual - floor = f'{lowest[0]}.{lowest[1]}' if lowest else None - return sorted(set(behind)), floor - - -# Manifest features and the client library that first understood them. A -# library older than this does not merely skip the feature, it MISREADS the -# entry: 2.6 added _CmmVersion, and an older lib treats a version-gated entry as -# ungated and installs every PC-DMIS build it cannot detect. -# -# Only features that NARROW behaviour belong here. An addition an old lib -# ignores harmlessly does not need a floor. -LIBVERSION_FEATURES = ( - ('cmmversion', '2.6'), -) - - -def required_libversion(scope): - """Lowest client library that may enforce this scope. - - An explicit minlibversion wins. Otherwise it is derived from the features - the draft actually uses, so a scope written before this column existed is - still gated on what it contains rather than on manifestversion - which is - the manifest's own 'Version' field and says nothing about the library. - - Falls back to manifestversion when nothing else applies, preserving the - behaviour share-imported manifests already relied on, where the two - numbering schemes do coincide. - """ - declared = (scope.minlibversion or '').strip() - if declared: - return declared - - floor = None - for attribute, version in LIBVERSION_FEATURES: - if any(getattr(entry, attribute, None) for entry in scope.entries): - candidate = parse_libversion(version) - if candidate and (floor is None or candidate > floor[0]): - floor = (candidate, version) - if floor: - return floor[1] - return scope.manifestversion - - -def _installed_app_model(): - """Lazily import the computers plugin's ComputerInstalledApp. - - The computers plugin is optional; importing it lazily (not at module load) - keeps geenforce usable when computers is absent or disabled. Mirrors - collector._computer_models. Returns the model class, or None if absent. - """ - try: - from plugins.computers.models import ComputerInstalledApp - return ComputerInstalledApp - except ImportError: - return None - - -def replace_scope_draft(scopename, phase, manifest): - """Create/refresh a scope and REPLACE its draft entries. Published versions - are left untouched. Returns the scope (uncommitted).""" - scope = ManifestScope.query.filter_by( - scopename=scopename, phase=phase).first() - if not scope: - scope = ManifestScope(scopename=scopename, phase=phase) - db.session.add(scope) - scope.manifestversion = str(manifest.get('Version', '1.0')) - scope.topcomment = manifest.get('_comment') - scope.site = manifest.get('Site') - scope.iscommon = (scopename == 'common') - - # Clear existing draft entries via the delete-orphan cascade. This also - # EMPTIES the in-memory collection, so a later lookup on scope.entries cannot - # match a stale (deleted) entry. A per-object db.session.delete() leaves the - # deleted objects in scope.entries until expiry, which on a re-publish made a - # caller (seed_display_scope) pick an old deleted entry and attach a payload - # to its dead entryid -> MySQL FK 1452 (SQLite does not enforce it, so the - # idempotency test missed it). - scope.entries.clear() - db.session.flush() - - for i, entry_dict in enumerate(manifest.get('Applications') or []): - scope.entries.append(build_entry(entry_dict, i)) - return scope - - -def publish_scope(scopename, phase, notes=None, publishedby=None, force=False): - """Freeze the current draft into a new published snapshot. Returns the - version number (uncommitted). - - Refuses when the manifest is newer than the enforcer lib on PCs that report - for this scope, unless force=True. - - WHY THIS GATE EXISTS: the lib treats a minor-newer manifest as backward - compatible and carries on. That holds for additions that WIDEN behaviour (a - new Type is skipped by an old lib), and inverts for one that NARROWS it. The - _CmmVersion filter arrived in lib 2.6 as a minor bump: an older lib does not - know the field, so every gated entry looks unfiltered and it installs EVERY - PC-DMIS version it cannot detect, on every CMM, within one cycle. The share - runbook says push the lib first; a runbook is not a control. This is, and it - reads the version the fleet actually reports rather than the one someone - believes is deployed. - - A scope nobody has reported for yet cannot be checked, so it publishes: a - fresh site would otherwise be unable to publish anything at all. - """ - scope = ManifestScope.query.filter_by( - scopename=scopename, phase=phase).first() - if not scope: - raise ValueError(f'No scope {scopename}/{phase}') - - if not force: - required = required_libversion(scope) - behind, floor = hosts_below_libversion(scopename, phase, required) - if behind: - raise LibVersionTooOldError( - scopename, required, floor or 'unknown', behind) - - text = scope_to_json(scope) - maxversion = db.session.query( - func.max(ManifestPublishedVersion.versionnumber) - ).filter_by(scopeid=scope.scopeid).scalar() or 0 - - ManifestPublishedVersion.query.filter_by( - scopeid=scope.scopeid, iscurrent=True - ).update({'iscurrent': False}) - - db.session.add(ManifestPublishedVersion( - scopeid=scope.scopeid, - versionnumber=maxversion + 1, - manifestjson=text, - publishedat=_utcnow(), - publishedby=publishedby, - iscurrent=True, - notes=notes)) - return maxversion + 1 - - -def rollback_scope(scopename, phase, versionnumber): - """Make an older published version current again (uncommitted).""" - scope = ManifestScope.query.filter_by( - scopename=scopename, phase=phase).first() - if not scope: - raise ValueError(f'No scope {scopename}/{phase}') - target = ManifestPublishedVersion.query.filter_by( - scopeid=scope.scopeid, versionnumber=versionnumber).first() - if not target: - raise ValueError(f'No version {versionnumber} for {scopename}') - ManifestPublishedVersion.query.filter_by( - scopeid=scope.scopeid, iscurrent=True - ).update({'iscurrent': False}) - target.iscurrent = True - return versionnumber - - -def record_enforcement_report(payload): - """Record one PC's enforcement cycle (observed state). Upserts the latest - report per (hostname, scopename, phase) and keeps prior ones as history. - - Payload (all but hostname optional): - hostname, scopename, phase, appliedversion, enforcerversion, lastcheckin, - counts {installed, skipped, failed, filtered}, - results [{name, action, selfhealed, exitcode, message}] - - Returns the new ManifestEnforcementReport (uncommitted). - """ - hostname = (payload.get('hostname') or '').strip() - if not hostname: - raise ValueError('hostname is required') - scopename = (payload.get('scopename') or '').strip() - phase = (payload.get('phase') or 'runtime').strip() - counts = payload.get('counts') or {} - results = payload.get('results') or [] - # Guard wrong JSON shapes: a bad type must be a clean 400, not a 500 from a - # later .get()/iteration (the route maps ValueError to 400). - if not isinstance(counts, dict): - raise ValueError('counts must be an object') - if not isinstance(results, list): - raise ValueError('results must be a list') - - failed = int(counts.get('failed', 0)) - installed = int(counts.get('installed', 0)) - # Derive status from EXPLICIT self-heal flags only, never the raw installed - # count: every healthy cycle runs Always/no-detection scripts (asset report, - # VNC firewall, EventSaver) that the engine counts as "installed", so keying - # self-heal off installed>0 would mark the common scope selfhealed forever - # and make 'ok' unreachable. A drift correction is one the client flags. - if failed > 0: - status = 'failed' - elif any(r.get('selfhealed') for r in results): - status = 'selfhealed' - else: - status = 'ok' - - # Demote the prior current report for this host+scope. Hostname match is - # case-insensitive to match the ilike read path - otherwise a PC reporting - # its name in different casing would leave two iscurrent rows. - ManifestEnforcementReport.query.filter( - func.lower(ManifestEnforcementReport.hostname) == hostname.lower(), - ManifestEnforcementReport.scopename == scopename, - ManifestEnforcementReport.phase == phase, - ManifestEnforcementReport.iscurrent == True - ).update({'iscurrent': False}, synchronize_session=False) - - report = ManifestEnforcementReport( - hostname=hostname, - scopename=scopename, - phase=phase, - appliedversion=payload.get('appliedversion'), - enforcerversion=payload.get('enforcerversion'), - subtype=_canonical_subtype(payload.get('subtype')), - installedcount=installed, - skippedcount=int(counts.get('skipped', 0)), - failedcount=failed, - filteredcount=int(counts.get('filtered', 0)), - status=status, - lastcheckin=_parse_dt(payload.get('lastcheckin')), - receivedat=_utcnow(), - iscurrent=True) - for item in results: - report.results.append(ManifestEnforcementResult( - entryname=item.get('name', ''), - action=item.get('action', ''), - # Explicit flag only; do NOT infer from action == 'installed' - # (Always/no-detection scripts install every cycle without being a - # drift correction). - selfhealed=bool(item.get('selfhealed', False)), - exitcode=item.get('exitcode'), - message=item.get('message'))) - db.session.add(report) - return report - - -def _parse_dt(value): - if not value: - return None - try: - return datetime.fromisoformat(str(value).replace('Z', '+00:00')).replace( - tzinfo=None) - except (ValueError, TypeError): - return None - - -def compliance_for_scope(scope): - """Fleet-install coverage for a scope's app-linked entries. - - One row per ManifestEntry that carries a curated appid (unlinked entries - are skipped), ordered by sortorder. Counts come from the computers plugin's - ComputerInstalledApp (active rows only, one row per PC per app). Degrades - gracefully with null counts when the computers plugin is absent. - - Returns the response data dict (scopeid/scopename/phase/computersplugin/rows). - """ - linked = [e for e in sorted(scope.entries, key=lambda e: e.sortorder) - if e.appid is not None] - installedmodel = _installed_app_model() - computerspresent = installedmodel is not None - - # One grouped query for the whole scope's appid set (fleet is tiny). - installedbyapp = {} - if computerspresent and linked: - appids = {e.appid for e in linked} - counted = db.session.query( - installedmodel.appid, func.count(installedmodel.id) - ).filter( - installedmodel.isactive == True, - installedmodel.appid.in_(appids), - ).group_by(installedmodel.appid).all() - installedbyapp = {appid: count for appid, count in counted} - - rows = [] - for entry in linked: - app = db.session.get(Application, entry.appid) - appname = app.appname if app else None - expectedversion = (entry.detectionvalue - if entry.detectionmethod == 'FileVersion' else None) - - if not computerspresent: - installedcount = None - versionmatchcount = None - coveragenote = ('computers plugin not installed; install counts ' - 'unavailable') - else: - installedcount = installedbyapp.get(entry.appid, 0) - if installedcount == 0: - versionmatchcount = None if expectedversion is None else 0 - coveragenote = 'not installed on any collected PC' - elif expectedversion is None: - versionmatchcount = None - method = entry.detectionmethod or 'none' - coveragenote = (f'installed on {installedcount} PC(s); no ' - f'version target (detection is {method})') - else: - versionmatchcount = db.session.query( - func.count(installedmodel.id) - ).filter( - installedmodel.isactive == True, - installedmodel.appid == entry.appid, - installedmodel.installedversion == expectedversion, - ).scalar() or 0 - coveragenote = (f'{versionmatchcount} of {installedcount} ' - 'collected PCs on the expected version') - - rows.append({ - 'entryid': entry.entryid, - 'entryname': entry.name, - 'appid': entry.appid, - 'appname': appname, - 'expectedversion': expectedversion, - 'installedcount': installedcount, - 'versionmatchcount': versionmatchcount, - 'coveragenote': coveragenote, - }) - - return { - 'scopeid': scope.scopeid, - 'scopename': scope.scopename, - 'phase': scope.phase, - 'computersplugin': computerspresent, - 'rows': rows, - } - - -def _payload_dir(): - d = os.path.join(current_app.instance_path, 'geenforce', 'payloads') - os.makedirs(d, exist_ok=True) - return d - - -def blob_path(sha256): - """Filesystem path where a blob's bytes live (may or may not exist).""" - return os.path.join(_payload_dir(), sha256) - - -def blob_referenced_by_scopes(sha256, scopenames): - """True when any of these scopes' CURRENT published manifest references - sha256 as an entry payload. - - Backs the resource-bound token check on GET /payload: a token pinned to its - own scope(s) may only pull blobs those scopes actually ship, not any blob by - hash. Empty scopenames -> False (a bound-but-empty token reaches nothing). - """ - if not scopenames: - return False - rows = db.session.query(ManifestPublishedVersion).join( - ManifestScope, - ManifestPublishedVersion.scopeid == ManifestScope.scopeid, - ).filter( - ManifestScope.scopename.in_(list(scopenames)), - ManifestPublishedVersion.iscurrent == True, # noqa: E712 - ).all() - for row in rows: - try: - doc = json.loads(row.manifestjson) - except (ValueError, TypeError): - continue - for entry in doc.get('Applications', []) or []: - if entry.get('PayloadSha256') == sha256: - return True - return False - - -def store_blob(rawbytes, filename, contenttype=None): - """Store bytes in the content-addressed payload store; return the sha256. - - Deduped by content hash - an already-present blob is not rewritten. Upserts - a ManifestBlob registry row (uncommitted). Entries reference it by - payloadsha256; the client fetches GET /api/geenforce/payload/. - """ - sha = hashlib.sha256(rawbytes).hexdigest() - path = blob_path(sha) - if not os.path.exists(path): - fd, tmp = tempfile.mkstemp(dir=_payload_dir(), suffix='.tmp') - try: - with os.fdopen(fd, 'wb') as handle: - handle.write(rawbytes) - os.replace(tmp, path) - except Exception: - if os.path.exists(tmp): - os.remove(tmp) - raise - if not db.session.get(ManifestBlob, sha): - db.session.add(ManifestBlob( - sha256=sha, filename=filename, contenttype=contenttype, - sizebytes=len(rawbytes), createdat=_utcnow())) - return sha - - -def store_inline_payload(entry, filename, contenttype, rawbytes): - """Replace the entry's inline payload with these bytes (uncommitted). - - Computes payloadsha256, upserts the single ManifestPayload row (the table - has no unique constraint on entryid, so the one-inline-payload-per-entry - rule is enforced here), and points the entry at it (payloadsource='inline'). - Returns the ManifestPayload. - """ - sha = hashlib.sha256(rawbytes).hexdigest() - # Flush any pending inserts (e.g. a prior entry's inline payload) BEFORE the - # bulk delete. Otherwise the delete's autoflush interleaves that half-built - # INSERT and, on MySQL, fails the manifestpayloads->manifestentries FK - # (SQLite does not enforce it the same way, so tests miss this). Then delete - # existing rows for this entry with autoflush off + no session-sync. - db.session.flush() - with db.session.no_autoflush: - ManifestPayload.query.filter_by( - entryid=entry.entryid).delete(synchronize_session=False) - db.session.flush() - payload = ManifestPayload( - entryid=entry.entryid, - filename=filename, - contenttype=contenttype, - payloadbytes=rawbytes, - payloadsha256=sha, - uploadedat=_utcnow()) - db.session.add(payload) - entry.payloadsource = 'inline' - entry.payloadref = filename - entry.payloadsha256 = sha - return payload - - -# A scope name becomes a DIRECTORY on the imaging share, so it has to be a -# single safe path segment. It was validated only as "not empty", which meant a -# name like "../escape" or an absolute path wrote the manifest outside the -# share root - proven by writing /tmp/manifest.json and files one and two -# levels above it. The real exposure is the permission split: geenforce.manage -# creates the scope, geenforce.publish writes the file, so a manage-only author -# could aim a publisher's write anywhere that process can reach. -# -# Allowed: letters, digits, dot, dash, underscore. Not '.' or '..', and no -# separator of either platform - the share is written from Linux and read by -# Windows, so a backslash is a separator too. -SCOPENAME_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]*$') - - -def valid_scopename(scopename): - """True when this name is safe to use as a directory on the share.""" - name = (scopename or '').strip() - if not name or name in ('.', '..') or len(name) > 100: - return False - if '/' in name or '\\' in name or '\x00' in name: - return False - return bool(SCOPENAME_PATTERN.match(name)) - - -def export_scope_to_share(scopename, phase, shareroot): - """Write a scope's current published JSON to the share, backing up the old - file to _meta/history first. Returns the written path.""" - scope = ManifestScope.query.filter_by( - scopename=scopename, phase=phase).first() - if not scope: - raise ValueError(f'No scope {scopename}/{phase}') - published = scope.publishedversions.filter_by(iscurrent=True).first() - if not published: - raise ValueError(f'{scopename} has no published version') - - if phase == 'preinstall': - target = os.path.join(shareroot, 'preinstall.json') - else: - # Checked again here, not only at creation: rows predating the - # validation exist, and this is the line that actually writes. - if not valid_scopename(scopename): - raise ValueError( - f'Refusing to export scope {scopename!r}: the name is not a ' - f'safe directory name') - target = os.path.join(shareroot, scopename, 'manifest.json') - - # Belt and braces: whatever the name, the resolved target must sit inside - # the share root. - root = os.path.realpath(shareroot) - if os.path.commonpath([root, os.path.realpath(os.path.dirname(target))]) != root: - raise ValueError( - f'Refusing to export scope {scopename!r}: it resolves outside the ' - f'share root') - os.makedirs(os.path.dirname(target), exist_ok=True) - - if os.path.isfile(target): - historydir = os.path.join(shareroot, '_meta', 'history') - os.makedirs(historydir, exist_ok=True) - stamp = _utcnow().strftime('%Y%m%d-%H%M%S') - with open(target) as src: - old = src.read() - with open(os.path.join(historydir, f'{stamp}-{scopename}.json'), 'w') as dst: - dst.write(old) - - # Atomic write: a partial/failed write must never leave the live on-share - # manifest (which every PC reads) truncated. Write a temp file in the same - # directory, then rename over the target. - targetdir = os.path.dirname(target) - fd, tmppath = tempfile.mkstemp(dir=targetdir, suffix='.tmp') - try: - with os.fdopen(fd, 'w') as handle: - handle.write(published.manifestjson) - os.replace(tmppath, target) - except Exception: - if os.path.exists(tmppath): - os.remove(tmppath) - raise - return target diff --git a/plugins/guides/models/guide.py b/plugins/guides/models/guide.py index 809445b8..ca6d955e 100644 --- a/plugins/guides/models/guide.py +++ b/plugins/guides/models/guide.py @@ -62,7 +62,7 @@ class Guide(BaseModel): A guide is edited and saved WHOLE - nobody reorders one paragraph in isolation - so a child table would buy ordering machinery, a second migration and N+1 reads, in exchange for nothing this feature needs. The - same reasoning the geenforce manifests and backup payloads already follow. + same reasoning the backup payloads already follow. """ __tablename__ = 'guides' diff --git a/plugins/knowledgebase/api/routes.py b/plugins/knowledgebase/api/routes.py index f782b6bb..54008a1b 100644 --- a/plugins/knowledgebase/api/routes.py +++ b/plugins/knowledgebase/api/routes.py @@ -1,7 +1,6 @@ """Knowledge Base API endpoints.""" from flask import Blueprint, request -from flask_jwt_extended import jwt_required from shopdb.api import ( intarg, @@ -180,21 +179,29 @@ def get_article(link_id: int): @knowledgebase_bp.route('//click', methods=['POST']) -@jwt_required(optional=True) +@require_permission('kb.view') def track_click(link_id: int): - """Increment click counter and return the URL to redirect to.""" - article = db.session.get(KnowledgeBase, link_id) + """Increment the click counter for an article the caller is allowed to read. - if not article or not article.isactive: + Gated on kb.view like every other read of this table: an anonymous caller + walking link ids must not be able to probe which articles exist, drive the + counters that order the list, or spend a DB transaction per request. + + Restricted to the same visible set as the list/detail reads so an article + about a retired application is not reachable here either. + + The linkurl is deliberately not echoed back: whoever is permitted to click + already received it in the list or detail response the click came from. + """ + article = _visible_articles().filter(KnowledgeBase.linkid == link_id).first() + + if not article: return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) article.increment_clicks() db.session.commit() - return success_response({ - 'linkurl': article.linkurl, - 'clicks': article.clicks - }) + return success_response({'clicks': article.clicks}) @knowledgebase_bp.route('', methods=['POST']) diff --git a/plugins/machines/frontend/views/MachineForm.vue b/plugins/machines/frontend/views/MachineForm.vue index bce1693a..f7b21a89 100644 --- a/plugins/machines/frontend/views/MachineForm.vue +++ b/plugins/machines/frontend/views/MachineForm.vue @@ -591,14 +591,14 @@ onMounted(async () => { machineTypes.value = typesRes.data.data || [] statuses.value = statusRes.data.data || [] - vendors.value = vendorRes.data.data || [] - locations.value = locRes || [] - models.value = allModels - businessunits.value = buRes.data.data || [] // listAll resolves to the ARRAY, not a response: fetchAllPages already // unwrapped every page. Reading .data.data off it threw, and the whole // parallel load went to the catch - so every dropdown on this form came up // empty and the machine's own values never loaded. + vendors.value = vendorRes || [] + locations.value = locRes || [] + models.value = allModels + businessunits.value = buRes || [] pcs.value = pcsRes || [] // Load relationship types separately diff --git a/plugins/machines/models/machine.py b/plugins/machines/models/machine.py index 320b242c..3c224b94 100644 --- a/plugins/machines/models/machine.py +++ b/plugins/machines/models/machine.py @@ -75,7 +75,7 @@ class Machine(BaseModel): # Maintenance tracking # Dates, not datetimes: there is no time in a maintenance date, and a # DATETIME went out of to_dict as "...T00:00:00Z", which a browser west of - # UTC rendered as the previous day. measuringtools and warranties already + # UTC rendered as the previous day. Warranties already # model theirs as db.Date. See migration 7d40. lastmaintenancedate = db.Column(db.Date, nullable=True) nextmaintenancedate = db.Column(db.Date, nullable=True) diff --git a/plugins/machines/plugin.py b/plugins/machines/plugin.py index 7eaec4a7..775e6b20 100644 --- a/plugins/machines/plugin.py +++ b/plugins/machines/plugin.py @@ -246,3 +246,16 @@ class MachinesPlugin(BasePlugin): ('machines.edit', 'Edit machines', 'machines'), ('machines.delete', 'Delete machines', 'machines'), ] + + def get_asset_type_descriptor(self): + """Return descriptor for machine types management in Asset Type Hub.""" + return { + 'assettype': 'machine', + 'subtype_endpoint': '/machines/types', + 'subtype_id_field': 'machinetypeid', + 'subtype_label_field': 'machinetype', + 'subtype_description_field': 'description', + 'subtype_color_field': 'color', + 'optional_tabs': [], + 'edit_permission': 'machines.manage', + } diff --git a/plugins/measuringtools/__init__.py b/plugins/measuringtools/__init__.py deleted file mode 100644 index a4d9da23..00000000 --- a/plugins/measuringtools/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Measuring-tools plugin package.""" - -from .plugin import MeasuringToolsPlugin - -__all__ = ['MeasuringToolsPlugin'] diff --git a/plugins/measuringtools/api/__init__.py b/plugins/measuringtools/api/__init__.py deleted file mode 100644 index 58ffce94..00000000 --- a/plugins/measuringtools/api/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Measuring-tools plugin API blueprint.""" - -from .routes import measuringtools_bp - -__all__ = ['measuringtools_bp'] diff --git a/plugins/measuringtools/api/routes.py b/plugins/measuringtools/api/routes.py deleted file mode 100644 index f836de36..00000000 --- a/plugins/measuringtools/api/routes.py +++ /dev/null @@ -1,436 +0,0 @@ -"""Measuring-tools plugin API. - -Two resources: the measuringtooltypes lookup (CRUD with an in-use delete guard) -and the measuring tools themselves (an Asset core row plus a measuringtools -extension row, written in one payload). Reads are jwt-optional per the app -convention; writes require the measuringtools.* permissions. - -Calibration status is derived at read time in the model (see -models.derive_status), never stored, so the report and list badges are always -current. -""" - -from datetime import date, datetime - -from flask import Blueprint, request -from flask_jwt_extended import jwt_required - -from shopdb.api import ( - db, Asset, AssetType, AuditLog, - success_response, error_response, paginated_response, ErrorCodes, - get_pagination_params, paginate_query, - require_permission, apply_import_timestamps, - DateValueError, parse_date_fields, -) - -from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COLORS - -measuringtools_bp = Blueprint('measuringtools', __name__) - - -def _merged(tool, today=None): - """Asset core dict with the measuringtools extension nested under 'measuringtool'.""" - result = tool.asset.to_dict() if tool.asset else {} - result['measuringtool'] = tool.to_dict(today) - return result - - -# ============================================================================= -# Measuring-tool types -# ============================================================================= - -@measuringtools_bp.route('/types', methods=['GET']) -@require_permission('measuringtools.view') -def list_types(): - """List measuring-tool types.""" - page, per_page = get_pagination_params(request) - query = MeasuringToolType.query - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(MeasuringToolType.isactive == True) - if search := request.args.get('search'): - query = query.filter(MeasuringToolType.name.ilike(f'%{search}%')) - query = query.order_by(MeasuringToolType.name) - items, total = paginate_query(query, page, per_page) - return paginated_response([t.to_dict() for t in items], page, per_page, total) - - -@measuringtools_bp.route('/types/', methods=['GET']) -@require_permission('measuringtools.view') -def get_type(type_id: int): - """Get one measuring-tool type.""" - tool_type = db.session.get(MeasuringToolType, type_id) - if not tool_type: - return error_response(ErrorCodes.NOT_FOUND, - f'Measuring-tool type {type_id} not found', http_code=404) - return success_response(tool_type.to_dict()) - - -@measuringtools_bp.route('/types', methods=['POST']) -@require_permission('measuringtools.create') -def create_type(): - """Create a measuring-tool type (reactivates a soft-deleted same-named one).""" - data = request.get_json() or {} - name = (data.get('name') or '').strip() - if not name: - return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') - - existing = MeasuringToolType.query.filter_by(name=name).first() - if existing: - if not existing.isactive: - existing.isactive = True - for key in ('description', 'color'): - if data.get(key) is not None: - setattr(existing, key, data[key]) - db.session.commit() - return success_response(existing.to_dict(), message='Reactivated existing type') - return error_response(ErrorCodes.CONFLICT, - f"Measuring-tool type '{name}' already exists", http_code=409) - - tool_type = MeasuringToolType( - name=name, - description=data.get('description'), - color=data.get('color'), - ) - db.session.add(tool_type) - db.session.commit() - return success_response(tool_type.to_dict(), message='Measuring-tool type created', - http_code=201) - - -@measuringtools_bp.route('/types/', methods=['PUT']) -@require_permission('measuringtools.edit') -def update_type(type_id: int): - """Update a measuring-tool type.""" - tool_type = db.session.get(MeasuringToolType, type_id) - if not tool_type: - return error_response(ErrorCodes.NOT_FOUND, - f'Measuring-tool type {type_id} not found', http_code=404) - data = request.get_json() or {} - if 'name' in data and data['name'] != tool_type.name: - if MeasuringToolType.query.filter_by(name=data['name']).first(): - return error_response(ErrorCodes.CONFLICT, - f"Measuring-tool type '{data['name']}' already exists", - http_code=409) - for key in ('name', 'description', 'color', 'isactive'): - if key in data: - setattr(tool_type, key, data[key]) - db.session.commit() - return success_response(tool_type.to_dict(), message='Measuring-tool type updated') - - -@measuringtools_bp.route('/types/', methods=['DELETE']) -@require_permission('measuringtools.delete') -def delete_type(type_id: int): - """Delete a measuring-tool type. Refused if any tool still uses it.""" - tool_type = db.session.get(MeasuringToolType, type_id) - if not tool_type: - return error_response(ErrorCodes.NOT_FOUND, - f'Measuring-tool type {type_id} not found', http_code=404) - inuse = MeasuringTool.query.filter_by(measuringtooltypeid=type_id).count() - if inuse: - return error_response(ErrorCodes.CONFLICT, - f'Cannot delete: {inuse} tool(s) still use this type', - http_code=409) - db.session.delete(tool_type) - db.session.commit() - return success_response(message='Measuring-tool type deleted') - - -# ============================================================================= -# Measuring tools -# ============================================================================= - -@measuringtools_bp.route('', methods=['GET']) -@require_permission('measuringtools.view') -def list_tools(): - """List measuring tools with filters + pagination. - - Query params: typeid, locationid, statusid, calibrationstatus, search, - active, page, perpage. - """ - page, per_page = get_pagination_params(request) - query = db.session.query(MeasuringTool).join(Asset) - - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(Asset.isactive == True) - # Exact-match natural-key lookup for idempotent import (asset number). - if exactassetnumber := request.args.get('assetnumber'): - query = query.filter(Asset.assetnumber == exactassetnumber) - # Type is a column in the list, so it has to be searchable. Outer join so a - # tool with no type still matches on its own fields. - if search := request.args.get('search'): - pattern = f'%{search}%' - query = query.outerjoin( - MeasuringToolType, - MeasuringTool.measuringtooltypeid == MeasuringToolType.measuringtooltypeid - ).filter(db.or_( - Asset.assetnumber.ilike(pattern), - Asset.name.ilike(pattern), - Asset.serialnumber.ilike(pattern), - # The optional identifiers too (ADR-001). Global search matches - # these, and a tag read off the machine has to find it here as - # well - this box is where someone holding the label looks. - Asset.gaugelabreference.ilike(pattern), - Asset.maintenancereference.ilike(pattern), - MeasuringToolType.name.ilike(pattern), - )) - if type_id := request.args.get('typeid', type=int): - query = query.filter(MeasuringTool.measuringtooltypeid == type_id) - if location_id := request.args.get('locationid', type=int): - query = query.filter(Asset.locationid == location_id) - if status_id := request.args.get('statusid', type=int): - query = query.filter(Asset.statusid == status_id) - - query = query.order_by(Asset.assetnumber) - today = date.today() - - # Calibration status is DERIVED (see derive_status), so it cannot be a SQL - # filter. It used to be applied to the rows of the current page, which - # meant the page showed the matches that happened to fall on it while - # meta.pagination.total counted the unfiltered set: asking for 'overdue' - # with one overdue tool in sixteen returned an empty page 1, a total of 16 - # and eight pages, with the actual row on page 3. A filter that hides - # matching rows and miscounts the rest is worse than no filter. - # - # So when this filter is in play the whole matching set is built and - # filtered before paging. That is the same tradeoff the previous comment - # named - a gage lab's worth of tools, not a fleet - spent where it buys - # correct answers rather than a cheaper query. - calibrationstatus = request.args.get('calibrationstatus') - if calibrationstatus: - matched = [_merged(tool, today) for tool in query.all()] - matched = [row for row in matched - if row['measuringtool']['calibrationstatus'] == calibrationstatus] - total = len(matched) - start = (page - 1) * per_page - data = matched[start:start + per_page] - return paginated_response(data, page, per_page, total) - - items, total = paginate_query(query, page, per_page) - data = [_merged(tool, today) for tool in items] - return paginated_response(data, page, per_page, total) - - -@measuringtools_bp.route('/', methods=['GET']) -@require_permission('measuringtools.view') -def get_tool(tool_id: int): - """Get one measuring tool (asset core merged with the extension).""" - tool = db.session.get(MeasuringTool, tool_id) - if not tool: - return error_response(ErrorCodes.NOT_FOUND, - f'Measuring tool {tool_id} not found', http_code=404) - return success_response(_merged(tool)) - - -@measuringtools_bp.route('/by-asset/', methods=['GET']) -@require_permission('measuringtools.view') -def get_tool_by_asset(asset_id: int): - """Get a measuring tool by its core asset id.""" - tool = MeasuringTool.query.filter_by(assetid=asset_id).first() - if not tool: - return error_response(ErrorCodes.NOT_FOUND, - f'Measuring tool for asset {asset_id} not found', http_code=404) - return success_response(_merged(tool)) - - -def _asset_type_id(): - """Resolve the seeded 'measuring_tool' asset-type id, or None.""" - asset_type = AssetType.query.filter_by(assettype='measuring_tool').first() - return asset_type.assettypeid if asset_type else None - - -@measuringtools_bp.route('', methods=['POST']) -@require_permission('measuringtools.create') -def create_tool(): - """Create a measuring tool: one Asset core row + one extension row.""" - data = request.get_json() or {} - # Refused, not nulled - see the note at the update site. - try: - dates = parse_date_fields( - data, ('lastcalibrationdate', 'nextcalibrationdate')) - except DateValueError as bad: - return error_response(ErrorCodes.VALIDATION_ERROR, str(bad)) - if not data.get('assetnumber'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') - if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): - return error_response(ErrorCodes.CONFLICT, - f"Asset with number '{data['assetnumber']}' already exists", - http_code=409) - - assettypeid = _asset_type_id() - if not assettypeid: - return error_response(ErrorCodes.INTERNAL_ERROR, - 'measuring_tool asset type not found. Plugin may not be ' - 'properly installed.', http_code=500) - - asset = Asset( - assetnumber=data['assetnumber'], - name=data.get('name'), - gaugelabreference=data.get('gaugelabreference'), - maintenancereference=data.get('maintenancereference'), - serialnumber=data.get('serialnumber'), - assettypeid=assettypeid, - statusid=data.get('statusid', 1), - locationid=data.get('locationid'), - businessunitid=data.get('businessunitid'), - mapx=data.get('mapx'), - levelid=data.get('levelid'), - mapy=data.get('mapy'), - notes=data.get('notes'), - ) - db.session.add(asset) - db.session.flush() # assign assetid - - tool = MeasuringTool( - assetid=asset.assetid, - measuringtooltypeid=data.get('measuringtooltypeid'), - calibrationintervaldays=data.get('calibrationintervaldays'), - lastcalibrationdate=dates.get('lastcalibrationdate'), - nextcalibrationdate=dates.get('nextcalibrationdate'), - calibrationprovider=(data.get('calibrationprovider') or '').strip() or None, - notes=(data.get('notes') or '').strip() or None, - ) - db.session.add(tool) - db.session.flush() - - apply_import_timestamps(asset, data) - AuditLog.log('created', 'MeasuringTool', entityid=tool.measuringtoolid, - entityname=asset.assetnumber) - db.session.commit() - return success_response(_merged(tool), message='Measuring tool created', http_code=201) - - -# Asset core fields writable through this plugin's write path. -_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference', - 'maintenancereference', 'serialnumber', - 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', - 'notes', 'isactive') -# Extension fields with plain assignment (dates handled separately). -_TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays', - 'calibrationprovider', 'notes') - - -@measuringtools_bp.route('/', methods=['PUT']) -@require_permission('measuringtools.edit') -def update_tool(tool_id: int): - """Update a measuring tool (asset core fields + extension in one payload).""" - tool = db.session.get(MeasuringTool, tool_id) - if not tool: - return error_response(ErrorCodes.NOT_FOUND, - f'Measuring tool {tool_id} not found', http_code=404) - data = request.get_json() or {} - # Refused, not nulled - see the note at the update site. - try: - dates = parse_date_fields( - data, ('lastcalibrationdate', 'nextcalibrationdate')) - except DateValueError as bad: - return error_response(ErrorCodes.VALIDATION_ERROR, str(bad)) - asset = tool.asset - - # An UPDATE may not blank the asset number. Create validates it and the - # column is NOT NULL, but the conflict check below only runs when the value - # DIFFERS, and '' never collides with anything - so a payload carrying an - # empty assetnumber wrote it straight through. A form that loaded blank - # (a failed reference load, a partial fetch) then saved the blank over a - # real record. - if 'assetnumber' in data and not (data['assetnumber'] or '').strip(): - return error_response( - ErrorCodes.VALIDATION_ERROR, - 'assetnumber cannot be empty') - - if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: - if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): - return error_response(ErrorCodes.CONFLICT, - f"Asset with number '{data['assetnumber']}' already exists", - http_code=409) - - changes = {} - for key in _ASSET_FIELDS: - if key in data: - if getattr(asset, key) != data[key]: - changes[key] = {'old': getattr(asset, key), 'new': data[key]} - setattr(asset, key, data[key]) - for key in _TOOL_FIELDS: - if key in data: - if getattr(tool, key) != data[key]: - changes[key] = {'old': getattr(tool, key), 'new': data[key]} - setattr(tool, key, data[key]) - # Refused, not nulled: a mistyped calibration date used to store NULL and - # answer 200, which flipped the tool to 'unknown' and dropped it out of the - # calibration report and the map overlay. - for key, parsed in dates.items(): - setattr(tool, key, parsed) - - if changes: - AuditLog.log('updated', 'MeasuringTool', entityid=tool.measuringtoolid, - entityname=asset.assetnumber, changes=changes) - apply_import_timestamps(asset, data) - db.session.commit() - return success_response(_merged(tool), message='Measuring tool updated') - - -@measuringtools_bp.route('/', methods=['DELETE']) -@require_permission('measuringtools.delete') -def delete_tool(tool_id: int): - """Soft delete a measuring tool (deactivates its asset).""" - tool = db.session.get(MeasuringTool, tool_id) - if not tool: - return error_response(ErrorCodes.NOT_FOUND, - f'Measuring tool {tool_id} not found', http_code=404) - tool.asset.isactive = False - AuditLog.log('deleted', 'MeasuringTool', entityid=tool.measuringtoolid, - entityname=tool.asset.assetnumber) - db.session.commit() - return success_response(message='Measuring tool deleted') - - -# ============================================================================= -# Map overlay (ADR-010 calibration-due badge) -# ============================================================================= - -@measuringtools_bp.route('/map-overlay', methods=['GET']) -@require_permission('measuringtools.view') -def map_overlay(): - """Calibration-status overlay for active measuring tools. - - The map places markers itself from the assets feed; this overlay only - supplies the per-asset calibration decoration. Consumers join by assetid, - so no map coordinates are returned. Status is derived at read time. - """ - today = date.today() - query = db.session.query(MeasuringTool).join(Asset).filter(Asset.isactive == True) - # Overlay contract (ADR-010): [{assetid, color, label}] joined by assetid. - # Only decorate tools that are actually due/overdue - a marker with no entry - # gets no badge. - data = [] - for tool in query.all(): - status = derive_status(tool.nextcalibrationdate, today) - if status not in ('overdue', 'duesoon'): - continue - data.append({ - 'assetid': tool.assetid, - 'color': STATUS_COLORS.get(status, STATUS_COLORS['unknown']), - 'label': 'Overdue' if status == 'overdue' else 'Due soon', - }) - return success_response(data) - - -# ============================================================================= -# Calibration report (for the Reports hub) -# ============================================================================= - -@measuringtools_bp.route('/report/calibration', methods=['GET']) -@require_permission('measuringtools.view') -def calibration_report(): - """Counts + lists bucketed by derived calibration status.""" - today = date.today() - buckets = {'overdue': [], 'duesoon': [], 'current': [], 'unknown': []} - query = db.session.query(MeasuringTool).join(Asset).filter(Asset.isactive == True) - for tool in query.all(): - buckets.setdefault(derive_status(tool.nextcalibrationdate, today), []).append( - _merged(tool, today)) - return success_response({ - 'counts': {key: len(value) for key, value in buckets.items()}, - 'buckets': buckets, - 'statuscolors': STATUS_COLORS, - }) diff --git a/plugins/measuringtools/frontend/routes.js b/plugins/measuringtools/frontend/routes.js deleted file mode 100644 index 7e0fc3d5..00000000 --- a/plugins/measuringtools/frontend/routes.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Measuring-tools plugin routes. - * - * Every route carries meta.plugin = 'measuringtools' so the ADR-009 router - * guard redirects to the dashboard when the backend plugin is disabled. List - * and detail are public (no requiresAuth, matching the other asset types); the - * form requires auth; the settings subtype page requires admin. - */ -export default [ - { - path: 'measuringtools', - name: 'measuringtools', - component: () => import('./views/MeasuringToolsList.vue'), - meta: { plugin: 'measuringtools' } - }, - { - path: 'measuringtools/new', - name: 'measuringtool-new', - component: () => import('./views/MeasuringToolForm.vue'), - meta: { requiresAuth: true, plugin: 'measuringtools' } - }, - { - // Resolve a tool from its core asset id (search rows / cross-links carry - // assetid, not the extension id). Shares the detail component. - path: 'measuringtools/by-asset/:assetid', - name: 'measuringtool-by-asset', - component: () => import('./views/MeasuringToolDetail.vue'), - meta: { plugin: 'measuringtools' } - }, - { - path: 'measuringtools/:id', - name: 'measuringtool-detail', - component: () => import('./views/MeasuringToolDetail.vue'), - meta: { plugin: 'measuringtools' } - }, - { - path: 'measuringtools/:id/edit', - name: 'measuringtool-edit', - component: () => import('./views/MeasuringToolForm.vue'), - meta: { requiresAuth: true, plugin: 'measuringtools' } - }, - { - path: 'reports/calibration', - name: 'calibration-report', - component: () => import('./views/CalibrationReport.vue'), - meta: { plugin: 'measuringtools', title: 'Calibration Report' } - }, - { - path: 'settings/measuringtooltypes', - name: 'measuringtooltypes', - component: () => import('./views/MeasuringToolTypesList.vue'), - meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' } - } -] - -// No full-screen routes: every view in this plugin renders inside -// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module -// without Rollup warning about a missing export. -export const toplevel = [] diff --git a/plugins/measuringtools/frontend/views/CalibrationReport.vue b/plugins/measuringtools/frontend/views/CalibrationReport.vue deleted file mode 100644 index 6820d4a2..00000000 --- a/plugins/measuringtools/frontend/views/CalibrationReport.vue +++ /dev/null @@ -1,101 +0,0 @@ - - - - - diff --git a/plugins/measuringtools/frontend/views/MeasuringToolDetail.vue b/plugins/measuringtools/frontend/views/MeasuringToolDetail.vue deleted file mode 100644 index cd0dcd66..00000000 --- a/plugins/measuringtools/frontend/views/MeasuringToolDetail.vue +++ /dev/null @@ -1,278 +0,0 @@ - - - - - diff --git a/plugins/measuringtools/frontend/views/MeasuringToolForm.vue b/plugins/measuringtools/frontend/views/MeasuringToolForm.vue deleted file mode 100644 index 5b18eb76..00000000 --- a/plugins/measuringtools/frontend/views/MeasuringToolForm.vue +++ /dev/null @@ -1,295 +0,0 @@ - - - - - diff --git a/plugins/measuringtools/frontend/views/MeasuringToolTypesList.vue b/plugins/measuringtools/frontend/views/MeasuringToolTypesList.vue deleted file mode 100644 index 559f7b40..00000000 --- a/plugins/measuringtools/frontend/views/MeasuringToolTypesList.vue +++ /dev/null @@ -1,157 +0,0 @@ - - - diff --git a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue deleted file mode 100644 index 52d18bf5..00000000 --- a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue +++ /dev/null @@ -1,195 +0,0 @@ - - - - - diff --git a/plugins/measuringtools/manifest.json b/plugins/measuringtools/manifest.json deleted file mode 100644 index f2f584af..00000000 --- a/plugins/measuringtools/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "measuringtools", - "display_name": "Measuring Tools", - "version": "1.0.0", - "description": "Metrology and inspection instruments (gauges, calipers, thread gages, bore gages) with a calibration lifecycle and derived calibration status.", - "author": "ShopDB Team", - "dependencies": [], - "core_version": ">=0.20.0,<1.0.0", - "api_prefix": "/api/measuringtools", - "default_enabled": false, - "provides": { - "features": ["measuring-tools", "calibration-tracking"] - } -} diff --git a/plugins/measuringtools/migrations/env.py b/plugins/measuringtools/migrations/env.py deleted file mode 100644 index 709e2ef6..00000000 --- a/plugins/measuringtools/migrations/env.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Alembic environment for the measuring-tools plugin migration chain. - -Delegates to the shared runner in shopdb.plugins.alembic_template, which filters -the metadata to this plugin's tables and drives Alembic against the per-plugin -version table alembic_version_measuringtools. See ADR-008 for the ownership -model. Unlike the ten cutover plugins whose 0001 is a no-op anchor, this plugin -is NEW: its 0001 baseline really CREATES its tables, because the core chain -never built them. -""" -import os - -os.environ['PLUGIN_NAME'] = 'measuringtools' - -from shopdb.plugins.alembic_template import run_migrations # noqa: E402 - -run_migrations() diff --git a/plugins/measuringtools/migrations/script.py.mako b/plugins/measuringtools/migrations/script.py.mako deleted file mode 100644 index 2c015630..00000000 --- a/plugins/measuringtools/migrations/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} diff --git a/plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py b/plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py deleted file mode 100644 index 15722d64..00000000 --- a/plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py +++ /dev/null @@ -1,74 +0,0 @@ -"""measuringtools plugin baseline (real create). - -This plugin was built AFTER the ADR-008 ownership cutover, so unlike the ten -bundled plugins whose 0001 is a stamp-only no-op anchor, this baseline actually -CREATES the plugin's tables. The core Alembic chain never knew about -measuringtooltypes / measuringtools, so this per-plugin chain is their sole -authoritative creator. It runs from `flask plugin install measuringtools` -(and `flask plugin upgrade-all`) after `flask db upgrade` builds the core schema. - -Note on create_plugin_tables: the shared helper in -shopdb.plugins.alembic_template builds a per-plugin MetaData filtered to only the -plugin's own tables, which means a foreign key to a core table (assets) cannot -be resolved at CreateTable-compile time (NoReferencedTableError). Since these -tables reference assets.assetid, the baseline emits explicit Alembic ops (the -same shape Alembic autogenerate produces) instead. Tables inherit the -connection's default charset, so on a utf8mb4 database they are utf8mb4, matching -how the core chain creates its tables. -""" -from alembic import op - -from shopdb.plugins.alembic_template import ( - create_index_if_missing, - create_table_if_missing, -) -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'measuringtools0001baseline' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - create_table_if_missing( - 'measuringtooltypes', - sa.Column('measuringtooltypeid', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=100), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('color', sa.String(length=20), nullable=True), - sa.Column('createddate', sa.DateTime(), nullable=False), - sa.Column('modifieddate', sa.DateTime(), nullable=False), - sa.Column('isactive', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('measuringtooltypeid'), - sa.UniqueConstraint('name'), - ) - create_table_if_missing( - 'measuringtools', - sa.Column('measuringtoolid', sa.Integer(), nullable=False), - sa.Column('assetid', sa.Integer(), nullable=False), - sa.Column('measuringtooltypeid', sa.Integer(), nullable=True), - sa.Column('calibrationintervaldays', sa.Integer(), nullable=True), - sa.Column('lastcalibrationdate', sa.Date(), nullable=True), - sa.Column('nextcalibrationdate', sa.Date(), nullable=True), - sa.Column('calibrationprovider', sa.String(length=150), nullable=True), - sa.Column('notes', sa.Text(), nullable=True), - sa.Column('createddate', sa.DateTime(), nullable=False), - sa.Column('modifieddate', sa.DateTime(), nullable=False), - sa.Column('isactive', sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['measuringtooltypeid'], - ['measuringtooltypes.measuringtooltypeid']), - sa.PrimaryKeyConstraint('measuringtoolid'), - sa.UniqueConstraint('assetid'), - ) - create_index_if_missing('idx_measuringtool_type', 'measuringtools', - ['measuringtooltypeid']) - - -def downgrade(): - op.drop_index('idx_measuringtool_type', table_name='measuringtools') - op.drop_table('measuringtools') - op.drop_table('measuringtooltypes') diff --git a/plugins/measuringtools/models/__init__.py b/plugins/measuringtools/models/__init__.py deleted file mode 100644 index f1f9f8de..00000000 --- a/plugins/measuringtools/models/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Measuring-tools plugin models.""" - -from .measuringtool import ( - MeasuringTool, - MeasuringToolType, - derive_status, - STATUS_COLORS, - DUESOON_WINDOW_DAYS, -) - -__all__ = [ - 'MeasuringTool', - 'MeasuringToolType', - 'derive_status', - 'STATUS_COLORS', - 'DUESOON_WINDOW_DAYS', -] diff --git a/plugins/measuringtools/models/measuringtool.py b/plugins/measuringtools/models/measuringtool.py deleted file mode 100644 index 78d9464e..00000000 --- a/plugins/measuringtools/models/measuringtool.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Measuring-tools models. - -Measuring tools are gage-lab instruments (calipers, micrometers, thread gages, -bore gages, height gages, Genspect heads, ...) that measure parts, as opposed -to machines that make parts (see ADR-005). Each tool is a core Asset plus a -one-to-one measuringtools extension row. - -The lifecycle a measuring tool cares about is CALIBRATION, not maintenance: -an interval, a last date, and a next date. Calibration STATUS is DERIVED from -nextcalibrationdate at read time (see derive_status), never stored, so it is -always current no matter how long since the last write. This mirrors the -warranty plugin's derive-at-read pattern. -""" - -from datetime import date, timedelta - -from shopdb.api import db, BaseModel - -# Window before nextcalibrationdate where a tool counts as "due soon". -DUESOON_WINDOW_DAYS = 30 - -# Derived status -> display color (hex). Reused by the frontend status badge. -STATUS_COLORS = { - 'overdue': '#F44336', - 'duesoon': '#FF9800', - 'current': '#4CAF50', - 'unknown': '#9E9E9E', -} - - -def derive_status(nextcalibrationdate, today=None): - """Calibration status from a next-calibration date. Never stored. - - overdue - the next date is in the past - duesoon - the next date is within DUESOON_WINDOW_DAYS from today - current - the next date is further out than the window - unknown - no next date recorded - """ - if not nextcalibrationdate: - return 'unknown' - today = today or date.today() - if nextcalibrationdate < today: - return 'overdue' - if nextcalibrationdate <= today + timedelta(days=DUESOON_WINDOW_DAYS): - return 'duesoon' - return 'current' - - -class MeasuringToolType(BaseModel): - """Measuring-tool classification (Caliper, Micrometer, Thread Gage, ...). - - Site-managed lookup with a display color for badges and map markers, - the same shape as the machine/computer/printer type tables. - """ - __tablename__ = 'measuringtooltypes' - - measuringtooltypeid = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100), unique=True, nullable=False) - description = db.Column(db.Text) - color = db.Column(db.String(20), comment='CSS color for UI/map markers') - - def __repr__(self): - return f"" - - -class MeasuringTool(BaseModel): - """Measuring-tool extension data, one-to-one with a core Asset. - - Identity lives on the Asset (assetnumber = gage tag, serialnumber = vendor - serial, gaugelabreference identifier). This row carries only the metrology - domain fields: the tool type and the calibration lifecycle. - """ - __tablename__ = 'measuringtools' - - measuringtoolid = db.Column(db.Integer, primary_key=True) - - # Link to the core asset (one extension row per asset). - assetid = db.Column( - db.Integer, - db.ForeignKey('assets.assetid', ondelete='CASCADE'), - unique=True, - nullable=False, - index=True, - ) - - measuringtooltypeid = db.Column( - db.Integer, - db.ForeignKey('measuringtooltypes.measuringtooltypeid'), - nullable=True, - ) - - # Calibration lifecycle. Status is DERIVED from nextcalibrationdate, so - # none of these columns store a status. - calibrationintervaldays = db.Column(db.Integer, nullable=True) - lastcalibrationdate = db.Column(db.Date, nullable=True) - nextcalibrationdate = db.Column(db.Date, nullable=True) - calibrationprovider = db.Column(db.String(150), nullable=True) - - notes = db.Column(db.Text, nullable=True) - - asset = db.relationship( - 'Asset', - backref=db.backref('measuringtool', uselist=False, lazy='joined'), - ) - measuringtooltype = db.relationship('MeasuringToolType', backref='tools') - - __table_args__ = ( - db.Index('idx_measuringtool_type', 'measuringtooltypeid'), - ) - - def __repr__(self): - return f"" - - def calibrationstatus(self, today=None): - return derive_status(self.nextcalibrationdate, today) - - def to_dict(self, today=None): - """Extension dict with the type name and derived calibration status.""" - result = super().to_dict() - # BaseModel.to_dict only isoformats datetime, not plain date columns. - # Emit calibration dates as 'YYYY-MM-DD' so the frontend parses them - # the same way it parses warranty dates. - for field in ('lastcalibrationdate', 'nextcalibrationdate'): - value = getattr(self, field) - result[field] = value.isoformat() if value else None - if self.measuringtooltype: - result['measuringtooltypename'] = self.measuringtooltype.name - result['measuringtooltypecolor'] = self.measuringtooltype.color - status = self.calibrationstatus(today) - result['calibrationstatus'] = status - result['calibrationstatuscolor'] = STATUS_COLORS.get( - status, STATUS_COLORS['unknown']) - return result diff --git a/plugins/measuringtools/plugin.py b/plugins/measuringtools/plugin.py deleted file mode 100644 index 445a0c80..00000000 --- a/plugins/measuringtools/plugin.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Measuring-tools plugin main class. - -The first plugin built on the matured framework scaffold (ADR-005). It owns two -tables (measuringtooltypes, measuringtools), a blueprint under /api/measuringtools, -a sidebar entry, and a calibration report card. It seeds its own asset type and -a set of starter tool types on install. -""" - -import json -import logging -from pathlib import Path -from typing import List, Dict, Optional, Type - -from flask import Flask, Blueprint - -from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.api import db, AssetType - -from .api import measuringtools_bp -from .models import MeasuringTool, MeasuringToolType - -logger = logging.getLogger(__name__) - -# Starter tool types seeded on install: (name, description, color). -# Colors are drawn from the shared frontend PALETTE (utils/colorStyle.js). -STARTER_TYPES = [ - ('Caliper', 'Vernier / digital caliper', '#14abef'), - ('Micrometer', 'Outside / inside / depth micrometer', '#2dce89'), - ('Thread Gage', 'Go / no-go thread gage', '#fb6340'), - ('Bore Gage', 'Bore / hole diameter gage', '#7934f3'), - ('Height Gage', 'Height / vertical measuring gage', '#ffc107'), - ('Indicator', 'Dial / test indicator', '#11cdef'), - ('Gage Block Set', 'Reference gage block set', '#e83e8c'), - ('Other', 'Other measuring tool', '#6c757d'), -] - - -class MeasuringToolsPlugin(BasePlugin): - """Metrology and inspection instruments (calibration lifecycle).""" - - def __init__(self): - self._manifest = self._load_manifest() - - def _load_manifest(self) -> Dict: - manifest_path = Path(__file__).parent / 'manifest.json' - if manifest_path.exists(): - with open(manifest_path, 'r') as f: - return json.load(f) - return {} - - @property - def meta(self) -> PluginMeta: - return PluginMeta( - name=self._manifest.get('name', 'measuringtools'), - version=self._manifest.get('version', '1.0.0'), - description=self._manifest.get( - 'description', 'Metrology and inspection instruments'), - author=self._manifest.get('author', 'ShopDB Team'), - dependencies=self._manifest.get('dependencies', []), - core_version=self._manifest.get('core_version', '>=0.6.0,<1.0.0'), - api_prefix=self._manifest.get('api_prefix', '/api/measuringtools'), - ) - - def get_blueprint(self) -> Optional[Blueprint]: - return measuringtools_bp - - def get_models(self) -> List[Type]: - return [MeasuringTool, MeasuringToolType] - - def get_navigation_items(self) -> List[Dict]: - return [ - { - 'name': 'Measuring Tools', - 'icon': 'ruler', - 'route': '/measuringtools', - 'permission': 'measuringtools.view', - 'position': 22, - 'section': 'assets', - }, - ] - - def get_reports(self) -> List[Dict]: - return [ - { - 'id': 'calibration', - 'name': 'Calibration Due', - 'description': 'Measuring tools bucketed by calibration status', - 'category': 'compliance', - 'route': '/reports/calibration', - 'permission': 'measuringtools.view', - }, - ] - - def get_config_schema(self) -> List[Dict]: - # No external credentials or endpoints: calibration is tracked by hand, - # so the setup wizard shows nothing to configure. Documented in the - # plugin guide as the intentional empty-schema case. - return [] - - def get_settings_cards(self) -> List[Dict]: - # ADR-010 pilot. Contributes the Measuring Tools settings card that used - # to be hardcoded in the core settingsNav.js catalog. - return [ - { - 'group': 'Measuring Tools', - 'to': '/settings/measuringtooltypes', - 'icon': 'ruler', - 'title': 'Measuring Tool Types', - 'description': 'Manage measuring-tool subtypes (caliper, ' - 'micrometer, thread gage...) + map colors', - 'position': 22, - }, - ] - - def get_asset_presentation(self) -> List[Dict]: - # ADR-010 pilot. Tells core how to render + link the measuring_tool - # asset type in global-search rows and cross-links. - # The consumer only substitutes {assetid} (search/cross-link rows carry - # the core asset id, not the extension id), so link through the by-asset - # resolver route rather than the id-keyed detail route. - return [ - { - 'assettype': 'measuring_tool', - 'icon': 'ruler', - 'label': 'Measuring Tool', - 'route': '/measuringtools/by-asset/{assetid}', - }, - ] - - def get_map_overlays(self) -> List[Dict]: - # ADR-010 pilot. Declares a calibration-due badge overlay for the - # shop-floor map; the map fetches the endpoint to decorate markers. - return [ - { - 'id': 'calibration-due', - 'label': 'Calibration due', - 'endpoint': '/api/measuringtools/map-overlay', - 'style': 'badge', - 'legend': True, - }, - ] - - def init_app(self, app: Flask, db_instance) -> None: - logger.info(f"Measuring-tools plugin initialized (v{self.meta.version})") - - def on_install(self, app: Flask) -> None: - with app.app_context(): - self._ensure_asset_type() - self._ensure_starter_types() - db.session.commit() - logger.info("Measuring-tools plugin installed") - - def _ensure_asset_type(self) -> None: - existing = AssetType.query.filter_by(assettype='measuring_tool').first() - if not existing: - db.session.add(AssetType( - assettype='measuring_tool', - pluginname='measuringtools', - tablename='measuringtools', - description='Metrology and inspection instruments (gauges, ' - 'calipers, thread gages, bore gages, ...)', - icon='ruler', - )) - logger.debug("Created asset type: measuring_tool") - db.session.commit() - - def _ensure_starter_types(self) -> None: - for name, description, color in STARTER_TYPES: - if not MeasuringToolType.query.filter_by(name=name).first(): - db.session.add(MeasuringToolType( - name=name, description=description, color=color)) - logger.debug(f"Created measuring-tool type: {name}") - db.session.commit() - - def get_permissions(self) -> List: - """Return the RBAC permissions this plugin owns.""" - return [ - ('measuringtools.view', 'View measuring tools', 'measuringtools'), - ('measuringtools.create', 'Create measuring tools', 'measuringtools'), - ('measuringtools.edit', 'Edit measuring tools', 'measuringtools'), - ('measuringtools.delete', 'Delete measuring tools', 'measuringtools'), - ] diff --git a/plugins/network/frontend/routes.js b/plugins/network/frontend/routes.js index b7aeb49a..abb6c8ce 100644 --- a/plugins/network/frontend/routes.js +++ b/plugins/network/frontend/routes.js @@ -55,6 +55,18 @@ export default [ name: 'subnets', component: () => import('./views/SubnetsList.vue'), meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' } + }, + { + path: 'settings/network/vlans', + name: 'network-hub-vlans', + component: () => import('./views/VLANsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' } + }, + { + path: 'settings/network/subnets', + name: 'network-hub-subnets', + component: () => import('./views/SubnetsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' } } ] diff --git a/plugins/network/frontend/views/SubnetDetail.vue b/plugins/network/frontend/views/SubnetDetail.vue index ca121ad0..e2e4a1fd 100644 --- a/plugins/network/frontend/views/SubnetDetail.vue +++ b/plugins/network/frontend/views/SubnetDetail.vue @@ -69,7 +69,7 @@ const devices = computed(() => subnet.value?.devices || []) const TYPE_LABELS = { computer: 'PC', network_device: 'Network Device', printer: 'Printer', - machine: 'Machine', measuring_tool: 'Measuring Tool', + machine: 'Machine', } function typeLabel(type) { return TYPE_LABELS[type] || type || '-' diff --git a/plugins/network/plugin.py b/plugins/network/plugin.py index 084e68e0..474be912 100644 --- a/plugins/network/plugin.py +++ b/plugins/network/plugin.py @@ -221,3 +221,16 @@ class NetworkPlugin(BasePlugin): ('network.edit', 'Edit network devices', 'network'), ('network.delete', 'Delete network devices', 'network'), ] + + def get_asset_type_descriptor(self): + """Return descriptor for network device types management in Asset Type Hub.""" + return { + 'assettype': 'network_device', + 'subtype_endpoint': '/network/types', + 'subtype_id_field': 'networkdevicetypeid', + 'subtype_label_field': 'networkdevicetype', + 'subtype_description_field': 'description', + 'subtype_color_field': 'color', + 'optional_tabs': [], + 'edit_permission': 'network.manage', + } diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index b1058325..77197e7e 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -11,7 +11,7 @@ from flask_jwt_extended import jwt_required from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import Notification, NotificationType -from ..models.notification import _utc_iso +from ..models.notification import _utc_iso, ALERT_SCOPE_KEYS from shopdb.api import require_permission, Setting, intarg @@ -217,18 +217,17 @@ def _employee_name(sso): """Live directory name for an SSO; None on any miss. Used as the fallback when a notification has no stored employeename (see the shopfloor feed).""" try: - from plugins.employees.api.routes import resolve_employee_display_name + from shopdb.api import resolve_employee_display_name return resolve_employee_display_name(sso) except Exception: return None def _employee_picture(sso): - """Resolved display photo URL for an SSO, via the shared employees-plugin - resolver so kiosk cards match EmployeeDetail in both directory modes - (self-hosted upload URL or external HR URL). None on any miss.""" + """Resolved display photo URL for an SSO, via the shared core resolver so a + feed card and a lookup agree about the same person. None on any miss.""" try: - from plugins.employees.api.routes import resolve_employee_photo_url + from shopdb.api import resolve_employee_photo_url return resolve_employee_photo_url(sso) except Exception: return None @@ -405,6 +404,69 @@ def get_notification(notification_id: int): return success_response(n.to_dict()) +def _clean_alert_fields(data): + """Validate and normalise alertfleet / alertscope / alerttimeout. + + Returns (fields, error). fields holds only the keys present in data, with + null coerced to the column default. alertscope must be a JSON object (or a + string holding one) whose keys the endpoint matcher understands; empty or + null means the whole fleet. + """ + import json + + fields = {} + if 'alertfleet' in data: + value = data['alertfleet'] + if value is None: + value = False + if not isinstance(value, bool): + return None, 'alertfleet must be true or false' + fields['alertfleet'] = value + + if 'alerttimeout' in data: + value = data['alerttimeout'] + if value is None or value == '': + value = 0 + if isinstance(value, bool): + return None, 'alerttimeout must be a whole number of seconds' + try: + value = int(value) + except (TypeError, ValueError): + return None, 'alerttimeout must be a whole number of seconds' + if value < 0: + return None, 'alerttimeout must not be negative' + fields['alerttimeout'] = value + + if 'alertscope' in data: + scope = data['alertscope'] + if isinstance(scope, str): + if not scope.strip(): + scope = None + else: + try: + scope = json.loads(scope) + except ValueError: + return None, 'alertscope must be a JSON object' + if scope is not None: + if not isinstance(scope, dict): + return None, 'alertscope must be a JSON object' + unknown = sorted(set(scope) - ALERT_SCOPE_KEYS) + if unknown: + return None, (f'alertscope has unknown key(s): {", ".join(unknown)}. ' + f'Allowed: {", ".join(sorted(ALERT_SCOPE_KEYS))}') + for key, wanted in scope.items(): + values = wanted if isinstance(wanted, list) else [wanted] + if any(v is not None and not isinstance(v, (str, int, float)) + or isinstance(v, bool) for v in values): + return None, (f'alertscope.{key} must be a string or a list ' + f'of strings') + if not scope: + scope = None + fields['alertscope'] = scope + + return fields, None + + @notifications_bp.route('', methods=['POST']) @require_permission('notifications.create') def create_notification(): @@ -419,6 +481,10 @@ def create_notification(): if not notification_text: return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required') + alert_fields, alert_error = _clean_alert_fields(data) + if alert_error: + return error_response(ErrorCodes.VALIDATION_ERROR, alert_error) + # Parse dates starttime = datetime.now(timezone.utc).replace(tzinfo=None) if data.get('starttime') or data.get('startdate'): @@ -455,7 +521,10 @@ def create_notification(): isactive=True, isshopfloor=data.get('isshopfloor', False), employeesso=data.get('employeesso'), - employeename=data.get('employeename') + employeename=data.get('employeename'), + alertfleet=alert_fields.get('alertfleet', False), + alertscope=alert_fields.get('alertscope'), + alerttimeout=alert_fields.get('alerttimeout', 0) ) db.session.add(n) @@ -504,6 +573,12 @@ def update_notification(notification_id: int): n.employeesso = data['employeesso'] if 'employeename' in data: n.employeename = data['employeename'] + alert_fields, alert_error = _clean_alert_fields(data) + if alert_error: + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, alert_error) + for field, value in alert_fields.items(): + setattr(n, field, value) # Parse and update dates if 'starttime' in data or 'startdate' in data: diff --git a/plugins/notifications/frontend/views/NotificationForm.vue b/plugins/notifications/frontend/views/NotificationForm.vue index c88ede31..528df1c5 100644 --- a/plugins/notifications/frontend/views/NotificationForm.vue +++ b/plugins/notifications/frontend/views/NotificationForm.vue @@ -217,6 +217,46 @@ Display on TV dashboard
+ +
+ + Push this notification to shop floor PCs +
+
+ +
+
+ + + Optional: Filter alerts by asset type, location, or business unit (JSON format) +
+ +
+ + + Minutes until auto-dismiss (0 = manual acknowledgment only) +
{{ error }}
@@ -282,13 +322,42 @@ const form = ref({ // loads its own stored value (see loadNotification), so this never flips a // setting somebody deliberately turned off. isshopfloor: true, - employeesso: '' + employeesso: '', + alertfleet: false, + alertscope: null, + alerttimeout: 0 }) // Site timezone (settings key site_timezone) so form times show/enter in the // site's wall clock, not the viewer's browser zone. Loaded in onMounted. const siteTimezone = ref(DEFAULT_TZ) +// Alert scope as text for editing (converts to/from JSON) +const alertScopeText = ref('') + +watch(() => form.value.alertscope, (newVal) => { + if (newVal && typeof newVal === 'object') { + alertScopeText.value = JSON.stringify(newVal, null, 2) + } else if (newVal && typeof newVal === 'string') { + alertScopeText.value = newVal + } else { + alertScopeText.value = '' + } +}, { immediate: true }) + +watch(alertScopeText, (newVal) => { + if (!newVal.trim()) { + form.value.alertscope = null + return + } + try { + form.value.alertscope = JSON.parse(newVal) + } catch (e) { + // Keep the text as-is until it's valid JSON + form.value.alertscope = newVal + } +}) + function selectedTypeName() { const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid)) return selectedType?.typename?.toLowerCase() || '' @@ -349,7 +418,10 @@ onMounted(async () => { endtime: formatDateForInput(n.endtime), isactive: n.isactive !== false, isshopfloor: n.isshopfloor || false, - employeesso: n.employeesso || '' + employeesso: n.employeesso || '', + alertfleet: n.alertfleet || false, + alertscope: n.alertscope || null, + alerttimeout: n.alerttimeout !== undefined ? n.alerttimeout : 0 } // Parse existing employee data: SSOs are comma-joined and names are @@ -527,7 +599,10 @@ async function saveNotification() { endtime: utcFromZonedInput(form.value.endtime, siteTimezone.value), isactive: form.value.isactive, isshopfloor: form.value.isshopfloor, - employeesso: form.value.employeesso || null + employeesso: form.value.employeesso || null, + alertfleet: form.value.alertfleet || false, + alertscope: form.value.alertscope || null, + alerttimeout: form.value.alerttimeout !== undefined ? form.value.alerttimeout : 0 } // For employee-photo types, also send employeename diff --git a/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py b/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py new file mode 100644 index 00000000..cec8b136 --- /dev/null +++ b/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py @@ -0,0 +1,102 @@ +"""Add fleet alert support to notifications plugin. + +Adds alertfleet, alertscope, and alerttimeout columns to the notifications table +to support fleet-wide alert broadcasting. Creates notificationacknowledgements +table to track which assets have acknowledged fleet alerts. + +Fleet alerts can be scoped to specific asset types, locations, or business units +via the alertscope TEXT field (JSON string). alerttimeout=0 means no auto-dismiss (manual only). + +Idempotent: column and table creation are skipped when they already exist. + +Revision ID: notifications0006fleetalerts +Revises: notifications0005boardorder +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'notifications0006fleetalerts' +down_revision = 'notifications0005boardorder' +branch_labels = None +depends_on = None + + +def _column_names(insp, table): + return {c['name'] for c in insp.get_columns(table)} + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + existing_tables = set(insp.get_table_names()) + + # Add alertfleet, alertscope, alerttimeout to notifications table + if 'notifications' in existing_tables: + cols = _column_names(insp, 'notifications') + + if 'alertfleet' not in cols: + op.add_column('notifications', + sa.Column('alertfleet', sa.Boolean(), nullable=False, + server_default=sa.false())) + + if 'alertscope' not in cols: + op.add_column('notifications', + sa.Column('alertscope', sa.Text, nullable=True)) + + if 'alerttimeout' not in cols: + op.add_column('notifications', + sa.Column('alerttimeout', sa.Integer(), nullable=False, + server_default='0')) + + # Create notificationacknowledgements table + if 'notificationacknowledgements' not in existing_tables: + op.create_table( + 'notificationacknowledgements', + sa.Column('notificationacknowledgementid', sa.Integer(), nullable=False), + sa.Column('notificationid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('acknowledgedat', sa.DateTime(), nullable=False, + server_default=sa.text('NOW()')), + sa.PrimaryKeyConstraint('notificationacknowledgementid'), + sa.ForeignKeyConstraint(['notificationid'], + ['notifications.notificationid'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], + ondelete='CASCADE'), + sa.UniqueConstraint('notificationid', 'assetid', + name='uq_notification_asset') + ) + op.create_index('idx_notificationack_notification', + 'notificationacknowledgements', + ['notificationid']) + op.create_index('idx_notificationack_asset', + 'notificationacknowledgements', + ['assetid']) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + existing_tables = set(insp.get_table_names()) + + # Drop notificationacknowledgements table + if 'notificationacknowledgements' in existing_tables: + op.drop_index('idx_notificationack_asset', + table_name='notificationacknowledgements') + op.drop_index('idx_notificationack_notification', + table_name='notificationacknowledgements') + op.drop_table('notificationacknowledgements') + + # Drop alert columns from notifications table + if 'notifications' in existing_tables: + cols = _column_names(insp, 'notifications') + + if 'alerttimeout' in cols: + op.drop_column('notifications', 'alerttimeout') + + if 'alertscope' in cols: + op.drop_column('notifications', 'alertscope') + + if 'alertfleet' in cols: + op.drop_column('notifications', 'alertfleet') diff --git a/plugins/notifications/models/__init__.py b/plugins/notifications/models/__init__.py index f6514a97..0dadbd21 100644 --- a/plugins/notifications/models/__init__.py +++ b/plugins/notifications/models/__init__.py @@ -1,5 +1,6 @@ """Notifications plugin models.""" from .notification import Notification, NotificationType +from .acknowledgement import NotificationAcknowledgement -__all__ = ['Notification', 'NotificationType'] +__all__ = ['Notification', 'NotificationType', 'NotificationAcknowledgement'] diff --git a/plugins/notifications/models/acknowledgement.py b/plugins/notifications/models/acknowledgement.py new file mode 100644 index 00000000..e2e887a8 --- /dev/null +++ b/plugins/notifications/models/acknowledgement.py @@ -0,0 +1,52 @@ +"""Notification acknowledgement tracking for fleet alerts.""" + +from datetime import datetime, timezone +from shopdb.api import db + + +class NotificationAcknowledgement(db.Model): + """ + Tracks which assets have acknowledged fleet alerts. + Matches existing notificationacknowledgements table. + """ + __tablename__ = 'notificationacknowledgements' + __table_args__ = ( + db.UniqueConstraint('notificationid', 'assetid', name='uq_notification_asset'), + ) + + notificationacknowledgementid = db.Column(db.Integer, primary_key=True) + notificationid = db.Column( + db.Integer, + db.ForeignKey('notifications.notificationid', ondelete='CASCADE'), + nullable=False, + index=True + ) + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + nullable=False, + index=True + ) + acknowledgedat = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) + + # Relationships + notification = db.relationship('Notification', backref='acknowledgements') + asset = db.relationship('Asset', backref='notification_acknowledgements') + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary following ShopDB patterns.""" + return { + 'notificationacknowledgementid': self.notificationacknowledgementid, + 'notificationid': self.notificationid, + 'assetid': self.assetid, + 'acknowledgedat': self.acknowledgedat.replace( + tzinfo=timezone.utc + ).isoformat() if self.acknowledgedat else None + } diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py index 69781d83..9619aeb2 100644 --- a/plugins/notifications/models/notification.py +++ b/plugins/notifications/models/notification.py @@ -1,12 +1,54 @@ """Notifications plugin models - adapted to existing database schema.""" +import json + from datetime import datetime, timezone from zoneinfo import ZoneInfo + +from sqlalchemy import Text +from sqlalchemy.types import TypeDecorator + from shopdb.api import db +class JSONText(TypeDecorator): + """A dict on the Python side, TEXT in the database. + + MySQL 5.6 has no JSON type and a site still runs it, so every JSON-bearing + column in this product is TEXT. Declared here rather than imported from the + computers plugin: notifications declares no dependency on computers, and a + cross-plugin import would be a hidden one. + """ + + impl = Text + cache_ok = True + + def process_bind_param(self, value, dialect): + if value is None: + return None + return json.dumps(value, separators=(',', ':')) + + def process_result_value(self, value, dialect): + if value is None or value == '': + return None + try: + return json.loads(value) + except (TypeError, ValueError): + # Hand-edited or written before this column held JSON. Return the + # raw text rather than raising: a scope that cannot be parsed + # should be visible and fixable, and the endpoints matcher already + # treats a non-dict scope as matching nothing. + return value + + _DEFAULT_TZ = 'America/New_York' +# Keys a fleet alert's alertscope may use. Must stay equal to the set the +# endpoints matcher (_ALERT_SCOPE_KEYS in plugins/endpoints/api/routes.py) +# understands; a test pins the two together. +ALERT_SCOPE_KEYS = frozenset({'hostname', 'assettype', 'computertype', + 'location', 'businessunit'}) + def _site_zone(): """Site-configured IANA zone (settings key site_timezone) for calendar day @@ -146,6 +188,16 @@ class Notification(db.Model): # ~11 people are listed. employeesso = db.Column(db.Text, nullable=True) employeename = db.Column(db.Text, nullable=True) + # Matches migration 0006: NOT NULL with server defaults. + alertfleet = db.Column(db.Boolean, nullable=False, default=False, + server_default=db.false()) + # JSONText, not db.JSON: migration 0006 ships this column as TEXT because a + # site still runs MySQL 5.6, which has no JSON type. db.JSON here meant + # create-all built a JSON column the migration path never produces, so the + # model and the shipped schema disagreed about the same column. + alertscope = db.Column(JSONText, nullable=True) + alerttimeout = db.Column(db.Integer, nullable=False, default=0, + server_default='0') # Relationships notificationtype = db.relationship('NotificationType', backref='notifications') @@ -194,7 +246,10 @@ class Notification(db.Model): 'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False, 'employeesso': self.employeesso, 'employeename': self.employeename, - 'iscurrent': self.is_current + 'iscurrent': self.is_current, + 'alertfleet': bool(self.alertfleet) if self.alertfleet is not None else False, + 'alertscope': self.alertscope, + 'alerttimeout': self.alerttimeout } # Add type info diff --git a/plugins/notifications/plugin.py b/plugins/notifications/plugin.py index 80d8ed71..4bf332dc 100644 --- a/plugins/notifications/plugin.py +++ b/plugins/notifications/plugin.py @@ -11,7 +11,7 @@ import click from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.api import db -from .models import Notification, NotificationType +from .models import Notification, NotificationType, NotificationAcknowledgement from .api import notifications_bp logger = logging.getLogger(__name__) @@ -60,7 +60,7 @@ class NotificationsPlugin(BasePlugin): def get_models(self) -> List[Type]: """Return list of SQLAlchemy model classes.""" - return [Notification, NotificationType] + return [Notification, NotificationType, NotificationAcknowledgement] def init_app(self, app: Flask, db_instance) -> None: """Initialize plugin with Flask app.""" @@ -75,10 +75,17 @@ class NotificationsPlugin(BasePlugin): def _ensure_notification_types(self) -> None: """Ensure default notification types exist. - For the special shopfloor types (recognition, training, - recertification) the typecolor is a keyword the dashboard maps to a - style and the feed uses to split multi-employee cards per employee; - generic types use a hex color. + The three employee-facing types - Recognition, Training and + Recertification - are NOT seeded on this branch. Each splits one + notification into a card per employee and renders an employee photo, + which needs the employees plugin, and this branch does not bundle it + (see deploy/site-profile-universal.json). Seeding them here would offer + an author a type that cannot resolve anybody: the form would list it, + the feed would split on a directory that is not there, and the failure + would arrive later and further away than the choice that caused it. + + A site that installs the employees plugin gets them from that plugin's + own seed, so nothing is lost by their absence here. """ # (typename, typedescription, typecolor, expirymode, expirydays, # expiryhour, splitperemployee, showemployeephoto, displaystyle) @@ -88,9 +95,6 @@ class NotificationsPlugin(BasePlugin): ('Incident', 'Incident or outage notification', '#dc3545', 'none', None, None, False, False, 'standard'), ('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'none', None, None, False, False, 'standard'), ('General', 'General announcement', '#28a745', 'none', None, None, False, False, 'standard'), - ('Recognition', 'Employee recognition (clears at 8 AM Eastern)', '#ffc107', 'dailytime', None, 8, True, True, 'carousel'), - ('Training', 'Training notice (one card per employee)', '#17a2b8', 'none', None, None, True, True, 'carousel'), - ('Recertification', 'Employees due to retake a training course (shows two weeks)', '#0d6efd', 'duration', 14, None, True, True, 'grid'), ] for (typename, typedescription, typecolor, expirymode, expirydays, diff --git a/plugins/printedparts/README.md b/plugins/printedparts/README.md deleted file mode 100644 index 51fe460d..00000000 --- a/plugins/printedparts/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Printedparts plugin - -3D-printed parts inventory + kiosk checkout - -This plugin was generated by `flask plugin new printedparts`. It satisfies the framework contract out of the box. Replace the example model and routes with your domain. - -## What's here - -- `plugin.py` - the `PrintedpartsPlugin` class extending `BasePlugin`. Edit `init_app` for custom setup, `on_install` to seed reference data. -- `models/printedparts.py` - example Asset extension table. Replace `examplefield` with your domain fields. -- `api/routes.py` - example list and detail endpoints. Add CRUD as needed. -- `schemas/__init__.py` - marshmallow schema stub for request/response validation. -- `tests/test_plugin.py` - smoke tests asserting contract compliance. -- `manifest.json` - plugin metadata. Bump `version` on changes; keep `core_version` range broad. - -## Common edits - -| You want to... | Do this | -|---|---| -| Add a hook (search, navigation, dashboard widget) | Override the method in `PrintedpartsPlugin`. See the PLUGIN-HOOKS wiki page. | -| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. | -| Add another model | Create `models/.py`, export it in `models/__init__.py`, return it in `get_models()`. | -| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. | - -## Frontend - -Vue components for this plugin live under `frontend/src/views/printedparts/` (per project convention). Backend scaffolding does not generate frontend yet; copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point. - -## Install and run - -```bash -flask plugin install printedparts -flask db migrate -m "Add printedparts plugin tables" -flask db upgrade -pytest plugins/printedparts/tests/ -``` - -## References - -- the PLUGIN-HOOKS wiki page - canonical hook reference -- the PLUGIN-QUICKSTART wiki page - 30-minute walkthrough -- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract -- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules - -## Why the kiosk take endpoint is unauthenticated - -`POST /api/printedparts/kiosk/take` is the product's first open WRITE (every -other kiosk endpoint is a read). Accepted deliberately, against the criteria -in docs/proposals/printedparts-plugin.md: - -1. Decrement-only: it can reduce stock of an active item, nothing else. -2. Fully attributed: it refuses to act without a badge that resolves under - the site policy; every action lands in the ledger with SSO + name + time. -3. Bounded blast radius: worst case is stock counts driven low - visible in - the ledger and reversible with an adjust. -4. Physically rate-limited: it serves a touch screen on the shop floor; - nothing enumerable, nothing worth scraping. - -Any future open-write endpoint must clear the same bar. diff --git a/plugins/printedparts/__init__.py b/plugins/printedparts/__init__.py deleted file mode 100644 index 90bc9c3f..00000000 --- a/plugins/printedparts/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Printedparts plugin package.""" - -from .plugin import PrintedpartsPlugin - -__all__ = ['PrintedpartsPlugin'] diff --git a/plugins/printedparts/api/__init__.py b/plugins/printedparts/api/__init__.py deleted file mode 100644 index 2043056e..00000000 --- a/plugins/printedparts/api/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Printedparts plugin API package.""" - -from .routes import printedparts_bp - -__all__ = ['printedparts_bp'] diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py deleted file mode 100644 index 58dc5688..00000000 --- a/plugins/printedparts/api/routes.py +++ /dev/null @@ -1,891 +0,0 @@ -"""Printedparts plugin API routes. - -Access model: browsing the catalog (items, detail, file listings) requires -the printedparts.view permission; every mutation carries its own permission. -Deliberately open: the kiosk endpoints (decision record in the proposal), -the image serve and file download (fetched by tags and anchor -downloads, which cannot carry a JWT header), and the reports (jwt-optional -like every other report in the product). -""" - -from flask import Blueprint, request -from flask_jwt_extended import jwt_required -from sqlalchemy import or_ - -from shopdb.api import ( - db, - success_response, - error_response, - paginated_response, - ErrorCodes, - get_pagination_params, - paginate_query, - require_permission, -) - -from ..models import PrintedItem - -printedparts_bp = Blueprint('printedparts', __name__) - - -@printedparts_bp.route('/items', methods=['GET']) -@require_permission('printedparts.view') -def list_items(): - """List printed items, paginated; search + low-stock filter.""" - page, per_page = get_pagination_params(request) - query = PrintedItem.query - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(PrintedItem.isactive == True) - if search := request.args.get('search'): - like = f'%{search}%' - query = query.filter(or_( - PrintedItem.itemcode.ilike(like), - PrintedItem.gagelabtag.ilike(like), - PrintedItem.itemname.ilike(like), - PrintedItem.itemdescription.ilike(like), - PrintedItem.binlocation.ilike(like), - )) - if request.args.get('lowstock', '').lower() == 'true': - query = query.filter( - PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold) - query = query.order_by(PrintedItem.itemname) - items, total = paginate_query(query, page, per_page) - return paginated_response( - [item.to_dict() for item in items], page, per_page, total) - - -@printedparts_bp.route('/items/', methods=['GET']) -@require_permission('printedparts.view') -def get_item(item_id: int): - """Get one printed item with its recent transactions.""" - item = db.session.get(PrintedItem, item_id) - if not item: - return error_response(ErrorCodes.NOT_FOUND, - f'Printed item {item_id} not found', - http_code=404) - data = item.to_dict() - recent = (item.transactions - .order_by(db.desc('transactiondate')) - .limit(25).all()) - data['recenttransactions'] = [t.to_dict() for t in recent] - return success_response(data) - - -# --- catalog mutations (stage 6 adds permission gates on top of jwt) -------- - -import glob -import os -import re - -from flask import current_app -from werkzeug.utils import secure_filename - -from shopdb.api import Setting - -IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'} -IMAGE_URL_PREFIX = '/api/printedparts/image/' - -EDITABLE_FIELDS = ('itemname', 'itemdescription', 'lowstockthreshold', - 'binlocation', 'printnotes') - - -def _imagedir(): - return os.path.join(current_app.instance_path, 'printedpartsimages') - - -def _mint_itemcode(item): - """Set itemcode from the configured prefix + the flushed row id.""" - prefix = Setting.get('printedparts_code_prefix') or '3DP' - item.itemcode = f'{prefix}{item.printeditemid:04d}' - - -@printedparts_bp.route('/items', methods=['POST']) -@require_permission('printedparts.create') -def create_item(): - """Create a printed item. - - The internal itemcode is always auto-minted; the OPTIONAL gagelabtag - carries the gage lab's assigned WJRP asset number (unique-checked).""" - data = request.get_json() or {} - itemname = (data.get('itemname') or '').strip() - if not itemname: - return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required') - - gagelabtag = (data.get('gagelabtag') or '').strip().upper() - if gagelabtag and PrintedItem.query.filter_by(gagelabtag=gagelabtag).first(): - return error_response(ErrorCodes.CONFLICT, - f'Gage lab tag {gagelabtag} is already in use', - http_code=409) - - threshold = data.get('lowstockthreshold') - if threshold is None: - threshold = int(Setting.get('printedparts_default_threshold') or 5) - - item = PrintedItem( - itemname=itemname, - gagelabtag=gagelabtag or None, - itemdescription=data.get('itemdescription'), - lowstockthreshold=threshold, - binlocation=data.get('binlocation'), - printnotes=data.get('printnotes'), - quantityonhand=0, - ) - db.session.add(item) - db.session.flush() # assigns printeditemid - _mint_itemcode(item) - db.session.commit() - return success_response(item.to_dict(), message='Printed item created', - http_code=201) - - -@printedparts_bp.route('/items/', methods=['PUT']) -@require_permission('printedparts.edit') -def update_item(item_id: int): - """Update catalog fields. Quantity moves ONLY through the ledger.""" - item = db.session.get(PrintedItem, item_id) - if not item: - return error_response(ErrorCodes.NOT_FOUND, - f'Printed item {item_id} not found', http_code=404) - data = request.get_json() or {} - if 'quantityonhand' in data: - return error_response( - ErrorCodes.VALIDATION_ERROR, - 'quantityonhand is ledger-managed; use restock or adjust') - if 'gagelabtag' in data: - gagelabtag = (data.get('gagelabtag') or '').strip().upper() - if gagelabtag: - clash = PrintedItem.query.filter( - PrintedItem.gagelabtag == gagelabtag, - PrintedItem.printeditemid != item.printeditemid).first() - if clash: - return error_response( - ErrorCodes.CONFLICT, - f'Gage lab tag {gagelabtag} is already in use', - http_code=409) - item.gagelabtag = gagelabtag or None - for field in EDITABLE_FIELDS: - if field in data: - setattr(item, field, data[field]) - db.session.commit() - return success_response(item.to_dict(), message='Printed item updated') - - -@printedparts_bp.route('/items/', methods=['DELETE']) -@require_permission('printedparts.delete') -def delete_item(item_id: int): - """Soft-retire an item; its ledger history stays.""" - item = db.session.get(PrintedItem, item_id) - if not item: - return error_response(ErrorCodes.NOT_FOUND, - f'Printed item {item_id} not found', http_code=404) - item.isactive = False - db.session.commit() - return success_response(message='Printed item retired') - - -@printedparts_bp.route('/items//restore', methods=['POST']) -@require_permission('printedparts.delete') -def restore_item(item_id: int): - """Bring a retired item back; code, photo, and history are intact.""" - item = db.session.get(PrintedItem, item_id) - if not item: - return error_response(ErrorCodes.NOT_FOUND, - f'Printed item {item_id} not found', http_code=404) - item.isactive = True - db.session.commit() - return success_response(item.to_dict(), message='Printed item restored') - - -# --- item image: the models.py upload/serve/delete trio --------------------- - -@printedparts_bp.route('/items//image', methods=['POST']) -@require_permission('printedparts.edit') -def upload_item_image(item_id: int): - """Upload (or replace) the photo for an item (multipart file=).""" - item = db.session.get(PrintedItem, item_id) - if not item: - return error_response(ErrorCodes.NOT_FOUND, - f'Printed item {item_id} not found', http_code=404) - upload = request.files.get('file') - if not upload or not upload.filename: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') - ext = os.path.splitext(upload.filename)[1].lower() - if ext not in IMAGE_EXTENSIONS: - return error_response(ErrorCodes.VALIDATION_ERROR, - f'Unsupported image type {ext}') - - imagedir = _imagedir() - os.makedirs(imagedir, exist_ok=True) - for old in glob.glob(os.path.join( - imagedir, secure_filename(f'printeditem-{item_id}') + '.*')): - os.remove(old) - - filename = secure_filename(f'printeditem-{item_id}{ext}') - upload.save(os.path.join(imagedir, filename)) - item.imageurl = f'{IMAGE_URL_PREFIX}{filename}' - db.session.commit() - return success_response(item.to_dict(), message='Item image uploaded') - - -@printedparts_bp.route('/image/', methods=['GET']) -def serve_item_image(filename): - """Serve an uploaded item image (public - kiosk and list read it).""" - return send_upload(_imagedir(), filename) - - -@printedparts_bp.route('/items//image', methods=['DELETE']) -@require_permission('printedparts.delete') -def delete_item_image(item_id: int): - """Clear an item image; delete the file only if this plugin owns it.""" - item = db.session.get(PrintedItem, item_id) - if not item: - return error_response(ErrorCodes.NOT_FOUND, - f'Printed item {item_id} not found', http_code=404) - url = item.imageurl or '' - if url.startswith(IMAGE_URL_PREFIX): - filename = secure_filename(url[len(IMAGE_URL_PREFIX):]) - path = os.path.join(_imagedir(), filename) - if os.path.exists(path): - os.remove(path) - item.imageurl = None - db.session.commit() - return success_response(item.to_dict(), message='Item image removed') - - -# --- the ledger: restock and adjust (stage 6 gates with printedparts.restock) - -from ..models import PrintedItemTransaction -from ..services.badges import BadgeError, resolve_badge - - -class InsufficientStock(Exception): - """A decrement was refused because the row no longer holds that many. - - Carries the quantity the DATABASE holds, not the stale one the caller read, - so the message the operator sees is the truth at the moment of the write. - """ - - def __init__(self, available): - super().__init__('insufficient stock') - self.available = available - - -def _move_quantity(item, quantitychange): - """Move quantityonhand by quantitychange in ONE statement, and let the row - itself refuse a decrement it cannot cover. - - This is the guard, not the caller's earlier `quantity > item.quantityonhand` - check. That check reads, decides in Python, and writes - so two kiosk takes - that interleave both read the same on-hand figure and both pass it. Five - concurrent takes of 5 from a bin holding 5 all succeeded, each wrote its own - 5 - 5 = 0, and quantityonhand ended up permanently out of step with the - ledger that recorded all five. - - Expressing it as `UPDATE ... WHERE quantityonhand >= :n` makes the database - do the comparing at write time, where it is atomic. A row that no longer - holds enough matches nothing, rowcount is 0, and the take is refused. - Returns the resulting quantity. - """ - query = PrintedItem.query.filter( - PrintedItem.printeditemid == item.printeditemid) - if quantitychange < 0: - query = query.filter(PrintedItem.quantityonhand >= -quantitychange) - moved = query.update( - {PrintedItem.quantityonhand: - PrintedItem.quantityonhand + quantitychange}, - synchronize_session=False) - if not moved: - # Read back what is actually there so the refusal can say so. - db.session.rollback() - available = db.session.query(PrintedItem.quantityonhand).filter( - PrintedItem.printeditemid == item.printeditemid).scalar() or 0 - raise InsufficientStock(available) - db.session.refresh(item) - return item.quantityonhand - - -def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None, - revision=None): - """Append a ledger row and move the cached quantity in ONE commit. - - The single-commit invariant is what keeps quantityonhand equal to the - ledger sum; every write path must go through here. Fires the low-stock - alert when this write CROSSES the item's threshold downward - crossing - (not being below) is the natural debounce: one alert per depletion, and - restocking above the threshold rearms it. - - Raises InsufficientStock when the row cannot cover the decrement. The - caller has already refused the obvious case; this is the one that only - shows up under concurrency. - """ - # Derived from what the write ACTUALLY produced, not from what this request - # read earlier. Under concurrency the two differ, and the threshold - # crossing has to be judged on the real numbers or the alert fires for the - # wrong take (or not at all). - quantityafter = _move_quantity(item, quantitychange) - quantitybefore = quantityafter - quantitychange - db.session.add(PrintedItemTransaction( - printeditemid=item.printeditemid, - transactiontype=transactiontype, - quantitychange=quantitychange, - employeesso=sso, - employeename=name, - reason=reason, - revision=revision, - )) - db.session.commit() - if (quantitychange < 0 - and quantitybefore > item.lowstockthreshold - and item.quantityonhand <= item.lowstockthreshold): - _send_lowstock_alert(item) - - -def _alert_recipients(): - """Merge selected shopdb users' account emails with the free-text list. - - Empty result means fall back to the site-wide alert_recipients.""" - from shopdb.api import User, Role - recipients = [] - userids = (Setting.get('printedparts_alert_userids') or '').strip() - for rawid in userids.split(','): - rawid = rawid.strip() - if not rawid.isdigit(): - continue - user = db.session.get(User, int(rawid)) - if user and user.isactive and user.email: - recipients.append(user.email) - roleids = (Setting.get('printedparts_alert_roleids') or '').strip() - for rawid in roleids.split(','): - rawid = rawid.strip() - if not rawid.isdigit(): - continue - role = db.session.get(Role, int(rawid)) - if role: - recipients.extend(member.email for member in role.users - if member.isactive and member.email) - extra = (Setting.get('printedparts_alert_email') or '').strip() - recipients.extend(address.strip() for address in extra.split(',') - if address.strip()) - # dedupe, order-preserving - return list(dict.fromkeys(recipients)) - - -def _send_lowstock_alert(item): - """Best-effort email when an item crosses its low-stock threshold. - - Recipients: Setting printedparts_alert_email (comma-separated), falling - back to the site's alert_recipients. Never fails the transaction - the - ledger write already committed.""" - from shopdb.api import send_email, send_webhook, Setting - code = item.gagelabtag or item.itemcode - subject = (f'Low stock: {item.itemname} ({code}) - ' - f'{item.quantityonhand} left') - # Absolute link to the item page when the site base URL is configured - # (e.g. https:///ops). Emails have no request context to derive it. - base = (Setting.get('site_base_url') or '').rstrip('/') - item_url = f'{base}/printedparts/{item.printeditemid}' if base else '' - link_html = f'

View {code}

' if item_url else '' - html = (f'

{item.itemname} ({code}) is down ' - f'to {item.quantityonhand} ' - f'(threshold {item.lowstockthreshold}).

' - f'

Bin: {item.binlocation or "-"}

' - f'{link_html}' - f'

Time to print more.

') - webhook_text = (f'**{item.itemname}** ({code}) is down to ' - f'{item.quantityonhand} (threshold {item.lowstockthreshold}). ' - f'Bin: {item.binlocation or "-"}.' - + (f' [View]({item_url})' if item_url else '')) - try: - # Webhook: the selected support team's own webhook, else the site - # default (send_webhook falls back to alert_webhook_url when url=None). - send_webhook(subject, webhook_text, url=_alert_team_webhook()) - # Email: this plugin's recipients, else the site's alert_recipients. - recipients = _alert_recipients() or _site_alert_recipients() - if recipients: - send_email(recipients, subject, html) - except Exception: - import logging - logging.getLogger(__name__).exception( - 'Low-stock alert failed for %s', item.itemcode) - - -def _site_alert_recipients(): - """Site-wide alert_recipients setting as a clean list (email fallback).""" - from shopdb.api import Setting - raw = Setting.get('alert_recipients') or '' - return [r.strip() for r in raw.replace(';', ',').split(',') if r.strip()] - - -def _alert_team_webhook(): - """Webhook URL of the support team chosen for printedparts alerts, or None. - - printedparts_alert_supportteamid selects a SupportTeam; alerts route to that - team's webhook. None -> send_webhook uses the site-wide default.""" - from shopdb.api import Setting - team_id = Setting.get('printedparts_alert_supportteamid') - if not team_id: - return None - try: - from shopdb.api import SupportTeam - team = db.session.get(SupportTeam, int(team_id)) - except (ValueError, TypeError): - return None - return (team.webhookurl or None) if team else None - - -@printedparts_bp.route('/items//restock', methods=['POST']) -@require_permission('printedparts.restock') -def restock_item(item_id: int): - """Add freshly printed stock. Body: {quantity, badge}.""" - item = db.session.get(PrintedItem, item_id) - if not item or not item.isactive: - return error_response(ErrorCodes.NOT_FOUND, - f'Printed item {item_id} not found', http_code=404) - data = request.get_json() or {} - quantity = data.get('quantity') - if not isinstance(quantity, int) or quantity < 1: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'quantity must be a positive integer') - try: - sso, name = resolve_badge(data.get('badge')) - except BadgeError as badge_error: - return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error), - http_code=422) - _ledger_write(item, 'restock', quantity, sso, name) - return success_response(item.to_dict(), message='Stock added') - - -@printedparts_bp.route('/items//adjust', methods=['POST']) -@require_permission('printedparts.restock') -def adjust_item(item_id: int): - """Correct the count (damage, recount). Body: {quantitychange, reason, badge}.""" - item = db.session.get(PrintedItem, item_id) - if not item or not item.isactive: - return error_response(ErrorCodes.NOT_FOUND, - f'Printed item {item_id} not found', http_code=404) - data = request.get_json() or {} - quantitychange = data.get('quantitychange') - if not isinstance(quantitychange, int) or quantitychange == 0: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'quantitychange must be a non-zero integer') - reason = (data.get('reason') or '').strip() - if not reason: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'reason is required for an adjustment') - if item.quantityonhand + quantitychange < 0: - return error_response( - ErrorCodes.VALIDATION_ERROR, - f'Adjustment would drive stock below zero ' - f'(on hand: {item.quantityonhand})') - try: - sso, name = resolve_badge(data.get('badge')) - except BadgeError as badge_error: - return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error), - http_code=422) - try: - _ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason) - except InsufficientStock as shortfall: - return error_response( - ErrorCodes.VALIDATION_ERROR, - f'Adjustment would drive stock below zero ' - f'(on hand: {shortfall.available})') - return success_response(item.to_dict(), message='Stock adjusted') - - -# --- kiosk: off by default, bound to a provisioned device token --------------- -# The take endpoint is the product's only open WRITE. The original decision -# record justified that with "physically rate-limited: it serves a touch screen -# on the shop floor". Nothing in code enforced the physical part, and an HTTP -# route is reachable from anywhere the app is, so the justification did not -# survive contact with a scanner. -# -# What was actually reachable with no credential: bare digits in -# _kiosk_find_item enumerate the catalogue without knowing a barcode, a badge is -# a short numeric SSO, and this route answered 422 for an unknown badge and 200 -# for a known one - an oracle for finding real ones. The write then decremented -# stock and stamped the ledger, which is the audit trail, with whichever SSO the -# caller supplied. -# -# So a badge is ATTRIBUTION, never authorization. Both kiosk routes now require -# the kiosk to be switched on for this site AND the device token an administrator -# provisioned. The read is gated too, not just the write: leaving it open would -# keep the enumeration oracle running against a kiosk that is supposedly off. -# -# The token is per-DEVICE, not per-page. A kiosk is a browser on a touch screen, -# so a token embedded in the served page would be readable by anyone who can -# load it. An admin opens the kiosk once with ?token=..., the page keeps it in -# that browser's localStorage and sends it as X-Kiosk-Token thereafter. - -import hmac - -# Largest single take the kiosk records. A shop-floor grab is single digits; 25 -# leaves real use untouched and denies draining a bin in one request. -KIOSK_MAX_TAKE_QUANTITY = 25 - - -def _kiosk_open(): - """True when this site has the kiosk on AND the caller carries its token.""" - enabled = Setting.get('printedparts_kiosk_enabled', default=False) - if not (enabled is True or str(enabled).strip().lower() in ('true', '1', 'yes', 'on')): - return False - expected = (Setting.get('printedparts_kiosk_token') or '').strip() - presented = (request.headers.get('X-Kiosk-Token') or '').strip() - if not expected or not presented: - return False - # compare_digest, not ==: the token is a secret and == leaks its length and - # matching prefix through timing. - return hmac.compare_digest(presented, expected) - - -def _kiosk_shut(): - """404, not 403 - a caller without the token does not learn the door exists.""" - return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404) - -def _kiosk_find_item(itemcode): - """Resolve a scanned or typed code to an active item. - - Accepts the full code (WJRP0042) or bare digits from the touch keypad - (42 -> prefix + zero-pad), so manual entry never needs letters. A label QR - carries 'TAG|rev'; the revision is stripped here (it identifies the physical - part's file rev, not a different item).""" - scanned = (itemcode or '').split('|', 1)[0].strip().upper() - item = PrintedItem.query.filter( - or_(PrintedItem.itemcode == scanned, - PrintedItem.gagelabtag == scanned), - PrintedItem.isactive == True).first() - if not item and scanned.isdigit(): - # Bare digits from the touch keypad match the NUMBER inside either - # identifier (internal code or gage-lab tag). Small catalog: scan - # actives and compare numeric tails; only a UNIQUE match counts. - wanted = int(scanned) - matches = [] - for candidate in PrintedItem.query.filter_by(isactive=True).all(): - for value in (candidate.itemcode, candidate.gagelabtag): - # The TRAILING digit run, not every digit in the string. A - # prefix can contain digits - the shipped default is '3DP' - - # and collecting all of them turned 3DP0004 into 30004, so the - # operator typing the 4 printed on the bin got "no part matches - # that barcode" and had to work out that the 3 belonged to the - # prefix. The number on the label is the number at the end. - digits = re.search(r'(\d+)$', (value or '').strip()) - if digits and int(digits.group(1)) == wanted: - matches.append(candidate) - break - if len(matches) == 1: - item = matches[0] - return item - - -@printedparts_bp.route('/kiosk/item/', methods=['GET']) -def kiosk_item(itemcode): - """Item summary for a scanned bin barcode. Kiosk-token gated.""" - if not _kiosk_open(): - return _kiosk_shut() - item = _kiosk_find_item(itemcode) - if not item: - return error_response(ErrorCodes.NOT_FOUND, - 'No part matches that barcode', http_code=404) - return success_response(item.to_dict()) - - -@printedparts_bp.route('/kiosk/take', methods=['POST']) -def kiosk_take(): - """Take parts from a bin. Body: {itemcode, badge, quantity}. - - The badge attributes the ledger row. It does not authorize the write - the - kiosk flag and the device token do. - """ - if not _kiosk_open(): - return _kiosk_shut() - data = request.get_json() or {} - - item = _kiosk_find_item(data.get('itemcode')) - if not item: - return error_response(ErrorCodes.NOT_FOUND, - 'No part matches that barcode', http_code=404) - - quantity = data.get('quantity') - # isinstance(True, int) is True in Python, so True would have passed as a - # quantity of 1. Reject bool explicitly. - if (not isinstance(quantity, int) or isinstance(quantity, bool) - or quantity < 1): - return error_response(ErrorCodes.VALIDATION_ERROR, - 'Enter how many you are taking') - if quantity > KIOSK_MAX_TAKE_QUANTITY: - return error_response( - ErrorCodes.VALIDATION_ERROR, - f'Take at most {KIOSK_MAX_TAKE_QUANTITY} at a time - see the ' - f'parts team') - if quantity > item.quantityonhand: - return error_response( - ErrorCodes.VALIDATION_ERROR, - f'Only {item.quantityonhand} on hand - take fewer or see the ' - f'parts team') - - try: - sso, name = resolve_badge(data.get('badge')) - except BadgeError: - # ONE message for every badge failure on the ANONYMOUS route. This is - # the only place it matters: distinguishing "malformed" from "not - # recognized" let a caller with no account sort guesses into real - # employee numbers. The gated restock/adjust handlers keep the specific - # message, because there the person is known and the detail is help. - return error_response(ErrorCodes.VALIDATION_ERROR, - 'Badge not accepted - see the parts team', - http_code=422) - - # Print-file revision the scanned part carried: explicit field, else the - # 'TAG|rev' tail of the scanned code. Recorded for traceability. - revision = data.get('revision') - if revision is None and '|' in (data.get('itemcode') or ''): - tail = data['itemcode'].split('|', 1)[1].strip() - revision = tail if tail.isdigit() else None - if revision is not None: - try: - revision = int(revision) - except (ValueError, TypeError): - revision = None - - try: - _ledger_write(item, 'take', -quantity, sso, name, revision=revision) - except InsufficientStock as shortfall: - # Another kiosk took the last of it between this request's read and its - # write. Same message as the pre-check, with the number the database - # actually holds now. - return error_response( - ErrorCodes.VALIDATION_ERROR, - f'Only {shortfall.available} on hand - take fewer or see the ' - f'parts team') - return success_response(item.to_dict(), - message=f'Took {quantity}, {item.quantityonhand} left') - - -# --- reports (merged into GET /api/reports while the plugin is enabled) ------ - -import csv -import io - -from flask import Response -from sqlalchemy import func - - -def _csv_response(rows, columns, filename): - """CSV download; local helper because generate_csv is not on the - contract surface (shopdb.api).""" - output = io.StringIO() - writer = csv.writer(output) - writer.writerow(columns) - for row in rows: - writer.writerow([row.get(column, '') for column in columns]) - return Response( - output.getvalue(), mimetype='text/csv', - headers={'Content-Disposition': f'attachment; filename={filename}'}) - - -@printedparts_bp.route('/reports/stock', methods=['GET']) -@require_permission('printedparts.view') -def report_stock(): - """Stock levels with low-stock flags and the cache-vs-ledger reconcile. - - ledgerdelta should always be 0; anything else means a write path - bypassed the single-commit rule and needs finding. - """ - # int() the sums: MySQL SUM returns Decimal, which JSON-serializes as a - # string and breaks the delta arithmetic's type. - ledger = {itemid: int(total) for itemid, total in - db.session.query( - PrintedItemTransaction.printeditemid, - func.coalesce(func.sum(PrintedItemTransaction.quantitychange), 0)) - .group_by(PrintedItemTransaction.printeditemid).all()} - rows = [] - for item in PrintedItem.query.filter_by(isactive=True).order_by( - PrintedItem.itemname).all(): - rows.append({ - 'itemcode': item.itemcode, - 'itemname': item.itemname, - 'binlocation': item.binlocation or '', - 'quantityonhand': item.quantityonhand, - 'lowstockthreshold': item.lowstockthreshold, - 'islowstock': item.islowstock, - 'ledgerdelta': item.quantityonhand - ledger.get(item.printeditemid, 0), - }) - columns = ['itemcode', 'itemname', 'binlocation', 'quantityonhand', - 'lowstockthreshold', 'islowstock', 'ledgerdelta'] - if request.args.get('format') == 'csv': - return _csv_response(rows, columns, 'printedparts-stock.csv') - return success_response({'columns': columns, 'rows': rows}) - - -@printedparts_bp.route('/reports/consumption', methods=['GET']) -@require_permission('printedparts.view') -def report_consumption(): - """Takes per item, optionally bounded by ?days= (default 30).""" - days = request.args.get('days', 30, type=int) - query = (db.session.query( - PrintedItem.itemcode, - PrintedItem.itemname, - func.count(PrintedItemTransaction.transactionid), - func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0)) - .join(PrintedItemTransaction, - PrintedItemTransaction.printeditemid == PrintedItem.printeditemid) - .filter(PrintedItemTransaction.transactiontype == 'take')) - if days > 0: - from datetime import datetime, timedelta, timezone - cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days) - query = query.filter(PrintedItemTransaction.transactiondate >= cutoff) - query = query.group_by(PrintedItem.itemcode, PrintedItem.itemname) - rows = [{'itemcode': code, 'itemname': name, 'takes': takes, - 'quantitytaken': int(taken)} - for code, name, takes, taken in query.all()] - rows.sort(key=lambda row: row['quantitytaken'], reverse=True) - columns = ['itemcode', 'itemname', 'takes', 'quantitytaken'] - if request.args.get('format') == 'csv': - return _csv_response(rows, columns, 'printedparts-consumption.csv') - return success_response({'columns': columns, 'rows': rows, 'days': days}) - - -@printedparts_bp.route('/reports/by-person', methods=['GET']) -@require_permission('printedparts.view') -def report_by_person(): - """Takes grouped by employee, optionally bounded by ?days= (default 30).""" - days = request.args.get('days', 30, type=int) - query = (db.session.query( - PrintedItemTransaction.employeesso, - func.max(PrintedItemTransaction.employeename), - func.count(PrintedItemTransaction.transactionid), - func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0)) - .filter(PrintedItemTransaction.transactiontype == 'take')) - if days > 0: - from datetime import datetime, timedelta, timezone - cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days) - query = query.filter(PrintedItemTransaction.transactiondate >= cutoff) - query = query.group_by(PrintedItemTransaction.employeesso) - rows = [{'employeesso': sso, 'employeename': name or '', 'takes': takes, - 'quantitytaken': int(taken)} - for sso, name, takes, taken in query.all()] - rows.sort(key=lambda row: row['quantitytaken'], reverse=True) - columns = ['employeesso', 'employeename', 'takes', 'quantitytaken'] - if request.args.get('format') == 'csv': - return _csv_response(rows, columns, 'printedparts-by-person.csv') - return success_response({'columns': columns, 'rows': rows, 'days': days}) - - -# --- print files: append-only revisions per item ------------------------------ - -from flask_jwt_extended import get_jwt_identity -from shopdb.api import send_upload - -from ..models import PrintedItemFile - -FILE_EXTENSIONS = {'.stl', '.3mf', '.gcode', '.gco', '.bgcode', '.step', - '.stp', '.obj', '.amf'} -MAX_FILE_BYTES = 100 * 1024 * 1024 - - -def _filedir(): - return os.path.join(current_app.instance_path, 'printedpartsfiles') - - -def _uploader_name(): - from shopdb.api import User - identity = get_jwt_identity() - try: - user = db.session.get(User, int(identity)) - if user: - return user.username - except (TypeError, ValueError): - pass - return str(identity) - - -@printedparts_bp.route('/items//files', methods=['GET']) -@require_permission('printedparts.view') -def list_item_files(item_id: int): - """Revision history, newest first.""" - files = (PrintedItemFile.query.filter_by(printeditemid=item_id) - .order_by(PrintedItemFile.revision.desc()).all()) - return success_response([f.to_dict() for f in files]) - - -@printedparts_bp.route('/items//files', methods=['POST']) -@require_permission('printedparts.edit') -def upload_item_file(item_id: int): - """Upload the next revision of the item's print file. - - multipart/form-data: file=, note=. - Revisions are append-only; nothing is replaced. - """ - item = db.session.get(PrintedItem, item_id) - if not item: - return error_response(ErrorCodes.NOT_FOUND, - f'Printed item {item_id} not found', http_code=404) - upload = request.files.get('file') - if not upload or not upload.filename: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') - ext = os.path.splitext(upload.filename)[1].lower() - if ext not in FILE_EXTENSIONS: - return error_response( - ErrorCodes.VALIDATION_ERROR, - f'Unsupported file type {ext}; allowed: ' - + ', '.join(sorted(FILE_EXTENSIONS))) - - upload.stream.seek(0, os.SEEK_END) - filesize = upload.stream.tell() - upload.stream.seek(0) - if filesize > MAX_FILE_BYTES: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'File exceeds the 100 MB limit') - - latest = (db.session.query(db.func.max(PrintedItemFile.revision)) - .filter_by(printeditemid=item_id).scalar()) or 0 - revision = latest + 1 - - filedir = _filedir() - os.makedirs(filedir, exist_ok=True) - storedfilename = secure_filename( - f'printeditem-{item_id}-rev{revision}{ext}') - upload.save(os.path.join(filedir, storedfilename)) - - record = PrintedItemFile( - printeditemid=item_id, - revision=revision, - filename=secure_filename(upload.filename), - storedfilename=storedfilename, - filesize=filesize, - uploadnote=(request.form.get('note') or '').strip() or None, - uploadedby=_uploader_name(), - ) - db.session.add(record) - db.session.commit() - return success_response(record.to_dict(), - message=f'Revision {revision} uploaded', - http_code=201) - - -@printedparts_bp.route('/files//download', methods=['GET']) -@require_permission('printedparts.view') -def download_item_file(file_id: int): - """Download a revision under its original filename.""" - record = db.session.get(PrintedItemFile, file_id) - if not record: - return error_response(ErrorCodes.NOT_FOUND, 'File not found', - http_code=404) - return send_upload(_filedir(), record.storedfilename, - as_attachment=True, - download_name=record.filename) - - -@printedparts_bp.route('/files/', methods=['DELETE']) -@require_permission('printedparts.delete') -def delete_item_file(file_id: int): - """Remove a bad revision (wrong file uploaded). History otherwise stays.""" - record = db.session.get(PrintedItemFile, file_id) - if not record: - return error_response(ErrorCodes.NOT_FOUND, 'File not found', - http_code=404) - path = os.path.join(_filedir(), record.storedfilename) - if os.path.exists(path): - os.remove(path) - db.session.delete(record) - db.session.commit() - return success_response(message='Revision removed') diff --git a/plugins/printedparts/frontend-api-snippet.js b/plugins/printedparts/frontend-api-snippet.js deleted file mode 100644 index 7dfebb85..00000000 --- a/plugins/printedparts/frontend-api-snippet.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Printedparts API client snippet. - * - * Paste this printedpartsApi block into frontend/src/api/index.js (next to the - * other per-resource blocks). Then, in the generated PrintedpartsList, PrintedpartsDetail, - * and PrintedpartsForm views, delete the local printedpartsApi const and import the shared - * one instead: - * - * import { printedpartsApi } from '../../api' - * - * The scaffolded views ship with an identical inline client so they build and - * run before you touch the shared api module. This file is NOT auto-merged into - * api/index.js on purpose; that module is hand-maintained and shared. - * - * The create/update/delete calls assume matching POST/PUT/DELETE routes exist - * on the backend. The scaffolded api/routes.py only ships list and get; add the - * write endpoints when you wire up the form. - */ -export const printedpartsApi = { - list(params = {}) { - return api.get('/printedparts', { params }) - }, - get(itemId) { - return api.get(`/printedparts/${itemId}`) - }, - create(data) { - return api.post('/printedparts', data) - }, - update(itemId, data) { - return api.put(`/printedparts/${itemId}`, data) - }, - remove(itemId) { - return api.delete(`/printedparts/${itemId}`) - } -} diff --git a/plugins/printedparts/frontend/routes.js b/plugins/printedparts/frontend/routes.js deleted file mode 100644 index 46c11b31..00000000 --- a/plugins/printedparts/frontend/routes.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Printedparts plugin frontend routes (ADR-013 Phase 4: self-contained plugin - * frontend). Views live beside this file under views/; core imports use the - * @/ alias. The stage-frontend step copies this whole frontend/ dir into the - * Vite tree and aggregates these routes, so a per-site build that omits - * printedparts carries none of this code. - * - * `default` = AppLayout child routes; `toplevel` = full-screen routes. - */ -export default [ - { - path: 'printedparts', - name: 'printedparts', - component: () => import('./views/PrintedItemsList.vue'), - meta: { requiresAuth: true, plugin: 'printedparts' } - }, - { - path: 'printedparts/new', - name: 'printedparts-new', - component: () => import('./views/PrintedItemForm.vue'), - meta: { requiresAuth: true, plugin: 'printedparts' } - }, - { - path: 'printedparts/:id', - name: 'printedparts-detail', - component: () => import('./views/PrintedItemDetail.vue'), - meta: { requiresAuth: true, plugin: 'printedparts' } - }, - { - path: 'printedparts/:id/edit', - name: 'printedparts-edit', - component: () => import('./views/PrintedItemForm.vue'), - meta: { requiresAuth: true, plugin: 'printedparts' } - }, - { - path: 'settings/printedparts', - name: 'settings-printedparts', - component: () => import('./views/PrintedPartsSettings.vue'), - meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' } - } -] - -export const toplevel = [ - { - // Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad. - path: '/parts-kiosk', - name: 'parts-kiosk', - component: () => import('./views/PartsKiosk.vue'), - meta: { plugin: 'printedparts' } - }, - { - // Requires login: lists the whole catalog, printedparts.view-gated at the API. - path: '/print/printedparts-labels', - name: 'print-printedparts-labels', - component: () => import('./views/PrintedPartsLabels.vue'), - meta: { requiresAuth: true, plugin: 'printedparts' } - } -] diff --git a/plugins/printedparts/frontend/views/PartsKiosk.vue b/plugins/printedparts/frontend/views/PartsKiosk.vue deleted file mode 100644 index 98ee9997..00000000 --- a/plugins/printedparts/frontend/views/PartsKiosk.vue +++ /dev/null @@ -1,394 +0,0 @@ - - - - - diff --git a/plugins/printedparts/frontend/views/PrintedItemDetail.vue b/plugins/printedparts/frontend/views/PrintedItemDetail.vue deleted file mode 100644 index e7fcbdc2..00000000 --- a/plugins/printedparts/frontend/views/PrintedItemDetail.vue +++ /dev/null @@ -1,379 +0,0 @@ - - - - - diff --git a/plugins/printedparts/frontend/views/PrintedItemForm.vue b/plugins/printedparts/frontend/views/PrintedItemForm.vue deleted file mode 100644 index 8d76fa17..00000000 --- a/plugins/printedparts/frontend/views/PrintedItemForm.vue +++ /dev/null @@ -1,175 +0,0 @@ - - - - - diff --git a/plugins/printedparts/frontend/views/PrintedItemsList.vue b/plugins/printedparts/frontend/views/PrintedItemsList.vue deleted file mode 100644 index 98a08c9f..00000000 --- a/plugins/printedparts/frontend/views/PrintedItemsList.vue +++ /dev/null @@ -1,169 +0,0 @@ - - - - - diff --git a/plugins/printedparts/frontend/views/PrintedPartsLabels.vue b/plugins/printedparts/frontend/views/PrintedPartsLabels.vue deleted file mode 100644 index 88f74310..00000000 --- a/plugins/printedparts/frontend/views/PrintedPartsLabels.vue +++ /dev/null @@ -1,214 +0,0 @@ - - - - - - - diff --git a/plugins/printedparts/frontend/views/PrintedPartsSettings.vue b/plugins/printedparts/frontend/views/PrintedPartsSettings.vue deleted file mode 100644 index 270f86ae..00000000 --- a/plugins/printedparts/frontend/views/PrintedPartsSettings.vue +++ /dev/null @@ -1,268 +0,0 @@ - - - - - diff --git a/plugins/printedparts/manifest.json b/plugins/printedparts/manifest.json deleted file mode 100644 index 1343ef2f..00000000 --- a/plugins/printedparts/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "printedparts", - "version": "0.1.0", - "description": "3D-printed parts inventory + kiosk checkout", - "display_name": "3D Printed Parts", - "author": "", - "dependencies": ["employees"], - "core_version": ">=0.16.0,<1.0.0", - "api_prefix": "/api/printedparts", - "default_enabled": false -} diff --git a/plugins/printedparts/migrations/env.py b/plugins/printedparts/migrations/env.py deleted file mode 100644 index 1490d7a5..00000000 --- a/plugins/printedparts/migrations/env.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Alembic environment for the printedparts plugin migration chain. - -Delegates to the shared runner in shopdb.plugins.alembic_template, which -filters the metadata to this plugin's tables and drives Alembic against the -per-plugin version table alembic_version_printedparts (ADR-008). This plugin -is NEW (post-cutover): its 0001 baseline really CREATES its tables. -""" -import os - -os.environ['PLUGIN_NAME'] = 'printedparts' - -from shopdb.plugins.alembic_template import run_migrations # noqa: E402 - -run_migrations() diff --git a/plugins/printedparts/migrations/script.py.mako b/plugins/printedparts/migrations/script.py.mako deleted file mode 100644 index 2c015630..00000000 --- a/plugins/printedparts/migrations/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} diff --git a/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py b/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py deleted file mode 100644 index 3d16b508..00000000 --- a/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py +++ /dev/null @@ -1,76 +0,0 @@ -"""printedparts plugin baseline (real create). - -Post-ADR-008 plugin: this per-plugin chain is the sole authoritative creator -of printeditems and printeditemtransactions - the core chain never knew them. -Runs from `flask plugin install printedparts` (and `flask plugin upgrade-all`) -after `flask db upgrade` builds the core schema. - -Both tables are self-contained (the only FK is transactions -> items inside -the plugin), so the shared create_plugin_tables helper would work here; the -ops are written out explicitly anyway to match the measuringtools exemplar -and keep the baseline reviewable. -""" -from alembic import op - -from shopdb.plugins.alembic_template import ( - create_index_if_missing, - create_table_if_missing, -) -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'printedparts0001baseline' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - create_table_if_missing( - 'printeditems', - sa.Column('printeditemid', sa.Integer(), nullable=False), - sa.Column('itemcode', sa.String(length=20), nullable=True), - sa.Column('itemname', sa.String(length=120), nullable=False), - sa.Column('itemdescription', sa.String(length=500), nullable=True), - sa.Column('imageurl', sa.String(length=255), nullable=True), - sa.Column('quantityonhand', sa.Integer(), nullable=False), - sa.Column('lowstockthreshold', sa.Integer(), nullable=False), - sa.Column('binlocation', sa.String(length=100), nullable=True), - sa.Column('printnotes', sa.Text(), nullable=True), - sa.Column('createddate', sa.DateTime(), nullable=False), - sa.Column('modifieddate', sa.DateTime(), nullable=False), - sa.Column('isactive', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('printeditemid'), - sa.UniqueConstraint('itemcode'), - ) - create_index_if_missing('ix_printeditems_itemcode', 'printeditems', ['itemcode']) - - create_table_if_missing( - 'printeditemtransactions', - sa.Column('transactionid', sa.Integer(), nullable=False), - sa.Column('printeditemid', sa.Integer(), nullable=False), - sa.Column('transactiontype', sa.String(length=10), nullable=False), - sa.Column('quantitychange', sa.Integer(), nullable=False), - sa.Column('employeesso', sa.String(length=20), nullable=False), - sa.Column('employeename', sa.String(length=120), nullable=True), - sa.Column('reason', sa.String(length=255), nullable=True), - sa.Column('transactiondate', sa.DateTime(), nullable=False), - sa.Column('createddate', sa.DateTime(), nullable=False), - sa.Column('modifieddate', sa.DateTime(), nullable=False), - sa.Column('isactive', sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint(['printeditemid'], ['printeditems.printeditemid'], - ondelete='CASCADE'), - sa.PrimaryKeyConstraint('transactionid'), - ) - create_index_if_missing('ix_printeditemtransactions_printeditemid', - 'printeditemtransactions', ['printeditemid']) - create_index_if_missing('ix_printeditemtransactions_employeesso', - 'printeditemtransactions', ['employeesso']) - create_index_if_missing('ix_printeditemtransactions_transactiondate', - 'printeditemtransactions', ['transactiondate']) - - -def downgrade(): - op.drop_table('printeditemtransactions') - op.drop_table('printeditems') diff --git a/plugins/printedparts/migrations/versions/0002_printeditemfiles.py b/plugins/printedparts/migrations/versions/0002_printeditemfiles.py deleted file mode 100644 index 0cfd8553..00000000 --- a/plugins/printedparts/migrations/versions/0002_printeditemfiles.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Add printeditemfiles: append-only print-file revisions per item. - -The plugin's first incremental migration on top of its 0001 baseline - -the ADR-008 payoff: the plugin evolves its own schema without touching -the core chain. Applied by `flask plugin upgrade-all`. -""" -from alembic import op - -from shopdb.plugins.alembic_template import ( - create_index_if_missing, - create_table_if_missing, -) -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'printedparts0002files' -down_revision = 'printedparts0001baseline' -branch_labels = None -depends_on = None - - -def upgrade(): - create_table_if_missing( - 'printeditemfiles', - sa.Column('fileid', sa.Integer(), nullable=False), - sa.Column('printeditemid', sa.Integer(), nullable=False), - sa.Column('revision', sa.Integer(), nullable=False), - sa.Column('filename', sa.String(length=255), nullable=False), - sa.Column('storedfilename', sa.String(length=191), nullable=False), - sa.Column('filesize', sa.Integer(), nullable=False), - sa.Column('uploadnote', sa.String(length=255), nullable=True), - sa.Column('uploadedby', sa.String(length=80), nullable=False), - sa.Column('createddate', sa.DateTime(), nullable=False), - sa.Column('modifieddate', sa.DateTime(), nullable=False), - sa.Column('isactive', sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint(['printeditemid'], - ['printeditems.printeditemid'], - ondelete='CASCADE'), - sa.PrimaryKeyConstraint('fileid'), - sa.UniqueConstraint('storedfilename'), - ) - create_index_if_missing('ix_printeditemfiles_printeditemid', - 'printeditemfiles', ['printeditemid']) - - -def downgrade(): - op.drop_table('printeditemfiles') diff --git a/plugins/printedparts/migrations/versions/0003_gagelabtag.py b/plugins/printedparts/migrations/versions/0003_gagelabtag.py deleted file mode 100644 index 8271ff74..00000000 --- a/plugins/printedparts/migrations/versions/0003_gagelabtag.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Add printeditems.gagelabtag: the gage-lab assigned asset tag. - -The gage lab issues WJRP asset numbers for printed parts; the internal -itemcode stays auto-generated, and this optional unique tag carries the -lab's number. The kiosk resolves scans/typed digits against both. - -Guarded/idempotent: skips when the table is absent (plugin disabled) or the -column already exists (e.g. a test DB built by db.create_all() from the model). -Unguarded, this failed `flask plugin upgrade-all` on any create-all-built -database and took 0004 down with it, since a chain stops at its first error. - -The index is matched by COLUMN SET, not by name: create_all names indexes by -SQLAlchemy's convention and this migration by the repo's, so a name-keyed guard -would add a second index over the same column rather than skip. -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'printedparts0003gagetag' -down_revision = 'printedparts0002files' -branch_labels = None -depends_on = None - - -def _indexed(inspector): - return any(tuple(ix.get('column_names') or ()) == ('gagelabtag',) - for ix in inspector.get_indexes('printeditems')) - - -def upgrade(): - inspector = sa.inspect(op.get_bind()) - if 'printeditems' not in inspector.get_table_names(): - return - columns = {column['name'] for column in inspector.get_columns('printeditems')} - if 'gagelabtag' not in columns: - op.add_column('printeditems', - sa.Column('gagelabtag', sa.String(length=50), nullable=True)) - inspector = sa.inspect(op.get_bind()) - if not _indexed(inspector): - op.create_index('ix_printeditems_gagelabtag', 'printeditems', - ['gagelabtag'], unique=True) - - -def downgrade(): - inspector = sa.inspect(op.get_bind()) - if 'printeditems' not in inspector.get_table_names(): - return - if _indexed(inspector): - op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems') - columns = {column['name'] for column in inspector.get_columns('printeditems')} - if 'gagelabtag' in columns: - op.drop_column('printeditems', 'gagelabtag') diff --git a/plugins/printedparts/migrations/versions/0004_transaction_revision.py b/plugins/printedparts/migrations/versions/0004_transaction_revision.py deleted file mode 100644 index d0784e7f..00000000 --- a/plugins/printedparts/migrations/versions/0004_transaction_revision.py +++ /dev/null @@ -1,43 +0,0 @@ -"""printedparts: printeditemtransactions.revision (traceability) - -Records which print-file revision the physical part carried when it was taken, -captured from the label QR (gage tag + revision). Nullable - manual entry and -pre-QR labels have no revision. Idempotent guard. - -Revision ID: printedparts0004txnrev -Revises: printedparts0003gagetag -Create Date: 2026-07-21 - -""" -from alembic import op -import sqlalchemy as sa - - -revision = 'printedparts0004txnrev' -down_revision = 'printedparts0003gagetag' -branch_labels = None -depends_on = None - - -def _has_col(insp): - return any(c['name'] == 'revision' - for c in insp.get_columns('printeditemtransactions')) - - -def upgrade(): - bind = op.get_bind() - insp = sa.inspect(bind) - if 'printeditemtransactions' not in insp.get_table_names(): - return - if not _has_col(insp): - op.add_column('printeditemtransactions', - sa.Column('revision', sa.Integer(), nullable=True)) - - -def downgrade(): - bind = op.get_bind() - insp = sa.inspect(bind) - if 'printeditemtransactions' not in insp.get_table_names(): - return - if _has_col(insp): - op.drop_column('printeditemtransactions', 'revision') diff --git a/plugins/printedparts/models/__init__.py b/plugins/printedparts/models/__init__.py deleted file mode 100644 index b39cb082..00000000 --- a/plugins/printedparts/models/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Printedparts plugin models.""" - -from .printeditem import ( - PrintedItem, - PrintedItemTransaction, - PrintedItemFile, - TRANSACTION_TYPES, -) - -__all__ = ['PrintedItem', 'PrintedItemTransaction', 'PrintedItemFile', - 'TRANSACTION_TYPES'] diff --git a/plugins/printedparts/models/printeditem.py b/plugins/printedparts/models/printeditem.py deleted file mode 100644 index 0986bd6e..00000000 --- a/plugins/printedparts/models/printeditem.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Printedparts models. - -PrintedItem is a KIND of 3D-printed part with a quantity on hand - a -consumable, not an ADR-001 asset (which is one row per physical thing). -PrintedItemTransaction is the ledger: every take, restock, and adjust as a -signed quantity change attributed to a badge-resolved employee. The ledger is -the source of truth; quantityonhand is a cache moved in the same commit as -each ledger write, and the stock report reconciles the two. -""" - -from datetime import datetime, timezone - -from shopdb.api import db, BaseModel - - -def _utcnow(): - return datetime.now(timezone.utc).replace(tzinfo=None) - - -TRANSACTION_TYPES = ('take', 'restock', 'adjust') - - -class PrintedItem(BaseModel): - """A printable part the engineers stock in bins.""" - - __tablename__ = 'printeditems' - - printeditemid = db.Column(db.Integer, primary_key=True) - itemcode = db.Column(db.String(20), unique=True, index=True, - comment='Generated bin-label code, e.g. 3DP-0042') - gagelabtag = db.Column(db.String(50), unique=True, index=True, - comment='Gage-lab assigned asset tag, e.g. WJRP0117') - itemname = db.Column(db.String(120), nullable=False) - itemdescription = db.Column(db.String(500)) - imageurl = db.Column(db.String(255)) - quantityonhand = db.Column(db.Integer, nullable=False, default=0) - lowstockthreshold = db.Column(db.Integer, nullable=False, default=5) - binlocation = db.Column(db.String(100)) - printnotes = db.Column(db.Text, comment='Material, print time, slicer file') - - transactions = db.relationship( - 'PrintedItemTransaction', backref='printeditem', - cascade='all, delete-orphan', passive_deletes=True, lazy='dynamic') - - @property - def islowstock(self): - return self.quantityonhand <= self.lowstockthreshold - - @property - def latestrevision(self): - """Highest print-file revision, or None when the item has no files. - Stamped on the label QR so the physical part carries its source rev.""" - from sqlalchemy import func - return db.session.query(func.max(PrintedItemFile.revision)).filter( - PrintedItemFile.printeditemid == self.printeditemid).scalar() - - def to_dict(self): - return { - 'printeditemid': self.printeditemid, - 'itemcode': self.itemcode, - 'gagelabtag': self.gagelabtag, - 'latestrevision': self.latestrevision, - 'itemname': self.itemname, - 'itemdescription': self.itemdescription, - 'imageurl': self.imageurl, - 'quantityonhand': self.quantityonhand, - 'lowstockthreshold': self.lowstockthreshold, - 'islowstock': self.islowstock, - 'binlocation': self.binlocation, - 'printnotes': self.printnotes, - 'isactive': self.isactive, - 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None, - 'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None, - } - - -class PrintedItemTransaction(BaseModel): - """One signed stock movement, always attributed to an employee.""" - - __tablename__ = 'printeditemtransactions' - - transactionid = db.Column(db.Integer, primary_key=True) - printeditemid = db.Column( - db.Integer, - db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'), - nullable=False, index=True) - transactiontype = db.Column(db.String(10), nullable=False, - comment='take, restock, or adjust') - quantitychange = db.Column(db.Integer, nullable=False, - comment='Negative for take, signed for adjust') - employeesso = db.Column(db.String(20), nullable=False, index=True) - employeename = db.Column(db.String(120)) - reason = db.Column(db.String(255)) - # Print-file revision the physical part carried, captured from the label QR - # at take time (null for manual entry / pre-QR labels). Traceability of - # which revision was actually consumed. - revision = db.Column(db.Integer) - transactiondate = db.Column(db.DateTime, nullable=False, default=_utcnow, - index=True) - - def to_dict(self): - return { - 'transactionid': self.transactionid, - 'printeditemid': self.printeditemid, - 'transactiontype': self.transactiontype, - 'quantitychange': self.quantitychange, - 'employeesso': self.employeesso, - 'employeename': self.employeename, - 'reason': self.reason, - 'revision': self.revision, - 'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None, - } - - -class PrintedItemFile(BaseModel): - """One uploaded revision of an item's print file (STL/3MF/gcode/...). - - Revisions are append-only per item: uploading assigns the next revision - number and never replaces earlier files, so the history of what was - actually printed stays reconstructible. The current file is simply the - highest revision. - """ - - __tablename__ = 'printeditemfiles' - - fileid = db.Column(db.Integer, primary_key=True) - printeditemid = db.Column( - db.Integer, - db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'), - nullable=False, index=True) - revision = db.Column(db.Integer, nullable=False) - filename = db.Column(db.String(255), nullable=False, - comment='Original upload name, used for download') - storedfilename = db.Column(db.String(191), nullable=False, unique=True, - comment='191: unique index fits the 767-byte MySQL prefix') - filesize = db.Column(db.Integer, nullable=False) - uploadnote = db.Column(db.String(255), - comment='What changed in this revision') - uploadedby = db.Column(db.String(80), nullable=False, - comment='Username of the uploader') - - def to_dict(self): - return { - 'fileid': self.fileid, - 'printeditemid': self.printeditemid, - 'revision': self.revision, - 'filename': self.filename, - 'filesize': self.filesize, - 'uploadnote': self.uploadnote, - 'uploadedby': self.uploadedby, - 'uploadeddate': self.createddate.isoformat() + 'Z' if self.createddate else None, - } diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py deleted file mode 100644 index 9d788bb0..00000000 --- a/plugins/printedparts/plugin.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Printedparts plugin main class. - -3D-printed parts inventory + kiosk checkout. Quantity-based consumables: -one row is a KIND of part with a count, not an individually tracked asset, -so unlike most plugins this one seeds NO AssetType (ADR-001 assets are -one-row-per-physical-thing). See docs/proposals/printedparts-plugin.md. -""" - -import json -import logging -from pathlib import Path -from typing import List, Optional, Type - -from flask import Flask, Blueprint - -from shopdb.plugins.base import BasePlugin, PluginMeta - -from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile -from .api import printedparts_bp - -logger = logging.getLogger(__name__) - - -class PrintedpartsPlugin(BasePlugin): - """3D-printed parts inventory + kiosk checkout.""" - - def __init__(self): - manifest_path = Path(__file__).parent / 'manifest.json' - with open(manifest_path) as f: - self._manifest = json.load(f) - - @property - def meta(self) -> PluginMeta: - return PluginMeta( - name=self._manifest['name'], - version=self._manifest['version'], - description=self._manifest['description'], - author=self._manifest.get('author', ''), - dependencies=self._manifest.get('dependencies', []), - core_version=self._manifest.get('core_version', '>=0.1.0'), - api_prefix=self._manifest.get('api_prefix'), - ) - - def get_blueprint(self) -> Optional[Blueprint]: - return printedparts_bp - - def get_models(self) -> List[Type]: - return [PrintedItem, PrintedItemTransaction, PrintedItemFile] - - def init_app(self, app: Flask, db_instance) -> None: - logger.info(f'Printedparts plugin initialized (v{self.meta.version})') - - def get_permissions(self) -> List: - """RBAC permissions this plugin owns (seeded on install/enable).""" - return [ - ('printedparts.view', 'View 3D printed parts', 'printedparts'), - ('printedparts.create', 'Create printed parts', 'printedparts'), - ('printedparts.edit', 'Edit printed parts', 'printedparts'), - ('printedparts.delete', 'Retire printed parts', 'printedparts'), - ('printedparts.restock', 'Restock and adjust stock counts', - 'printedparts'), - ] - - def get_settings_cards(self) -> List[dict]: - return [ - { - 'group': '3D Printed Parts', - 'to': '/settings/printedparts', - 'icon': 'box', - 'title': '3D Parts Settings', - 'description': 'Item code prefix, default threshold, kiosk ' - 'badge policy, low-stock alert recipients', - 'position': 47, - }, - ] - - def get_reports(self) -> List[dict]: - return [ - { - 'id': 'printedparts-stock', - 'name': '3D Parts Stock', - 'description': 'Stock levels with low-stock flags and the ' - 'cache-vs-ledger reconcile check', - 'category': 'inventory', - 'endpoint': '/api/printedparts/reports/stock', - }, - { - 'id': 'printedparts-consumption', - 'name': '3D Parts Consumption', - 'description': 'Takes per item over a date range', - 'category': 'usage', - 'endpoint': '/api/printedparts/reports/consumption', - }, - { - 'id': 'printedparts-by-person', - 'name': '3D Parts by Person', - 'description': 'Takes grouped by employee', - 'category': 'usage', - 'endpoint': '/api/printedparts/reports/by-person', - }, - ] - - def get_navigation_items(self) -> List[dict]: - return [ - { - 'name': '3D Parts', - 'icon': 'box', - 'route': '/printedparts', - 'position': 27, - # Named so the nav can hide it from anybody - a signed-out - # visitor included - who does not hold it. - 'permission': 'printedparts.view', - 'section': 'assets', - }, - ] - - def on_install(self, app: Flask) -> None: - logger.info('Printedparts plugin installed') - - def get_settings_defaults(self) -> List[dict]: - # The framework seeds these at install, at enable, and on every - # `flask plugin upgrade-all`, so a key added in a later version reaches - # a site that installed an earlier one. - return [ - { - 'key': 'printedparts_code_prefix', - 'value': '3DP', - 'valuetype': 'string', - 'category': 'printedparts', - 'description': 'Prefix for generated item codes', - }, - { - 'key': 'printedparts_label_prefix', - 'value': '', - 'valuetype': 'string', - 'category': 'printedparts', - # Public: the parts kiosk runs logged out, so the anonymous - # read has to be allowed or the prefix never renders there. - 'public': True, - 'description': 'Leading text on the physical gage-lab labels, ' - 'shown at the kiosk before the number box so ' - 'operators type only the digits. Empty shows no ' - 'prefix. Site-specific: set it to whatever this ' - "site's labels are printed with, e.g. WJ", - }, - { - 'key': 'printedparts_default_threshold', - 'value': '5', - 'valuetype': 'integer', - 'category': 'printedparts', - 'description': 'Default low-stock threshold for new items', - }, - { - 'key': 'printedparts_kiosk_enabled', - 'value': 'false', - 'valuetype': 'boolean', - 'category': 'printedparts', - 'description': 'Serve the parts kiosk routes. Off by default: ' - 'they are the only unauthenticated write in the ' - 'product, so a site opts in', - }, - { - 'key': 'printedparts_kiosk_token', - 'value': '', - # string, not 'secret': the column documents string/boolean/ - # integer/json only. Masking is keyed on the NAME containing - # 'token' (settings.py::_is_secret), so this is still hidden in - # the API and the audit log. - 'valuetype': 'string', - 'category': 'printedparts', - 'description': 'Device token the kiosk sends as X-Kiosk-Token. ' - 'Generate one, then open the kiosk once as ' - '/parts-kiosk?token= on each screen', - }, - { - 'key': 'printedparts_unknown_badge', - 'value': 'deny', - 'valuetype': 'string', - 'category': 'printedparts', - 'description': 'Kiosk policy when a badge resolves to no ' - 'employee: allow or deny', - }, - { - 'key': 'printedparts_alert_email', - 'value': '', - 'valuetype': 'string', - 'category': 'printedparts', - 'description': 'Comma-separated low-stock alert recipients; ' - 'empty uses the site alert_recipients', - }, - { - 'key': 'printedparts_alert_userids', - 'value': '', - 'valuetype': 'string', - 'category': 'printedparts', - 'description': 'Comma-separated shopdb user ids whose account ' - 'emails receive low-stock alerts', - }, - { - 'key': 'printedparts_alert_roleids', - 'value': '', - 'valuetype': 'string', - 'category': 'printedparts', - 'description': 'Comma-separated role ids; every active member ' - 'of these roles receives low-stock alerts', - }, - { - 'key': 'printedparts_alert_supportteamid', - 'value': '', - 'valuetype': 'string', - 'category': 'printedparts', - 'description': 'Support team whose webhook receives low-stock ' - 'alerts; empty uses the site alert_webhook_url', - }, - ] diff --git a/plugins/printedparts/schemas/__init__.py b/plugins/printedparts/schemas/__init__.py deleted file mode 100644 index 656980f5..00000000 --- a/plugins/printedparts/schemas/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Printedparts plugin schemas (marshmallow). - -Add schema classes here when you need request/response validation -beyond the simple to_dict() output. The framework wires marshmallow -into the response helpers; see the PLUGIN-HOOKS wiki page for details. -""" diff --git a/plugins/printedparts/services/__init__.py b/plugins/printedparts/services/__init__.py deleted file mode 100644 index 21758a61..00000000 --- a/plugins/printedparts/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Printedparts plugin services.""" diff --git a/plugins/printedparts/services/badges.py b/plugins/printedparts/services/badges.py deleted file mode 100644 index 4da5f53f..00000000 --- a/plugins/printedparts/services/badges.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Badge resolution for the printedparts plugin. - -Same input contract as the USB plugin (deliberately copied, not imported - -cross-plugin imports break the shopdb.api-only contract): - -- all digits -> an SSO typed or scanned directly -- 0BZ -> a physical badge wrapping a PayNo (keyboard-wedge - scanners emit this shape) -- anything else -> unresolvable - -Name lookup honors the site's employee directory mode (the same setting the -employees/usb plugins use): - -- selfhosted: the employees plugin's directoryemployees table, by SSO. The - table has no PayNo column, so a PayNo badge only resolves when the wrapped - digits are themselves the SSO. -- external: the HR directory via employee_connection() - SSO badges by SSO, - PayNo badges by PayNo (which also recovers the real SSO to record). - -Policy (Setting printedparts_unknown_badge): 'deny' (default) rejects a badge -with no directory match; 'allow' records the SSO with an empty name. -""" - -import logging -import re - -from shopdb.api import Setting, employee_connection - -logger = logging.getLogger(__name__) - -_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE) - - -class BadgeError(ValueError): - """Raised when a badge cannot be accepted under the site policy.""" - - -def _parse_badge(badge): - """Return ('sso'|'payno', digits) or raise BadgeError on unknown shape. - - The shapes themselves live in shopdb.api (normalise_badge), because the USB - kiosk reads the same physical badges and used to disagree: it took the - string raw, so the PayNo wrapper resolved to nobody and an operator whose - scanner emits it could take a part and not check out a drive. - - That also closed a subtler hole. This used str.isdigit(), which is true for - Arabic-Indic and other decimal forms, so a badge written in them was - accepted, resolved against int() to a REAL employee, and then stored as an - SSO string that joins to nothing. - """ - from shopdb.api import BadgeShapeError, badge_kind, normalise_badge - - try: - return badge_kind(badge), normalise_badge(badge) - except BadgeShapeError as bad: - raise BadgeError(str(bad)) - - -def _selfhosted_lookup(digits): - """(sso, name) from the employees plugin directory, or None.""" - try: - from plugins.employees.models import DirectoryEmployee - from shopdb.api import db - employee = db.session.get(DirectoryEmployee, int(digits)) - if employee: - return digits, f'{employee.firstname} {employee.lastname}'.strip() - except Exception: - logger.exception('Self-hosted directory lookup failed for %s', digits) - return None - - -def _external_lookup(kind, digits): - """(sso, name) from the HR directory, or None. PayNo badges resolve to - the employee's real SSO.""" - try: - conn = employee_connection() - except Exception: - logger.exception('HR directory connection failed') - return None - try: - with conn.cursor() as cursor: - column = 'SSO' if kind == 'sso' else 'PayNo' - cursor.execute( - f'SELECT SSO, First_Name, Last_Name FROM employees ' - f'WHERE {column} = %s', (digits,)) - row = cursor.fetchone() - if row: - sso = str(row[0] if not isinstance(row, dict) else row['SSO']) - first = row[1] if not isinstance(row, dict) else row['First_Name'] - last = row[2] if not isinstance(row, dict) else row['Last_Name'] - return sso, f'{(first or "").strip()} {(last or "").strip()}'.strip() - except Exception: - logger.exception('HR directory lookup failed for %s %s', kind, digits) - finally: - try: - conn.close() - except Exception: - pass - return None - - -def resolve_badge(badge): - """Return (sso, name) for a scanned badge, enforcing the site policy. - - Raises BadgeError with a kiosk-displayable message when the badge shape is - unrecognized or the policy denies an unmatched badge. - """ - kind, digits = _parse_badge(badge) - - mode = (Setting.get('employee_directory_mode') or 'selfhosted').lower() - if mode == 'external': - resolved = _external_lookup(kind, digits) - else: - resolved = _selfhosted_lookup(digits) - - if resolved is None: - policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower() - if policy != 'allow': - raise BadgeError('Badge not recognized - see the parts team') - return digits, '' - return resolved diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index e626b95e..e2bed15a 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -1123,12 +1123,18 @@ def set_asset_printer_assignment(asset_id: int): ', '.join(str(assetid) for assetid in sorted(notprinters)))) typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER) - if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]: + missingtypes = [name for name in (_USES_PRINTER, _DEFAULT_PRINTER) + if not typeids[name]] + if missingtypes: # Seed data, not a migration. An un-seeded database cannot hold an - # assignment, and saying so beats writing rows nothing can read. + # assignment, and saying so beats writing rows nothing can read. Name + # the types that are actually missing: a partially seeded table is the + # common case, and "types are not seeded" sends someone looking for an + # empty table they will not find. return error_response( ErrorCodes.INTERNAL_ERROR, - 'Relationship types are not seeded - run: flask seed reference-data', + 'Relationship type(s) not seeded: {0} - run: flask seed ' + 'reference-data'.format(', '.join(missingtypes)), http_code=500) _reconcile_edges(asset_id, typeids[_USES_PRINTER][0], @@ -1784,10 +1790,13 @@ def seed_assignment_from_observed(asset_id: int): details={'hostname': hostname, 'skipped': candidate['skipped']}) typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER, _CONTROLS) - if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]: + missingtypes = [name for name in (_USES_PRINTER, _DEFAULT_PRINTER) + if not typeids[name]] + if missingtypes: return error_response( ErrorCodes.INTERNAL_ERROR, - 'Relationship types are not seeded - run: flask seed reference-data', + 'Relationship type(s) not seeded: {0} - run: flask seed ' + 'reference-data'.format(', '.join(missingtypes)), http_code=500) warnings = [] diff --git a/plugins/printers/client/Report-PrintersToShopDB.ps1 b/plugins/printers/client/Report-PrintersToShopDB.ps1 index 3e7b16ac..b4ab24dc 100644 --- a/plugins/printers/client/Report-PrintersToShopDB.ps1 +++ b/plugins/printers/client/Report-PrintersToShopDB.ps1 @@ -30,11 +30,19 @@ # lost its printers. # # AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only -# covers the geenforce fetch/report endpoints). It needs a collector.ingest key, -# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB -# CollectorKey (the same secret store Report-AssetToShopDB.ps1 uses; provisioned -# at imaging), or overridden via the manifest entry's Args -ApiKey. Never bake -# the key into the manifest JSON on the share. +# covers the geenforce fetch/report endpoints). Once the endpoint agent (1.20+) +# has enrolled this PC, its per-PC credential is read from the agent's +# SYSTEM-only credential.bin and sent as X-Endpoint-Credential; the server then +# refuses the shared key for this PC (401 SHARED_KEY_REFUSED). Before that, a +# collector.ingest key is sent as the X-API-Key header, read from +# HKLM:\SOFTWARE\GE\ShopDB CollectorKey (the same secret store +# Report-AssetToShopDB.ps1 uses; provisioned at imaging), or overridden via the +# manifest entry's Args -ApiKey. Never bake the key into the manifest JSON on +# the share. +# +# PINS: when HKLM:\SOFTWARE\GE\ShopDB ServerPins is set, the server's TLS key +# must match one of them or nothing is sent. See +# plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md. # # Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under # the SYSTEM task. Always exits 0 so a printer problem never fails an @@ -77,6 +85,271 @@ function Log([string]$msg) { "$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null } +# --------------------------------------------------------------------------- +# Per-PC credential and server pin. Kept in step with the same block in +# Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and +# upgrade-shopdbagent.ps1: each runs standalone, so the text is duplicated. +# +# Credential: the endpoint agent (1.20+) enrolls and stores its own credential +# in credential.bin, DPAPI LocalMachine, readable only by SYSTEM and +# Administrators. When present it is sent as X-Endpoint-Credential and the +# shared key is not sent. Without it the shared CollectorKey goes as X-API-Key. +# +# Pins: HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ), lowercase hex +# SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. When any pin +# is set the TLS handshake is accepted only if the leaf key matches one. +# --------------------------------------------------------------------------- + +$ShopDbCredentialFile = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\credential\credential.bin' + +$ShopDbPinSource = @' +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +public static class ShopDbServerPinValidator +{ + private static readonly HashSet Pins = new HashSet(StringComparer.OrdinalIgnoreCase); + public static string LastPresented; + + public static void Install(string[] pins) + { + Pins.Clear(); + foreach (string pin in pins) + { + if (!String.IsNullOrEmpty(pin)) { Pins.Add(pin); } + } + ServicePointManager.ServerCertificateValidationCallback = Validate; + } + + public static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors) + { + if (Pins.Count == 0) { return errors == SslPolicyErrors.None; } + if (certificate == null) { return false; } + try + { + LastPresented = SpkiSha256Hex(certificate.GetRawCertData()); + } + catch (Exception) + { + LastPresented = null; + return false; + } + return Pins.Contains(LastPresented); + } + + public static bool IsPinned(string pin) + { + return !String.IsNullOrEmpty(pin) && Pins.Contains(pin); + } + + // Walks the DER: Certificate > TBSCertificate > [0] version (optional), + // serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo. + public static string SpkiSha256Hex(byte[] der) + { + int length; + int position = EnterSequence(der, 0, out length); + position = EnterSequence(der, position, out length); + if (der[position] == 0xA0) { position = Skip(der, position); } + for (int field = 0; field < 5; field++) { position = Skip(der, position); } + if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); } + int end = Skip(der, position); + using (SHA256 sha = SHA256.Create()) + { + byte[] hash = sha.ComputeHash(der, position, end - position); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + } + + private static int ReadLength(byte[] der, int position, out int length) + { + int first = der[position]; + if (first < 0x80) { length = first; return position + 1; } + int count = first & 0x7F; + if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); } + length = 0; + for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; } + return position + 1 + count; + } + + private static int EnterSequence(byte[] der, int position, out int length) + { + if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); } + return ReadLength(der, position + 1, out length); + } + + private static int Skip(byte[] der, int position) + { + int length; + int content = ReadLength(der, position + 1, out length); + return content + length; + } +} +'@ + +function Get-ShopDbServerPins { + # Both registry views, like CollectorKey. Anything not 64 hex chars is dropped. + $pins = @() + foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) { + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view) + try { + $key = $base.OpenSubKey('SOFTWARE\GE\ShopDB') + if ($key) { + try { + foreach ($value in @($key.GetValue('ServerPins'))) { + if ($null -eq $value) { continue } + $pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant() + if ($pin -match '^[0-9a-f]{64}$' -and $pins -notcontains $pin) { $pins += $pin } + } + } finally { $key.Dispose() } + } + } finally { $base.Dispose() } + } catch {} + } + return ,$pins +} + +function Test-ShopDbServerPinsValue { + # True when ServerPins exists in either registry view, even empty. The + # registry then owns the pin list and appsettings pins are ignored, the + # same rule the agent follows. + foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) { + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view) + try { + $key = $base.OpenSubKey('SOFTWARE\GE\ShopDB') + if ($key) { + try { + if ($key.GetValueNames() -contains 'ServerPins') { return $true } + } finally { $key.Dispose() } + } + } finally { $base.Dispose() } + } catch {} + } + return $false +} + +function Enable-ShopDbServerPins([string[]]$Pins) { + # True when pinning is active. False means pins are set but could not be + # enforced; the caller must not send anything then. + if ($PSVersionTable.PSEdition -eq 'Core') { + # PowerShell 7 web cmdlets ignore ServicePointManager, so the check + # would silently not happen. + Log 'ERROR ServerPins is set; pin checking needs Windows PowerShell 5.1 (powershell.exe), not pwsh' + return $false + } + try { + if (-not ('ShopDbServerPinValidator' -as [type])) { + Add-Type -TypeDefinition $ShopDbPinSource -ErrorAction Stop + } + [ShopDbServerPinValidator]::Install($Pins) + return $true + } catch { + Log "ERROR ServerPins is set but pin checking could not be enabled: $($_.Exception.Message)" + return $false + } +} + +function Get-ShopDbEndpointCredential([string]$TargetUrl) { + # Empty when there is none or it cannot be used for this URL. + if (-not (Test-Path -LiteralPath $ShopDbCredentialFile)) { return '' } + try { + Add-Type -AssemblyName System.Security -ErrorAction Stop + $protected = [System.IO.File]::ReadAllBytes($ShopDbCredentialFile) + $plain = [System.Security.Cryptography.ProtectedData]::Unprotect( + $protected, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine) + $record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json + } catch { + Log "WARN could not read the endpoint credential ($ShopDbCredentialFile): $($_.Exception.Message)" + return '' + } + $credential = [string]$record.credential + if (-not $credential.StartsWith('sdbe_')) { + Log 'WARN endpoint credential file has no usable credential; ignoring it' + return '' + } + if (-not $TargetUrl.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) { + Log "WARN not sending the endpoint credential over a non-https URL ($TargetUrl)" + return '' + } + # Never hand a credential to a server other than the one that issued it. + $issuer = ([string]$record.baseurl).Trim().TrimEnd('/') + if ($issuer -and -not ($TargetUrl.TrimEnd('/') + '/').StartsWith($issuer + '/', [System.StringComparison]::OrdinalIgnoreCase)) { + Log "WARN endpoint credential was issued by $issuer, not the target $TargetUrl; not sending it" + return '' + } + return $credential +} + +function Get-ShopDbHttpFailure($ErrorRecord) { + # Status and error envelope code (data.error.code) from a failed request. + $status = 0 + $code = '' + $text = '' + try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch {} + if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { + $text = $ErrorRecord.ErrorDetails.Message + } else { + try { + $reader = New-Object System.IO.StreamReader($ErrorRecord.Exception.Response.GetResponseStream()) + $text = $reader.ReadToEnd() + $reader.Dispose() + } catch {} + } + if ($text) { + try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {} + } + $presented = '' + $matched = $false + if ('ShopDbServerPinValidator' -as [type]) { + $presented = [string][ShopDbServerPinValidator]::LastPresented + $matched = [ShopDbServerPinValidator]::IsPinned($presented) + } + # Innermost exception text: the outer WebException hides the real cause + # (unexpected EOF, connection reset, trust failure). + $message = '' + $exception = $ErrorRecord.Exception + while ($exception) { + if ($exception.Message) { $message = $exception.Message } + $exception = $exception.InnerException + } + return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message } +} + +function Write-ShopDbAuthFailure($Failure) { + switch ($Failure.Code) { + 'CREDENTIAL_INVALID' { + Log ('ERROR 401 CREDENTIAL_INVALID: the server does not accept this PC''s endpoint credential ' + + '(revoked or unknown). The endpoint agent deletes it and re-enrolls on its own.') + } + 'SHARED_KEY_REFUSED' { + Log ('ERROR 401 SHARED_KEY_REFUSED: the server refuses the shared key for this PC, because ' + + 'the PC is enrolled or the shared key is switched off. Reports need the endpoint agent ' + + "credential: check agent 1.20+ is installed, running and enrolled ($ShopDbCredentialFile).") + } + 'ASSET_MISMATCH' { + Log ('ERROR 403 ASSET_MISMATCH: this PC''s credential belongs to a different asset than the ' + + 'hostname in the report. Check the hostname and the enrollment in ShopDB.') + } + default { + if ($Failure.Status -eq 0 -and $Failure.PresentedPin) { + # A pin was seen, but only an absent pin is a pin failure. A + # matched pin means the handshake passed and something later + # failed (server dropped mid-transfer, timeout). + if ($Failure.PinMatched) { + Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)" + } else { + Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins" + } + } + } + } +} + $REGPATHS = @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB') function Get-ShopdbRegValue([string]$name) { @@ -107,9 +380,22 @@ if (-not $ApiUrl -and -not $WhatIfOnly) { # collector key from the GE-Enforce secret store when not passed via Args. if (-not $ApiKey) { $ApiKey = Get-ShopdbRegValue 'CollectorKey' } -if (-not $ApiKey -and -not $WhatIfOnly) { - Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.' - exit 0 +$EndpointCredential = '' +if (-not $WhatIfOnly) { + $EndpointCredential = Get-ShopDbEndpointCredential $ApiUrl + if (-not $EndpointCredential -and -not $ApiKey) { + Log 'ERROR no endpoint credential and no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.' + exit 0 + } + + $serverPins = Get-ShopDbServerPins + if ($serverPins.Count -gt 0) { + if (-not (Enable-ShopDbServerPins $serverPins)) { + Log 'ERROR refusing to report without the server pin check. Skipping.' + exit 0 + } + Log "server pin check on ($($serverPins.Count) pin(s))" + } } # Queues that are not devices: the Windows-supplied virtual printers plus the @@ -301,14 +587,27 @@ if ($WhatIfOnly) { } Log ("POST {0} host={1} queues={2}" -f $ApiUrl, $Hostname, $reported.Count) -try { - $response = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json ` - -ContentType 'application/json' ` - -Headers @{ 'X-API-Key' = $ApiKey } ` - -TimeoutSec $TimeoutSec -ErrorAction Stop - Log ("RESPONSE {0}" -f ($response | ConvertTo-Json -Compress -Depth 4)) -} catch { - Log "ERROR POST failed: $($_.Exception.Message)" +# Credential first; the shared key only when there is no credential, or when +# the server has just refused the credential (revoked: the shared key is +# admitted again for this PC until the agent re-enrolls). Never both at once. +$attempts = @() +if ($EndpointCredential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $EndpointCredential }) } +if ($ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $ApiKey }) } + +foreach ($attempt in $attempts) { + try { + $response = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json ` + -ContentType 'application/json' ` + -Headers $attempt[1] ` + -TimeoutSec $TimeoutSec -ErrorAction Stop + Log ("RESPONSE ({0}) {1}" -f $attempt[0], ($response | ConvertTo-Json -Compress -Depth 4)) + break + } catch { + Log "ERROR POST with $($attempt[0]) failed: $($_.Exception.Message)" + $failure = Get-ShopDbHttpFailure $_ + Write-ShopDbAuthFailure $failure + if ($failure.Code -ne 'CREDENTIAL_INVALID') { break } + } } exit 0 diff --git a/plugins/printers/frontend/routes.js b/plugins/printers/frontend/routes.js index f5428243..98e57b10 100644 --- a/plugins/printers/frontend/routes.js +++ b/plugins/printers/frontend/routes.js @@ -26,6 +26,12 @@ export default [ component: () => import('./views/ZabbixSettings.vue'), meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' } }, + { + path: 'settings/integrations/zabbix', + name: 'integrations-zabbix', + component: () => import('./views/ZabbixSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' } + }, { path: 'settings/printer-alerts', name: 'printer-alerts', @@ -61,7 +67,7 @@ export default [ path: 'settings/modelsupplies', name: 'model-supplies', component: () => import('./views/ModelSuppliesList.vue'), - meta: { requiresAuth: true, plugin: 'printers' } + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' } }, { path: 'settings/printerdrivers', diff --git a/plugins/printers/frontend/views/ZabbixSettings.vue b/plugins/printers/frontend/views/ZabbixSettings.vue index fd1a5ca0..cf79726c 100644 --- a/plugins/printers/frontend/views/ZabbixSettings.vue +++ b/plugins/printers/frontend/views/ZabbixSettings.vue @@ -1,7 +1,32 @@