Files
shopdb-flask/docs/GE-ENFORCE-DISPLAY.md
cproudlock 802256f929 docs: wiki update for API docs, printer installer, geenforce cutover, timezone
Execute WIKI-UPDATE-PLAN.md (14 items):
- NEW docs/PRINTER-INSTALLER.md: install-list / pc-default / install-batch
  contract + public installer map page.
- NEW-shape docs/API-REFERENCE.md: index + pointer to the live generated docs
  (/api/docs Redoc, openapi.json, llms.txt, MCP), replacing a stale full dump.
- geenforce cutover + GE-ENFORCE-DISPLAY/CLIENT/DEPLOY: server-first display
  dispatcher (display-role by FQDN, display-type.txt fallback), dashboarddefaults
  FQDN keying, legacy kiosk autostart self-heal (Wow6432Node), per-PC-type
  cutover status.
- PLUGINS: printers/slides rows + plugin-permissions note (slides.manage).
- IMPORT-API: dashboarddefaults FQDN-first keying.
- CONFIG: word-wise search, site_timezone setting.
- PILOT-DEPLOY: servers-to-network reclassify step. IMPORT-ADOPTION: fixup note.
- CLAUDE.md: test count 1077->1159, HTTPS-cutover state. CHANGELOG: timezone +
  kiosk-autostart fixes, site_timezone setting.
2026-07-30 16:05:21 -04:00

7.0 KiB

GE-Enforce: the gea-shopfloor-display scope

Displays are the share-less corner of the fleet. They are Entra-joined, credential-less kiosk PCs that pull their manifest over HTTPS on port 443 and authenticate with a read-only service PAT scoped geenforce.fetch, sent as X-API-Key. They have no SMB share mount. The kiosk engine and the kiosk browser are baked into the display image, not shipped over HTTPS, so the display manifest heals POLICY / CONFIG drift only, never EXEs. It is self-sufficient and does not inherit the fleet-wide common scope (see below).

The display fetch token MUST be resource-bound

The same read-only key ships to every display (delivered by DSC, or baked into the image), so it must not be a skeleton key for the whole content store. Mint the display token bound to just this scope, so a leak cannot pull any other scope's manifest or any blob by hash:

POST /api/apitokens
{ "name": "display fetch", "scopes": ["geenforce.fetch"],
  "resourcescopes": ["gea-shopfloor-display"] }

With resourcescopes set, GET /manifest?pctype=<other> returns 403 and GET /payload/<sha> returns 404 for any blob the display scope does not reference. resourcescopes NULL (unset) = unrestricted, for back-compat with existing service tokens. Rotate by minting a new bound token and revoking the old one (deactivate it server-side); DSC re-delivers, or re-image.

There are three display subtypes, selected by C:\Enrollment\display-type.txt: Dashboard, Lobby, and 3DPrintRoom.

Authoring the scope

The scope is authored programmatically by plugins/geenforce/seed_display_scope.py, which builds a manifest dict and hands it to service.replace_scope_draft (the same call the import-share CLI uses), then attaches the inline dispatcher payload. From a Flask app context:

from plugins.geenforce.seed_display_scope import seed_display_scope
seed_display_scope(publish=True)   # publish=False leaves it as a draft

replace_scope_draft is an idempotent draft rebuild. publish=True additionally freezes an immutable published snapshot (that step is not idempotent: it always creates a new version).

What the scope contains

  1. Four Registry drift-heal entries that re-assert the Microsoft Edge kiosk relaunch policies set at imaging by 09-Setup-Display.ps1. Each writes the value and detects drift with DetectionMethod = ValueMatches against the same path/name, so a display that loses a policy self-heals on the next enforce cycle with no keyboard or mouse on site:
    • RelaunchNotification = 2 (DWord, Required auto-restart)
    • RelaunchNotificationPeriod = 3600000 (DWord, 1 hour)
    • RelaunchHeadsUpPeriod = 60000 (DWord, 1 minute)
    • RelaunchWindow (String, JSON, 02:00 start, 120 minute duration)
  2. One PS1 dispatcher, delivered inline over HTTPS. It reads C:\Enrollment\display-type.txt and launches the kiosk target for the subtype. The subtype -> route map is a data-driven table (DISPLAY_TYPE_TARGETS) at the top of both the seed module and the generated script, so targets are easy to edit. DetectionMethod = Always so it re-asserts each cycle, but the script is idempotent (it skips relaunch if a kiosk process is already serving the target URL).

