# 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:

```python
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.
