diff --git a/docs/geenforce-api-cutover.md b/docs/geenforce-api-cutover.md new file mode 100644 index 0000000..d85f99b --- /dev/null +++ b/docs/geenforce-api-cutover.md @@ -0,0 +1,697 @@ +# GE-Enforce over HTTPS API: cutover reference + +This is the operator/developer reference for moving a shopfloor PC type off the +SMB-share GE-Enforce delivery and onto the shopdb HTTPS API. The first cohort +cut over was the displays/kiosks (`gea-shopfloor-display`): share-less, +Intune/Entra-joined PCs with no SFLD share credentials. This doc captures +everything learned doing that, so extending the cutover to the other pc-types +(`gea-shopfloor-cmm`, `-collections`, `-keyence`, `-genspect`, `-heattreat`, +`-partmarker`, `-nocollections`, `common`) does not require re-learning it. + +It pairs with the existing docs (which describe the pieces; this one describes +the CUTOVER): + +- `docs/GE-ENFORCE.md` - concepts and the plugin +- `docs/GE-ENFORCE-CLIENT.md` - client fetch/report contract +- `docs/GE-ENFORCE-DEPLOY.md` - what must land on a PC +- `docs/GE-ENFORCE-DISPLAY.md` - the display scope specifics +- `/home/camp/projects/pxe/docs/ge-enforce-v2-architecture.md` - the SMB world + being cut away from + +Contents: + +1. [Overview and why](#1-overview-and-why) +2. [Server architecture](#2-server-architecture) +3. [Delivery models: smb vs http/inline payloads](#3-delivery-models-smb-vs-httpinline-payloads) +4. [Authoring a scope](#4-authoring-a-scope) +5. [The on-PC client and engine](#5-the-on-pc-client-and-engine) +6. [Bootstrap for share-less PCs](#6-bootstrap-for-share-less-pcs) +7. [Asset reporting via the collector](#7-asset-reporting-via-the-collector) +8. [HARD-WON GOTCHAS](#8-hard-won-gotchas) +9. [How this was verified](#9-how-this-was-verified) +10. [Deploy](#10-deploy) +11. [PLAYBOOK: extending to a new pc-type](#11-playbook-extending-to-a-new-pc-type) +12. [Open items / TODO](#12-open-items--todo) + +--- + +## 1. Overview and why + +GE-Enforce v2 delivers desired-state manifests and installer payloads from the +SFLD SMB share (`\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\`). +Every PC mounts the share with Azure-DSC-provisioned SFLD credentials, reads +`\manifest.json`, and runs the engine +(`Install-FromManifest.ps1`). That works for the domain fleet but is a hard +dead end for share-less PCs. + +The HTTPS API path replaces the share as the transport, keeping the engine and +its detection/self-heal behavior untouched: + +| Concern | SMB share | HTTPS API | +|---------|-----------|-----------| +| Manifest source | `\manifest.json` on the share | `GET /api/geenforce/manifest?pctype=` (published snapshot) | +| Payload source | share paths (`apps\...`) | `GET /api/geenforce/payload/` (content-addressed) | +| Auth | SFLD share credential (DSC) | `geenforce.fetch` service token OR source-IP allowlist | +| Result visibility | log files on the PC | `POST /api/geenforce/report` -> Enforcement Reports UI | +| Versioning | file overwrite + `_meta/history` | immutable published versions, rollback, ETag | + +Which PCs MUST use the API: the share-less ones. Displays/kiosks are +Intune/Entra-joined with no SFLD credentials and no domain trust, so SMB is not +an option at all. The rest of the fleet CAN stay on the share (and currently +does); for them the API is an opt-in migration, not a forced one. + +West Jefferson facts used throughout this doc: + +| Fact | Value | +|------|-------| +| Prod host | `tsgwp00525.wjs.geaerospace.net` | +| App mount | `/shopdb` (IIS, app dir `C:\inetpub\wwwroot\shopdb`, pool `shopdbflask-prod`) | +| BaseUrl clients use | `https://tsgwp00525.wjs.geaerospace.net/shopdb` | +| Prod DB | `shopdb_flask` (MySQL) | +| Client allowlist CIDRs | `10.134.48.0/23,10.48.249.0/26` (the WJ corp/AESFMA shopfloor subnets) | +| Dev/staging instance | `/ops` mount, DB `shopdb_flask_dev`, pool `shopdbflask` | + +--- + +## 2. Server architecture + +All server code is in `plugins/geenforce/` (routes: `plugins/geenforce/api/routes.py`). + +### Client-facing endpoints (three) + +| Endpoint | Method | Auth | What it does | +|----------|--------|------|--------------| +| `/api/geenforce/manifest?pctype=&phase=runtime` | GET | `geenforce.fetch` token OR IP allowlist | Serves the CURRENT PUBLISHED manifest snapshot for a scope (never the draft). `ETag: "-v"`, `X-Manifest-Version` header, 304 on `If-None-Match`. | +| `/api/geenforce/payload/` | GET | `geenforce.fetch` token OR IP allowlist | Streams a payload blob by content hash: blob store first (`service.blob_path`), then inline `ManifestPayload`. ETag = the hash. Per-IP rate limited (120/min default) and size-capped (512 MB default, 413 above). | +| `/api/geenforce/report` | POST | `geenforce.report` token OR IP allowlist | Records one enforcement cycle via `service.record_enforcement_report`: hostname, scopename, appliedversion, enforcerversion, counts, per-entry results. Upserts the current report per (hostname, scopename, phase); older reports kept as history. | + +Plus the collector for asset reporting (section 7): `POST +/api/collector/computers` in `shopdb/core/api/collector.py` - a DIFFERENT auth +domain (`collector.ingest`), NOT covered by the geenforce allowlist. + +### Auth model + +`_require_service_token(scope)` in `routes.py` is the decorator factory. Two +paths, fail-closed (neither -> 401): + +1. A managed service token with the scope (`geenforce.fetch` for + manifest/payload, `geenforce.report` for report), sent as `X-API-Key` or a + Bearer PAT (`authorized_service_token`). A token may carry + `resourcescopelist` bindings: a bound token can only fetch its own scope's + manifest (403 otherwise) and only blobs those scopes' published manifests + reference (`service.blob_referenced_by_scopes`, 404 so hashes cannot be + probed). Displays get a token bound to `gea-shopfloor-display` (see + GE-ENFORCE-DISPLAY.md). +2. The IP allowlist: `_ip_allowlisted()` checks the caller against the setting + `geenforce_allowed_cidrs` (comma-separated CIDRs/IPs, empty = disabled). + Network trust replaces the shared secret for a vaulted fleet. The + allowlisted path has no resource-scope binding (unrestricted). + +The token paths use RBAC permissions the plugin registers in +`GeEnforcePlugin.get_permissions()`: `geenforce.manage` (edit), +`geenforce.publish` (ship), `geenforce.fetch` and `geenforce.report` +(client service tokens). Admin CRUD/publish/simulate/compliance routes are JWT +plus `geenforce.manage`/`geenforce.publish`. + +### Why the allowlist uses remote_addr (the IIS XFF dependency) + +`_trusted_client_ip()` returns `request.remote_addr`, NOT the raw +`X-Forwarded-For` header. Proxies APPEND to X-Forwarded-For, so its first hop +is attacker-controlled: parsing it (as `_client_ip()` does, acceptably, for +rate limiting only) would let any caller send `X-Forwarded-For: +` and bypass the token entirely. + +`remote_addr` is trustworthy only because of a two-piece chain that MUST stay +in place: + +1. The IIS URL-Rewrite rule in the `/shopdb` web.config OVERWRITES (not + appends) the inbound `X-Forwarded-For` with `REMOTE_ADDR`, the real TCP + peer. +2. waitress runs with `--trusted-proxy=127.0.0.1 + --trusted-proxy-headers=x-forwarded-for`, so it derives `remote_addr` from + that overwritten header only when the request comes from IIS on localhost. + A client that somehow hits waitress directly is not a trusted proxy, so its + `remote_addr` is its own real peer address. Either way, real client IP. + +If the IIS rule is ever removed, the allowlist stops matching (fails closed to +401 for token-less clients) - it does NOT become spoofable, but the fleet +falls back to cached manifests. Keep the rule on. + +--- + +## 3. Delivery models: smb vs http/inline payloads + +Every `ManifestEntry` carries a `payloadsource` (`plugins/geenforce/serializer.py` +and `importer.py`): + +| PayloadSource | Meaning | Manifest emission | +|---------------|---------|-------------------| +| `smb` (default) | Entry installs from the share exactly as v2 does; the entry's `Installer`/`Script`/`Source` is a share-relative path. | Nothing emitted - share manifests round-trip byte-identical, parity preserved. | +| `http` | Payload lives in the server's content-addressed blob store (`instance/geenforce/payloads/`, registry row `ManifestBlob`). For big files (MSIs, EXEs). Upload via `flask geenforce add-payload ` or `service.store_blob`. | `PayloadSource`, `PayloadSha256`, `PayloadRef` keys on the entry. | +| `inline` | Payload bytes live IN the DB (`ManifestPayload`, <= 1 MB) - small scripts and configs. Attach via `service.store_inline_payload(entry, filename, contenttype, rawbytes)` or `POST /api/geenforce/entries//payload`. | Same three keys. | + +Both `http` and `inline` are served from the same client URL: +`GET /api/geenforce/payload/` (blob store checked first, then inline). +The sha256 IS the integrity contract - the client re-hashes after download. + +### How the client stages payloads (Resolve-ShopdbPayloads) + +`Resolve-ShopdbPayloads` in `plugins/geenforce/client/ShopdbEnforceClient.psm1` +is the bridge that lets the UNCHANGED engine install share-less: + +1. For each entry with `PayloadSha256` and `PayloadSource` http/inline, call + `Get-ShopdbPayload`: download to + `C:\ProgramData\ShopDB\geenforce\payloads\` (ext from + `PayloadRef`), verify the sha256, keep it as a content-addressed + last-known-good cache (a cache hit only counts if the bytes still hash + right). +2. Rewrite the entry's path field to the LEAF filename of the staged file + (`Split-Path -Leaf`) - NOT the absolute path. Field by Type: + `Installer` for MSI/EXE/CMD/BAT/INF, `Script` for PS1, `Source` for File. +3. Write a sibling `.resolved.json` manifest and return its path (or + the original path if nothing needed resolving). A payload that cannot be + fetched/verified THROWS - the runner's fail-safe catch decides what happens. + +The runner (`Invoke-ShopdbEnforce.ps1`) then sets the engine's +`-InstallerRoot` to that same payloads directory, so the engine's +`Join-Path $InstallerRoot ` resolves to the staged file. `smb` entries in +a mixed manifest are left untouched and still resolve against the share (a PC +that has it). + +--- + +## 4. Authoring a scope + +Two authoring paths, both ending in `service.replace_scope_draft(scopename, +phase, manifest_dict)` (idempotent draft rebuild - published versions are never +touched by a re-import): + +### A. import-share: adopt an existing SMB manifest + +``` +flask geenforce import-share --shareroot [--scope ] [--preinstall ] +flask geenforce publish [--phase runtime] [--notes "..."] +``` + +`importer.discover_share` walks the share root and ingests +`common/manifest.json`, `display/manifest.json`, and every +`gea-shopfloor-*/manifest.json` (skipping `.bak` variants). Entries come in as +`smb` payloads. Run `flask geenforce parity --shareroot ` first (Gate A): +proves import+re-export is behaviorally lossless before anything ships. + +### B. authoring in code: seed_display_scope as the template + +`plugins/geenforce/seed_display_scope.py` is the reference for a scope that +never existed on the share. Pattern: + +- Build the manifest dict in Python (`build_display_manifest()`): four + `Type=Registry` drift-heal entries re-asserting the Edge kiosk relaunch + policies from imaging (`09-Setup-Display.ps1`), one inline PS1 dispatcher, + one inline PS1 always-on script. Registry heals use + `DetectionMethod=ValueMatches` against the same path/name they write, so + drift self-heals; the PS1s use `DetectionMethod=Always` and are idempotent. +- The dispatcher (`Invoke-DisplayKioskDispatch.ps1`, generated by + `build_dispatcher_script()`) reads `C:\Enrollment\display-type.txt`, maps + the subtype through the data-driven `DISPLAY_TYPE_TARGETS` table + (Dashboard -> `/shopfloor`, Lobby -> `/tv`, 3DPrintRoom -> `/parts-kiosk`), + and writes an all-users Startup shortcut (`ShopDB Kiosk.lnk`) launching Edge + `--kiosk` fullscreen at `{BaseUrl}{route}`. It does NOT Start-Process Edge + (see gotchas). Base URL comes from HKLM `BaseUrl`, falling back to the WJ + host. +- `seed_display_scope(publish=False)`: `replace_scope_draft`, flush (entries + need entryids), then `service.store_inline_payload(...)` for each script + entry (sets `payloadsource='inline'`, `payloadsha256`, `payloadref`), + optionally `service.publish_scope(...)`, commit. Draft rebuild is + idempotent; publish always creates a NEW version. + +Run it on the server: + +``` +cd C:\inetpub\wwwroot\shopdb +$env:FLASK_APP = 'shopdb' +'from plugins.geenforce.seed_display_scope import seed_display_scope; print(seed_display_scope(publish=True))' | venv\Scripts\python -m flask shell +``` + +Expected: `{scopeid, entrycount: 6, entrytypes: [Registry x4, PS1, PS1], +dispatchersha256, alwaysonsha256, publishedversion: N}`. + +### Publishing + +`service.publish_scope` freezes the draft (rendered by +`serializer.scope_to_json`) into an immutable `ManifestPublishedVersion` and +flips `iscurrent`. Clients only ever see published versions. +`rollback_scope` re-currents an older version. Also available over the API +(`POST /scopes//publish`, permission `geenforce.publish`) and the +GE-Enforce UI. + +### Attaching payloads + +- Inline (<= 1 MB): `service.store_inline_payload` in code, or + `POST /api/geenforce/entries//payload` (multipart file). +- Blob (`http`): `flask geenforce add-payload ` prints the sha256; + set `PayloadSource=http` + `PayloadSha256` (+ `PayloadRef` for the + filename/extension) on the entry. + +Publish AFTER attaching - the published JSON is what carries the +`PayloadSha256` the client fetches, and blob access for resource-bound tokens +is checked against the CURRENT published manifest. + +--- + +## 5. The on-PC client and engine + +### Config: HKLM:\SOFTWARE\GE\ShopDB + +| Value | Used by | Notes | +|-------|---------|-------| +| `BaseUrl` | enforce client + kiosk dispatcher | e.g. `https://tsgwp00525.wjs.geaerospace.net/shopdb`. Required. | +| `ApiToken` | enforce client | `geenforce.fetch` (+ report) PAT. OPTIONAL - a token-less client relies on the IP allowlist (`Get-ShopdbConfig` treats BaseUrl-only as valid). | +| `CollectorKey` | `Report-AssetToShopDB.ps1` | `collector.ingest` PAT. REQUIRED for asset reporting (allowlist does not cover the collector). | + +The key's ACL is restricted to SYSTEM + Administrators (the bootstrap does +this) so the kiosk auto-login user cannot read the PATs. + +### The pieces on disk (kiosk layout, `C:\ProgramData\GE-Enforce`) + +- `Invoke-ShopdbEnforce.ps1` - the runner +- `ShopdbEnforceClient.psm1` - the client module +- `lib\Install-FromManifest.ps1` - the engine (>= 2.6) +- `Report-AssetToShopDB.ps1` - the asset collector +- Cache: `C:\ProgramData\ShopDB\geenforce\` (`.json`, `.etag`, + `.version`, `payloads\`), logs `C:\Logs\Shopfloor\` + +### Scheduled tasks (SYSTEM, RunLevel Highest) + +| Task | Runs | Interval | +|------|------|----------| +| `ShopDB GE-Enforce` | `Invoke-ShopdbEnforce.ps1 -Scope -EnginePath -BaseUrl ` | AtStartup + every 15 min | +| `ShopDB Asset Report` | `Report-AssetToShopDB.ps1` | AtStartup + every 60 min | + +Tokens are NOT in the task arguments (visible in task XML) - the scripts read +them from HKLM. + +### The runner flow (Invoke-ShopdbEnforce.ps1) + +1. `Get-ShopdbConfig` (params override registry). No BaseUrl -> exit 0, retry + next cycle. +2. `Sync-ShopdbManifest -Scope `: ETag-conditional GET; 200 validates + the JSON before overwriting the cache (a proxy error page served as 200 + must not clobber last-known-good); 304 or any network failure -> cached + copy. Nothing at all -> Windows event log entry (source `ShopdbEnforce`, + id 1001) plus a best-effort failure report so it is visible server-side, + then exit 0. +3. `-ShadowMode` (with `-ShareManifestPath`): `Compare-ShopdbShadow` logs + name/order diffs, engine runs against the SHARE (zero behavior change). + This is the first step of every cutover. +4. Cutover mode: optional `-IncludeCommon` merges the fleet `common` scope + (`Merge-ShopdbManifests`: common's unique entries first, pctype wins on + Name conflict). OFF by default - a scope is enforced ALONE and displays are + self-sufficient. Then `Resolve-ShopdbPayloads` stages http/inline payloads + (section 3). +5. Engine call (the integration point): + `& $EnginePath -ManifestPath -PCType $Scope -InstallerRoot -LogFile `. +6. `ConvertTo-ShopdbSummary` normalizes whatever came back (summary object, + array of emitted objects, bare int, $null) into + `@{Installed;Skipped;Failed;Filtered;Results;EnforcerVersion}`, then + `New-ShopdbReport` maps to the lowercase wire contract and + `Send-ShopdbReport` POSTs it. All best-effort; the whole script exits 0 no + matter what (fail-safe: a broken web app never breaks a PC). + +### The engine contract (Install-FromManifest.ps1, lib 2.6) + +Mandatory params: `-ManifestPath`, `-InstallerRoot`, `-LogFile`; optional +`-PCType`, `-PCSubType`. Entry Types: MSI, EXE, CMD/BAT, PS1, INF, File, +Registry. Detection: Registry, File, FileVersion, Hash, MarkerFile, +ValueMatches, pnputil, Always. Filters: PCTypes (with old/new-name alias +groups), TargetHostnames, TargetMachineNumbers, `_CmmVersion`. Exit 0/1/2 +unchanged for the SMB path; NEW in the API cutover: the engine ends with +`Write-Output` of a summary pscustomobject +(`Installed/Skipped/Failed/Filtered/EnforcerVersion/Results`), which is the +only thing on the success stream (logs go via Write-Host), so +`& $EnginePath ...` captures it cleanly. + +`SelfHealed` on a result means a REAL drift correction (a detected-missing +entry that got reinstalled). `Always`/no-detection entries install every cycle +by design and are not flagged, so the server-side status derivation +(`service.record_enforcement_report`: failed > selfhealed > ok) stays honest. + +### The display-type.txt dispatcher pattern + +For kiosks, per-subtype behavior does not fork the scope: ONE scope +(`gea-shopfloor-display`), one inline dispatcher entry that reads +`C:\Enrollment\display-type.txt` at enforce time and acts on it. Subtype +changes are a one-file edit plus the next 15-min cycle, and retargeting a +subtype is a table edit in `seed_display_scope.py` + republish. The dispatcher +writes an all-users Startup shortcut (never Start-Process - see gotchas), and +sweeps stale kiosk launchers first (its own `ShopDB Kiosk*.lnk`, the imaging +installers' `GE Aerospace Dashboard*`/`GE Aerospace Lobby*` shortcuts, any +browser .lnk with `-kiosk`-ish args or shopdb URLs, and .url files pointing at +the kiosk routes). + +--- + +## 6. Bootstrap for share-less PCs + +A share-less PC cannot pull its first files from the share, so the bootstrap +itself is downloadable from the web app. + +`Install-ShopdbKiosk.ps1` (source of truth: +`/home/camp/pxe-images/shopdb-migration/kiosk-installer/`) is hosted at +`C:\inetpub\wwwroot\shopdb\installers\kiosk\` and downloadable at +`{BaseUrl}/installers/kiosk/Install-ShopdbKiosk.ps1`. Run elevated on the PC: + +``` +Set-ExecutionPolicy Bypass -Scope Process -Force +$u = 'https://tsgwp00525.wjs.geaerospace.net/shopdb/installers/kiosk/Install-ShopdbKiosk.ps1' +Invoke-RestMethod $u -OutFile "$env:TEMP\Install-ShopdbKiosk.ps1" +& "$env:TEMP\Install-ShopdbKiosk.ps1" -DisplayType Lobby -CollectorKey 'shopdb_pat_...' +# add -ShopdbToken 'shopdb_pat_...' only if the subnet is NOT allowlisted +``` + +What it does (idempotent; re-running is also the manual update path): + +1. Writes `C:\Enrollment\display-type.txt` (the subtype) and + `C:\Enrollment\pc-type.txt` (the scope, default `gea-shopfloor-display`). +2. Writes HKLM `BaseUrl` [+ `ApiToken`] + `CollectorKey`, then locks the key + ACL to SYSTEM + Administrators. +3. Downloads runner + module + engine + collector from + `{BaseUrl}/installers/kiosk/` over HTTPS (TLS 1.2 forced). +4. Registers the two SYSTEM tasks (section 5). +5. Starts both once so the PC is live immediately. + +IIS prerequisite: the `installers\kiosk` web.config MUST carry +`` MIME maps for `.ps1`/`.psm1` (`text/plain`) or IIS 404.3s +the downloads (see gotchas). + +Delivery options for the bootstrap itself: + +- Imaging-baked: the display image runs it (or lays down the same state) at + imaging time - see `project-display-self-contained`. +- Installer push: Intune/hand-run the one-liner above on an already-deployed + PC. This is how the pilot kiosks were done + (`shopdb-migration/run-on-kiosk-F.txt`). + +--- + +## 7. Asset reporting via the collector + +`Report-AssetToShopDB.ps1` (in the kiosk bundle; also deployed in the SMB +`common\` scope for the share fleet) POSTs the PC's identity to: + +``` +POST {BaseUrl}/api/collector/computers +X-API-Key: +``` + +Auth (`shopdb/core/api/collector.py`, `_check_collector_auth`): a managed +token scoped `collector.ingest` (Bearer or X-API-Key) OR the +`COLLECTOR_API_KEY`/`COLLECTOR_API_KEY_COMPUTERS` env key. The GE-Enforce IP +allowlist does NOT apply here - the collector always needs a key, read from +HKLM `CollectorKey` (or the manifest entry's `Args -ApiKey`). + +Schema (`plugins/computers/plugin.py`, `get_collector_schema`): identity field +`hostname` (required); optional `machinenumber`, `pctype`, `pcsubtype`, +`serialnumber`, `loggedinuser`, `lastboottime`, `lastcheckin`, `ipaddress`, +`vendorname`, `modelnumber`, `osname`, `installedsoftware`, `defaultprinter`, +`printers`. All lowercase concatenated (the project naming convention). + +`apply_collector_payload` upserts idempotently by hostname (falls back to +`Asset.assetnumber`), creates the Asset+Computer when missing, maps +`machinenumber` -> `Asset.assetnumber` (imaging placeholder `9999` skipped both +client- and server-side), `pctype` -> ComputerType via the settings mapping, +and creates Vendor/Model/OS rows as needed. Fields not posted are not touched - +a partial read never blanks a good value, so the script only includes fields it +actually resolved. + +Client details worth keeping: machine number resolution order is eDNC registry +`MachineNo` (WOW6432Node then native) -> `C:\Enrollment\cmm\cmmid.txt` -> +`C:\Enrollment\machine-number.txt`; the reported `ipaddress` is filtered to +the corp ranges (same two CIDRs as the allowlist) so a machine-LAN controller +NIC never lands in shopdb. + +--- + +## 8. HARD-WON GOTCHAS + +Read this section before touching ANY of the moving parts. Every bullet cost +real debugging time. Format: symptom -> cause -> fix. + +- **PS crash "property 'X' cannot be found" under Set-StrictMode** -> + the module runs `Set-StrictMode -Version Latest`, and engine + results/summaries arrive as EITHER hashtables or PSCustomObjects with + varying key casing; direct `$obj.Key` access on an absent key throws -> + route every dynamic property read through `Get-ShopdbProperty` (handles + both shapes, case-insensitive, returns $null when absent). Never dot into + parsed JSON or engine output directly. +- **Register-ScheduledTask rejects the repeating trigger** -> passing + `-RepetitionDuration [TimeSpan]::MaxValue` serializes to an out-of-range + Duration the Task Scheduler XML schema rejects -> use + `-RepetitionInterval` ALONE; it defaults to indefinite repetition + (verified Win11 / PS 5.1). See `Register-SystemTask` in + `Install-ShopdbKiosk.ps1`. +- **Engine exits 2 / "InstallerRoot not found"** -> `-InstallerRoot` and + `-LogFile` are MANDATORY engine params and InstallerRoot must EXIST -> + the runner always passes both and pre-creates the payloads dir before the + engine call. Any new caller must do the same. +- **http payload "not found: C:\...\C:\..." (path doubling)** -> the engine + resolves entry paths as `Join-Path $InstallerRoot `; writing the + staged payload's ABSOLUTE path into the entry made the engine double it -> + `Resolve-ShopdbPayloads` writes the LEAF filename only, and the runner sets + `-InstallerRoot` to the payloads cache dir. Keep those two in lockstep. +- **Manifest fetch "works" but parsing fails / cache garbage** -> if the + manifest is served with a non-JSON content type, PowerShell 5.1 + `Invoke-WebRequest` `.Content` comes back as a `byte[]` instead of a string + -> the server route returns `mimetype='application/json'` (see + `get_manifest`); any mock server or proxy in the chain must do the same. + The client also validates JSON before overwriting last-known-good. +- **Bootstrap download 404 (HTTP 404.3)** -> IIS refuses to serve unknown + static extensions; `.ps1`/`.psm1` have no default MIME map -> add + `` + (and `.psm1`) in the `installers\kiosk` web.config, with `` first + if inherited. +- **IP allowlist spoofable / mysteriously not matching** -> raw + `X-Forwarded-For` is attacker-controlled (proxies append; first hop is the + caller's to write) -> `_ip_allowlisted` uses `request.remote_addr` via + `_trusted_client_ip`, which is only correct because the IIS URL-Rewrite + rule OVERWRITES X-Forwarded-For with REMOTE_ADDR and waitress trusts only + 127.0.0.1 as proxy. The rule is a hard dependency: never remove it, and + verify the spoof is closed after server changes + (`curl -H "X-Forwarded-For: 10.134.48.10"` from a non-allowlisted host + must get 401). +- **Kiosk browser never appears though the dispatcher "ran fine"** -> the + enforce task runs as SYSTEM in session 0, which has no interactive + desktop; `Start-Process msedge.exe` opens INVISIBLY there -> write an + all-users Startup shortcut + (`C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\ShopDB Kiosk.lnk`) + and let the auto-login user launch it in a visible session. This is why + the dispatcher is shortcut-based. +- **TLS/transport errors on older images ("could not create SSL/TLS secure + channel")** -> Windows PowerShell 5.1 does not reliably negotiate TLS 1.2 + by default -> every network helper calls `Set-ShopdbTls` + (`[Net.ServicePointManager]::SecurityProtocol = Tls12`) first; the + bootstrap and collector force it too. Any new script that touches the API + must do the same. +- **Secrets readable by the kiosk user / visible in Task Scheduler** -> + default HKLM\SOFTWARE ACL grants BUILTIN\Users read, and task arguments + are world-readable in the task XML -> restrict the + `HKLM:\SOFTWARE\GE\ShopDB` key ACL to SYSTEM + Administrators (the + bootstrap does), and NEVER put a token in a task's `-Argument` string - + scripts read `ApiToken`/`CollectorKey` from HKLM at run time. +- **Enforcement Reports show 0/0/0 with no per-entry detail** -> the engine + historically returned nothing on the success stream, so the runner had no + counts to report -> the 2.6 engine emits the summary object as its ONLY + `Write-Output` (all logging is Write-Host), and + `ConvertTo-ShopdbSummary` tolerates non-compliant engines by zero-filling. + If reports go 0/0/0 again, the engine on that PC is pre-summary - update + it. +- **Two kiosk browsers fighting / stale kiosk launch after retarget** -> old + installs left their own Startup launchers behind, in several flavors -> + the dispatcher's sweep must match ALL of: single- AND double-dash `-kiosk` + arguments (the regex `-kiosk` matches both), shortcuts whose args carry + shopdb URLs (`tsgwp00525`, `/shopdb/`, the dead `shopfloor-dashboard` + route), the imaging installers' `GE Aerospace Dashboard*` / `GE Aerospace + Lobby*` shortcut names, and `.url` files pointing at the kiosk routes. + Extend the sweep whenever a new launcher naming appears. + +--- + +## 9. How this was verified + +Two complementary verification passes; keep BOTH for future cohorts because +they catch different bug classes: + +- **Code review** finds logic bugs: the XFF spoof hole, the StrictMode + absent-key crashes, the status-derivation trap (`installed>0` is not + self-heal), the report casing mismatch. +- **VM smoke test** finds integration/OS-version bugs: the + RepetitionDuration serialization rejection, the .ps1 MIME 404, session-0 + invisibility, TLS negotiation, byte[] vs string response content - none of + which a read of the code surfaces. + +The VM rig: + +- The win11 virt-manager VM (see `project-win11-vm` / + `reference-vm-qga-as-system` memory), driven by + `/home/camp/pxe-images/ednc-bins/qga-run.py` - qemu guest agent + `guest-exec`, which runs PowerShell AS SYSTEM. That matters: the scheduled + tasks run as SYSTEM, so testing as SYSTEM reproduced the session-0 and + profile-less behaviors an interactive test would have masked. +- A mock HTTP server on the host exposing `/api/geenforce/manifest` (served + `application/json`), `/api/geenforce/payload/`, and capturing the + `/api/geenforce/report` POST body. This exercised the full client loop - + ETag/304, cache fallback (kill the server mid-test), payload hash + verification, resolved-manifest rewrite, engine run, summary -> report + mapping - without touching prod. +- Full pass = bootstrap installer run end to end in the VM, then assert: both + tasks registered, HKLM written + ACLed, manifest cached, payloads staged by + sha, Startup shortcut written, report captured with real counts. + +--- + +## 10. Deploy + +TWO independent channels. Confusing them is the classic mistake: the client/ +engine/collector/bootstrap are pxe-images SHARE artifacts, NOT deployed by the +git pipeline. + +### Channel 1: backend + frontend (the git .cmd pipeline) + +Prod is air-gapped from dev; code moves via a git bundle on the share +(`\\172.16.9.9\pxe-images\github\shopdb-flask-pub.bundle`) and the .cmd +scripts in `/home/camp/pxe-images/github/`, run on the work PC: + +1. `pull-shopdb-bundle.cmd` - fetch the bundle into the local clone + (ff-only). +2. `update-shopdb-github.cmd` - push the clone to GitHub. +3. `update-dev-server.cmd` - robocopy the clone to `X:` (dev `/ops` tree) + + the `/ops`-base frontend dist. Validate on `/ops` FIRST. +4. `update-prod-server.cmd` - robocopy to `Y:` (`C:\inetpub\wwwroot\shopdb`) + + the `/shopdb`-base dist (`frontend-dist-subpath-shopdb`). Both scripts + carry instance guards (web.config MOUNT_PATH check) - do not bypass them. + +Then RDP: `Restart-WebAppPool shopdbflask-prod`, and if migrations/deps +changed: `flask db upgrade`, `flask plugin upgrade-all`, `flask seed +permissions`, `flask seed settings`. + +(The fast path used during the pilot - robocopy just the changed plugin files +from `shopdb-migration\prod-patch-geenforce\` + pool restart, per +`deploy-server-patch.txt` - works, but the same commits must ALSO go through +the bundle pipeline or prod drifts from git.) + +### Channel 2: client + engine + collector + bootstrap (the kiosk bundle) + +These live at `/home/camp/pxe-images/shopdb-migration/kiosk-installer/` and +deploy by robocopy from the work PC (Z: = share, Y: = prod app dir): + +``` +robocopy Z:\shopdb-migration\kiosk-installer Y:\installers\kiosk /E +``` + +That directory (bundle contents: `Install-ShopdbKiosk.ps1`, +`Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`, +`lib\Install-FromManifest.ps1`, `Report-AssetToShopDB.ps1`, `web.config` with +the MIME maps) IS the distribution point - PCs download from +`{BaseUrl}/installers/kiosk/`. Reference copies of the client kit also live in +the repo at `plugins/geenforce/client/` and the engine's source of truth is +`/home/camp/pxe-images/common/lib/Install-FromManifest.ps1`; when the engine +or client changes, update the kiosk bundle copy too (nothing syncs it +automatically). PCs pick up new bytes by re-running the bootstrap one-liner. + +### Server prerequisites (once per site, all three or token-less clients 401) + +1. Publish the scope(s) - `seed_display_scope(publish=True)` or + `flask geenforce publish `. +2. Seed `geenforce_allowed_cidrs` = `10.134.48.0/23,10.48.249.0/26` + (Settings rail > GE-Enforce Settings, or SQL upsert into `settings`). +3. Mint tokens (Settings > API Tokens, Restrict permissions ON): + `collector.ingest` (required, the kiosk `-CollectorKey`) and + `geenforce.fetch` (fallback for non-allowlisted subnets; resource-bind it + to the scope). +4. Keep the IIS URL-Rewrite XFF-overwrite rule enabled (section 2). + +--- + +## 11. PLAYBOOK: extending to a new pc-type + +Checklist for cutting any of the remaining scopes (`gea-shopfloor-cmm`, +`-collections`, `-keyence`, `-genspect`, `-heattreat`, `-partmarker`, +`-nocollections`, `common`) over to the API. + +1. **Decide the delivery model.** Does this pc-type keep SMB access? If yes, + the cheap cutover is manifest-over-API + payloads-still-smb (entries stay + `smb`, nothing to upload, the engine resolves share paths as today). Only + a genuinely share-less PC needs http/inline payload conversion. Note the + payload endpoint's 512 MB default ceiling + (`GEENFORCE_PAYLOAD_MAX_BYTES`) before promising huge installers over + HTTPS. +2. **Get the scope into shopdb.** Existing share manifest: + `flask geenforce parity` then `flask geenforce import-share --scope + `. New/reworked scope: author in code following + `seed_display_scope.py` (registry heals with ValueMatches detection, + idempotent Always PS1s, data-driven tables for anything per-subtype). +3. **Convert payloads (share-less only).** Small scripts/configs -> + `store_inline_payload` / the entry payload upload endpoint. Installers -> + `flask geenforce add-payload `, set + `PayloadSource=http` + `PayloadSha256` + `PayloadRef` on the entry. + Remember: `PayloadRef`'s extension decides the staged filename's + extension. +4. **Publish.** New version every publish; clients converge within one + enforce cycle. Verify with + `curl "{BaseUrl}/api/geenforce/manifest?pctype="` from an + allowlisted host. +5. **Auth for the PCs.** Subnet already inside + `10.134.48.0/23,10.48.249.0/26` -> token-less, nothing to do. New subnet + -> add its CIDR to `geenforce_allowed_cidrs` (Settings rail validates). + Not network-trustable -> mint a `geenforce.fetch` token resource-bound to + the scope and deliver it to HKLM `ApiToken`. +6. **Bootstrap the client.** Share-attached fleet: adapt the dispatcher / + `Install-GEEnforce.ps1` path (the pilot flow in + `shopdb-migration/kiosk-api-pilot.txt`: shadow first, then flip, then + DISABLE the old share enforce task so the two do not fight). Share-less: + the `Install-ShopdbKiosk.ps1` pattern - generalize `-Scope` and skip the + display-only pieces. Decide `-IncludeCommon`: displays run without it; + a non-display share-less PC that needs the fleet-wide common entries over + HTTPS turns it on AND requires common's entries to be payload-converted + first (an SMB-payload common entry will fail on a share-less PC). +7. **Run SHADOW mode first** on one pilot PC + (`-ShadowMode -ShareManifestPath `): fetch + compare + + report with zero behavior change. Watch the shadow diff log lines and the + Enforcement Reports row before flipping. +8. **Re-read section 8 (gotchas).** Especially: LEAF filenames, StrictMode + property access, SYSTEM/session-0, task trigger serialization, MIME maps + if you host new downloadables. +9. **Verify on the VM** (section 9) before the pilot PC: bootstrap + + enforce cycle against a mock or the dev `/ops` instance, as SYSTEM via + qga-run.py. +10. **Pilot one PC, then the cohort.** Keep the rollback in your pocket: + disable/remove the new task, re-enable the share task, remove HKLM + `BaseUrl` - the share path is untouched by all of this. +11. **pc-type mapping.** Make sure the collector's pctype mapping + (computers plugin settings, `pctypemap`) covers the scope name so asset + reports do not warn `no ComputerType mapping`. + +--- + +## 12. Open items / TODO + +- **Name resolution for reported users.** `loggedinuser` lands as a bare + username; resolving it to a display name depends on either the + `wjf_employees` `First_Name`/`Last_Name` data or shopdb User accounts + existing for shopfloor users. Not wired up; reports show raw usernames + until it is. +- **Old imaging-installer registry cleanup.** Displays imaged before the API + cutover carry leftover state from the old imaging-time kiosk installers + (superseded shortcuts are already swept by the dispatcher; stale registry + values are not yet cleaned). A cleanup entry in the display scope is the + natural vehicle. +- **The SMB fleet is still on the share.** Only displays/kiosks are on the + API. cmm/collections/keyence/genspect/heattreat/partmarker/nocollections/ + common still enforce from SFLD; section 11 is the path. Shadow mode makes + each migration observable before it changes anything. +- **3DPrintRoom kiosk target is a placeholder.** `DISPLAY_TYPE_TARGETS` + points it at `/parts-kiosk`; confirm the real route with the floor team + before publishing to production 3D-print-room displays (flagged in + `seed_display_scope.py`). +- **Fast-path prod patches vs git.** The `prod-patch-geenforce` robocopy + path can leave prod ahead of the repo; reconcile by pushing the same + changes through the bundle pipeline (section 10, channel 1). diff --git a/frontend/src/utils/datetime.js b/frontend/src/utils/datetime.js new file mode 100644 index 0000000..4ad8cce --- /dev/null +++ b/frontend/src/utils/datetime.js @@ -0,0 +1,80 @@ +// Site-timezone datetime helpers. +// +// Notification start/end times are stored as UTC and must be shown + entered in +// the site's configured IANA timezone (settings key site_timezone), not the +// viewer's browser zone, so every site and remote admin agrees on the wall +// clock. These convert between a UTC instant and a 'YYYY-MM-DDTHH:mm' string in +// a given zone (the value a datetime-local input holds). + +const DEFAULT_TZ = 'America/New_York' + +function pad(n) { + return String(n).padStart(2, '0') +} + +// Wall-clock parts of a UTC instant AS SEEN in tz. Uses Intl so it is correct +// across DST without shipping a tz table. +function zonedParts(date, tz) { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hourCycle: 'h23', + }) + const map = {} + for (const p of dtf.formatToParts(date)) { + if (p.type !== 'literal') map[p.type] = p.value + } + return { + year: Number(map.year), month: Number(map.month), day: Number(map.day), + hour: Number(map.hour), minute: Number(map.minute), second: Number(map.second), + } +} + +// tz offset (ms) at the given instant: (wall-clock-as-UTC) - (real UTC). +function tzOffsetMs(date, tz) { + const p = zonedParts(date, tz) + const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) + return asUtc - date.getTime() +} + +// UTC instant (or ISO string) -> 'YYYY-MM-DDTHH:mm' wall clock in tz, for a +// datetime-local input. Empty string on no/invalid input. +export function zonedInputFromUtc(value, tz = DEFAULT_TZ) { + if (!value) return '' + const date = value instanceof Date ? value : new Date(value) + if (isNaN(date.getTime())) return '' + const p = zonedParts(date, tz) + return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}` +} + +// 'YYYY-MM-DDTHH:mm' wall clock in tz -> UTC ISO string. Interprets the entered +// time as tz-local (DST-aware) and returns the real instant. Empty -> null. +export function utcFromZonedInput(localStr, tz = DEFAULT_TZ) { + if (!localStr) return null + const [datePart, timePart = '00:00'] = localStr.split('T') + const [y, mo, d] = datePart.split('-').map(Number) + const [h, mi] = timePart.split(':').map(Number) + if ([y, mo, d, h, mi].some(Number.isNaN)) return null + // Guess the instant as if the wall clock were UTC, then subtract the zone + // offset AT that instant. One correction is exact except in the ~1h/yr DST + // fold, where either side is a fair reading. + const guess = Date.UTC(y, mo - 1, d, h, mi) + const offset = tzOffsetMs(new Date(guess), tz) + return new Date(guess - offset).toISOString() +} + +// UTC instant (or ISO) -> human string in tz, e.g. "Jul 30, 2026, 2:34 PM". +export function formatInZone(value, tz = DEFAULT_TZ, opts = {}) { + if (!value) return '' + const date = value instanceof Date ? value : new Date(value) + if (isNaN(date.getTime())) return '' + return new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', month: 'short', day: 'numeric', + hour: 'numeric', minute: '2-digit', + ...opts, + }).format(date) +} + +export { DEFAULT_TZ } diff --git a/frontend/src/views/settings/SiteSettings.vue b/frontend/src/views/settings/SiteSettings.vue index e52c0e0..d1c399a 100644 --- a/frontend/src/views/settings/SiteSettings.vue +++ b/frontend/src/views/settings/SiteSettings.vue @@ -74,6 +74,7 @@ const LABELS = { dualpath_single_machine: 'Dualpath as Single Machine', employee_directory_mode: 'Employee Directory Mode', usb_directory_mode: 'USB Directory Mode', + site_timezone: 'Site Timezone', setup_complete: 'Setup Complete' } function prettyLabel(key) { @@ -83,7 +84,12 @@ function prettyLabel(key) { // Enumerated settings render as dropdowns to prevent typos. const OPTIONS = { employee_directory_mode: ['selfhosted', 'external'], - usb_directory_mode: ['selfhosted', 'external'] + usb_directory_mode: ['selfhosted', 'external'], + site_timezone: [ + 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Phoenix', + 'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu', + 'America/Indiana/Indianapolis' + ] } function isTrue(setting) { @@ -97,6 +103,7 @@ const GROUPS = [ { title: 'Behavior', keys: ['dualpath_single_machine'] }, { title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'contact_email_domain', 'employeeid_pattern'] }, { title: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] }, + { title: 'Localization', keys: ['site_timezone'] }, { title: 'System', keys: ['setup_complete'] } ] @@ -108,7 +115,8 @@ const HELP = { employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.', printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net', contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.', - dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.' + dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.', + site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.' } function fieldHelp(key) { return HELP[key] || '' diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index a3aa612..da8d187 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -12,7 +12,7 @@ from shopdb.api import db, success_response, error_response, paginated_response, from ..models import Notification, NotificationType -from shopdb.api import require_permission +from shopdb.api import require_permission, Setting notifications_bp = Blueprint('notifications', __name__) @@ -26,16 +26,41 @@ SPLIT_TYPECOLORS = frozenset({'recognition', 'training', 'recertification'}) # an explicit end time, by notification typecolor. # recognition - clears at the next 8:00 AM Eastern (daily reset) # recertification - stays up two weeks (employees have time to book the course) -EASTERN = ZoneInfo('America/New_York') +_DEFAULT_TZ = 'America/New_York' RECERTIFICATION_DAYS = 14 -def _next_eastern_time(after, hour, minute=0): - """Next hour:minute America/New_York strictly after `after` (naive UTC), - returned as naive UTC. Uses the tz database so it is correct across EST/EDT.""" - after_east = after.replace(tzinfo=timezone.utc).astimezone(EASTERN) - target = after_east.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0) - if target <= after_east: +def _site_tz(): + """Site-configured IANA timezone (settings key site_timezone), used for the + daily-reset expiry math and any wall-clock computation. Falls back to + America/New_York if unset or invalid.""" + row = Setting.query.filter_by(key='site_timezone').first() + name = (row.value if row and row.value else _DEFAULT_TZ) + try: + return ZoneInfo(name) + except Exception: + return ZoneInfo(_DEFAULT_TZ) + + +def _parse_utc(date_str): + """Parse an incoming ISO datetime to NAIVE UTC (how starttime/endtime store). + + The client submits new Date(local).toISOString(), i.e. UTC with a 'Z'. A + value without an offset is assumed already-UTC (not server-local) so the + stored instant is unambiguous.""" + dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).replace(tzinfo=None) + + +def _next_site_time(after, hour, minute=0): + """Next hour:minute in the site timezone strictly after `after` (naive UTC), + returned as naive UTC. Uses the tz database so it is correct across DST.""" + tz = _site_tz() + after_local = after.replace(tzinfo=timezone.utc).astimezone(tz) + target = after_local.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0) + if target <= after_local: target += timedelta(days=1) return target.astimezone(timezone.utc).replace(tzinfo=None) @@ -51,13 +76,13 @@ def _auto_endtime(ntype, starttime): mode = getattr(ntype, 'expirymode', None) or 'none' if mode == 'dailytime': hour = ntype.expiryhour if ntype.expiryhour is not None else 8 - return _next_eastern_time(starttime, hour, ntype.expiryminute or 0) + return _next_site_time(starttime, hour, ntype.expiryminute or 0) if mode == 'duration' and ntype.expirydays: return starttime + timedelta(days=int(ntype.expirydays)) if mode == 'none': # legacy fallback for rule-bearing types created before the expiry columns if getattr(ntype, 'typecolor', None) == 'recognition': - return _next_eastern_time(starttime, 8, 0) + return _next_site_time(starttime, 8, 0) if getattr(ntype, 'typecolor', None) == 'recertification': return starttime + timedelta(days=RECERTIFICATION_DAYS) return None @@ -360,7 +385,7 @@ def create_notification(): if data.get('starttime') or data.get('startdate'): try: date_str = data.get('starttime') or data.get('startdate') - starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + starttime = _parse_utc(date_str) except ValueError: return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format') @@ -368,7 +393,7 @@ def create_notification(): if data.get('endtime') or data.get('enddate'): try: date_str = data.get('endtime') or data.get('enddate') - endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + endtime = _parse_utc(date_str) except ValueError: return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format') @@ -447,7 +472,7 @@ def update_notification(notification_id: int): date_str = data.get('starttime') or data.get('startdate') if date_str: try: - n.starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + n.starttime = _parse_utc(date_str) except ValueError: return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format') else: @@ -457,7 +482,7 @@ def update_notification(notification_id: int): date_str = data.get('endtime') or data.get('enddate') if date_str: try: - n.endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + n.endtime = _parse_utc(date_str) except ValueError: return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format') else: diff --git a/plugins/notifications/frontend/views/CalendarView.vue b/plugins/notifications/frontend/views/CalendarView.vue index cb2fc62..7bdde03 100644 --- a/plugins/notifications/frontend/views/CalendarView.vue +++ b/plugins/notifications/frontend/views/CalendarView.vue @@ -231,13 +231,17 @@ function openEventFromTooltip(evt) { function formatDate(dateStr) { if (!dateStr) return '' - return new Date(dateStr).toLocaleDateString('en-US', { + // allDay events carry a site-local date-only value (YYYY-MM-DD). Build the + // Date from local parts so it is not shifted a day by UTC-midnight parsing. + const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr) + const date = parts + ? new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3])) + : new Date(dateStr) + return date.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' + day: 'numeric' }) } diff --git a/plugins/notifications/frontend/views/NotificationForm.vue b/plugins/notifications/frontend/views/NotificationForm.vue index 956aee7..9087a22 100644 --- a/plugins/notifications/frontend/views/NotificationForm.vue +++ b/plugins/notifications/frontend/views/NotificationForm.vue @@ -169,7 +169,7 @@ Now - When notification becomes visible + When notification becomes visible ({{ siteTimezone }})
@@ -243,8 +243,9 @@ diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py index 42616b7..18708af 100644 --- a/plugins/notifications/models/notification.py +++ b/plugins/notifications/models/notification.py @@ -1,9 +1,48 @@ """Notifications plugin models - adapted to existing database schema.""" from datetime import datetime, timezone +from zoneinfo import ZoneInfo from shopdb.api import db +_DEFAULT_TZ = 'America/New_York' + + +def _site_zone(): + """Site-configured IANA zone (settings key site_timezone) for calendar day + placement. Imported lazily to avoid a circular import at model load.""" + from shopdb.api import Setting + row = Setting.query.filter_by(key='site_timezone').first() + name = row.value if row and row.value else _DEFAULT_TZ + try: + return ZoneInfo(name) + except Exception: + return ZoneInfo(_DEFAULT_TZ) + + +def _site_date(dt): + """The calendar day (YYYY-MM-DD) a stored UTC datetime falls on in the site + zone. allDay events must key off the site-local day, not the UTC day, or a + late-evening notification lands on the wrong date for western sites.""" + if not dt: + return None + return dt.replace(tzinfo=timezone.utc).astimezone(_site_zone()).date().isoformat() + + +def _utc_iso(dt): + """Serialize a stored datetime as an explicit-UTC ISO string. + + starttime/endtime are stored NAIVE but always hold UTC wall-clock (the + create/update parse normalizes to UTC). Emitting a bare naive isoformat let + the browser read it as LOCAL time, shifting displays by the tz offset (a + 14:34 EDT notification showed 18:34). Tag it UTC so new Date() parses the + real instant and renders in the viewer's zone. + """ + if not dt: + return None + return dt.replace(tzinfo=timezone.utc).isoformat() + + class NotificationType(db.Model): """ Notification type classification. @@ -120,10 +159,10 @@ class Notification(db.Model): 'notification': self.notification, 'title': self.title, 'message': self.notification, - 'starttime': self.starttime.isoformat() if self.starttime else None, - 'endtime': self.endtime.isoformat() if self.endtime else None, - 'startdate': self.starttime.isoformat() if self.starttime else None, - 'enddate': self.endtime.isoformat() if self.endtime else None, + 'starttime': _utc_iso(self.starttime), + 'endtime': _utc_iso(self.endtime), + 'startdate': _utc_iso(self.starttime), + 'enddate': _utc_iso(self.endtime), 'ticketnumber': self.ticketnumber, 'link': self.link, 'linkurl': self.link, @@ -175,8 +214,8 @@ class Notification(db.Model): return { 'id': self.notificationid, 'title': title, - 'start': self.starttime.isoformat() if self.starttime else None, - 'end': self.endtime.isoformat() if self.endtime else None, + 'start': _site_date(self.starttime), + 'end': _site_date(self.endtime), 'allDay': True, 'backgroundColor': color, 'borderColor': color, diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 4f58da5..a356686 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -56,6 +56,7 @@ PUBLIC_SETTING_CATEGORIES = {'branding', 'map'} PUBLIC_SETTING_KEYS = { 'site_base_url', 'facility_name', 'printer_hostname_template', 'contact_email_domain', 'servicenow_enabled', 'setup_complete', + 'site_timezone', } @@ -443,6 +444,13 @@ def build_default_settings(): 'category': 'site', 'description': 'Set true once the first-run setup wizard has been finished' }, + { + 'key': 'site_timezone', + 'value': 'America/New_York', + 'valuetype': 'string', + 'category': 'site', + 'description': "IANA timezone for this site (e.g. America/New_York, America/Chicago). Notification start/end times and daily-reset expiry are computed in this zone." + }, { 'key': 'employee_directory_mode', 'value': 'selfhosted',