Role resolution: server first, display-type.txt fallback

The dispatcher first asks the server: GET /api/dashboarddefaults/display-role?fqdn=<fqdn> (public, unauthenticated). A row in dashboarddefaults keyed by the display's FQDN (IP fallback) wins and returns the role and frontend path directly. Only when the server is unreachable or has no mapping does the dispatcher fall back to the local display-type.txt map below. To repurpose a display, edit its dashboarddefaults row; the change takes effect on the next enforce cycle.

Fallback map (local file):

display-type.txt kiosk route notes
Dashboard /shopfloor core ShopfloorDashboard, standalone full-screen
Lobby /tv slides plugin TV dashboard (surface lobby)
3DPrintRoom /parts-kiosk PLACEHOLDER, TODO-confirm printedparts parts kiosk route; confirm the real 3D-print-room target with the floor team before publishing to production displays

Dashboard-defaults FQDN keying

dashboarddefaults rows were historically keyed by IP. Migration 7d31_dashboarddefault_fqdn added an fqdn column; resolution is now FQDN-first with IP as fallback (_resolve_default in shopdb/core/api/dashboarddefaults.py). FQDNs are stored lowercase. This survives DHCP churn on kiosk subnets. POST /api/dashboarddefaults accepts fqdn, ipaddress, displayrole (dashboard|lobby|partskiosk), businessunitid, and description; displaypath is not stored but derived from the role (DISPLAY_ROLE_PATHS). Two public read endpoints consume it: /api/dashboarddefaults/display-role (dispatcher) and /api/dashboarddefaults/visitor-location (lobby business-unit lookup). The server derives a display's FQDN from its reported BIOS serial as F<serial>.<domain> (derive_display_fqdn, domain from the display_fqdn_domain setting); the dispatcher in plugins/geenforce/seed_display_scope.py builds the same FQDN client-side for its lookup.

Legacy autostart self-heal

The dispatcher also cleans up after the old GE Aerospace Dashboard / Lobby Display Inno installers, which planted autostarts (a Public-Desktop .lnk, an all-users Startup .lnk, and an HKLM ...\CurrentVersion\Run value) that relaunch Edge at now-dead URLs (/shopfloor-dashboard/, /tv-dashboard/) and white-screen. The 32-bit installer's Run value was WOW64-redirected into Wow6432Node, which is why it survived earlier cleanup. Every enforce cycle the dispatcher sweeps both registry views, all loaded user hives, Run/RunOnce/policy Run keys, and every per-user and common Startup folder, matching by legacy name and by the old URLs, then kills any old-URL Edge. The kiosk shortcut it writes is a direct Edge shortcut (no launcher or VBS). The fix ships by re-publishing this code-authored scope (seed_display_scope(publish=True)), not an import-share. pxe-images/github/find-legacy-kiosk-autostart.ps1 is a read-only locator for stragglers.

Self-sufficient: displays do NOT inherit common

The gea-shopfloor-display scope carries everything a display enforces. It does NOT inherit the fleet-wide common scope. Displays run the enforcer with common-merge off (the client default; common-merge is opt-in via Invoke-ShopdbEnforce.ps1 -IncludeCommon), so common's SMB-backed fleet entries (Adobe, Oracle, OpenText, Defect Tracker, EventSaver, printer map, self-update, asset-reporting, ...) never reach a share-less display.

This was a deliberate decision: a display needs none of common's software, and inheriting common would have forced repackaging every SMB common payload as http/inline for a share-less box. Keeping the display scope self-sufficient avoids all of that. If a future non-display share-less PC genuinely needs the fleet-wide entries, that is what -IncludeCommon plus a per-entry SMB->http payload conversion would be for -- but displays do not use it.