# Power Monitor Demo — Gateway & Publishing Spec **Status:** DRAFT v0.1 (living document) · **Date:** 2026-06-05 **Owner:** Arizona Computer Guru (advisory) · **Implementer:** Georg Haubner (Dataforth) **Subject:** Standing up a gated, proxied demo of the Power Monitor UI at `PWM.dataforth.com` > ACG's role here is to **shape the design via feedback**, not to modify the application > code. This document is the reference architecture we hand to Georg. The SPA itself > ("ExternalCodeReview" build) is unchanged except where called out under > **Feedback for Georg**. --- ## 1. Purpose & Scope Publish a demo of the Power Monitor dashboard so **technology partners and Dataforth staff** can run feature-testing and feedback sessions. This is **not** the shipped client product — it is a hosted, controlled preview. In scope: hosting model, auth, the proxy/policy gateway, per-user permissioning, and the consolidated code-review feedback. Out of scope: rebuilding the meter firmware API or the SPA's internals. **Delivery model:** the shipped product is a **computer-run application** that connects to meters over the network and displays their data — it is **not** flashed onto the meter (hardware limits). The demo is the hosted, browser-accessible version of that same app. This corrects the "served from embedded flash" rationale in the source's own `project.md` (see Feedback). Practically: the **shipped product = a local app on the customer's network** (the hardened `dev_server.exe` lineage); the **demo = the hosted version of that app** behind our gateway. Same proxy concept, two trust levels — which is why the SSRF/open-proxy concern is **High for the public demo** but **much lower for the local product** (own LAN, own meters). ## 2. Architecture Overview ``` Browser ──HTTPS──> PWM.dataforth.com │ (outbound tunnel; no meter is ever publicly exposed) ▼ ┌──────────────────────────┐ │ Demo Gateway │ hosted INSIDE the Dataforth network │ • serves static SPA │ │ • passwordless sessions │ │ • POLICY enforcement pt │ │ • proxies ONLY live mtrs │ └───────────┬──────────────┘ │ (allowlisted) ▼ Live DTF meters (LAN) Simulated units = generated client-side in the browser; never touch the gateway. ``` Key insight from code review: **simulated units are produced client-side**; only **live meters** generate network traffic (`/api/Data?ip=`). Therefore the gateway's security boundary is precisely *"what may this user do to a real meter,"* and that must be enforced **on the wire** at the gateway — never trusting the app's (cosmetic) client-side button gating. ## 3. Device Classes (all three visible in the demo) | Class | Source | Marker | Reaches gateway? | |---|---|---|---| | **Simulated** | baked into the build | **"SIMULATION" disclaimer** (see Feedback) | No — client-side only | | **Live (curated)** | real DTF meters, fixed allowlist | live indicator | Yes — proxied | | **Explorable** | user-entered IP within the meter subnet | — | Yes — internal users only | The demo data Georg built into the executable **stays**. Live units **stay visible** so staff can validate that real readings populate the dashboard correctly. ## 3.5 Demo Behavior & Build Approach (feedback for Georg) ### Show everything; simulate what the user can't really do For a *feedback* demo, partners and staff must SEE the full feature set — firmware, config, and IP screens included. So non-admins are **not** shown 403 errors and controls are **not** hidden: - Every screen and control renders for everyone. - Capability held → the write is **real** (proxied to the meter). - Capability absent → the write is routed to a **client-side simulation** returning a believable result. Full interactive experience; no real meter touched. (IP visibility for external users is the one exception — soft-hidden per the `see_ip` nice-to-have.) ### Build it as an isolated DEMO MODULE — not a fork The application is **actively evolving** and the point is to test *current* features. - **Do NOT maintain a separate demo fork** — it drifts immediately; partners would test stale features, and two codebases must be kept in sync. Wrong cost. - **Do add a `demo` mode driven by an isolated module** (one scoped component, enabled by a build flag / env). It applies the SIMULATION disclaimers, reads the user's capabilities (gateway `/me` endpoint), and provides the write-simulation shim above. The shipped product builds **without** the module, so none of it can activate in production. ### A clean strip point for the production build (consideration) Georg will eventually want a production build with **no demo/mock code** in it. Since this is a **computer-run app, not meter firmware**, the motivation is **code hygiene + safety** (not flash size): removing any path by which fabricated values could render on the shipped product — the flip side of the "mock engine fused into the live data path" finding. Two clean options, his call: - **Stay buildless** (current style) -> draw the boundary at the **file level**: extract all sim/mock/demo logic out of `w3js_main.js` into a standalone `demo_module.js`; the demo `index.html` includes it, the production bundle omits it. A runtime `if (DEMO_MODE)` flag is NOT a strip — the code still ships in the bundle. - **Add a small build step** — now a real option, since the original "no Node on embedded hardware" rationale no longer applies. A bundler can dead-code-eliminate a `DEMO_MODE` flag at compile time. Either way it's the same extraction that resolves the "simulation fused into the live path" finding — do it once, serve both demo and product. Strictly necessary is Georg's judgment; cheap now, costly to retrofit. ### Two layers, on purpose (defense in depth) - **Demo module (in the app) = UX layer** — show-all, simulate writes, disclaimers. Trusted only for presentation. - **Gateway = hard security boundary** — independently blocks any real write a user isn't entitled to, on the wire, regardless of what the browser does. Never trusts the app. The two are independent: even with a bug in the demo module, the gateway still prevents a non-privileged user from modifying a real meter. ## 4. Authentication — passwordless magic links - Admin (internal) adds a user (name + permission checkboxes); the gateway mints a **one-time magic link**. Admin delivers it however is convenient (email, Teams, in person). Clicking it establishes a durable signed session cookie. - **No passwords** are ever set, stored, rotated, or emailed. - **Revoke** = set the user inactive; the session is invalidated. - v1 needs **no email infrastructure** — the admin copies the link. Automated send via Dataforth M365 SMTP can be added later without changing the model. - **Session lifetime:** Georg's call (a sane default + optional auto-expiry for external users after the feedback window). ## 5. Authorization — `internal` is the master gate | | External user | Internal user | |---|---|---| | Simulated units | full (harmless) | full | | Live meters | **read-only, IPs hidden** | read-only + (per checkbox) write | | See / explore / add / modify IPs | **never** | yes (within meter subnet) | | Destructive on real meters (config/reboot/firmware) | **never** | per-user checkbox | | Manage user list (`is_admin`) | never | internal only (Georg + Mike) | - **All IP-related capability is gated by `internal`** — external users never see, explore, add, or modify anything IP-related. - The per-user checkboxes (`write_config`, `reboot_restore`, `update_firmware`) are finer control **among internal users**, applied to real meters. - **Visibility != capability:** non-admins **see every option**; lacking a capability means that action is **simulated**, not hidden or errored (see §3.5). - **New-user defaults:** `internal=false`, `is_admin=false`, all checkboxes off → read-only + simulated units only. ## 6. User Store (gateway-owned) ```yaml user: name: "Jane Partner" login_handle: jane@partner.example # used to mint/deliver the magic link internal: false # master gate for IP + destructive surface is_admin: false # manage user list (internal only) active: true # uncheck to revoke capabilities: # only meaningful when internal=true write_config: false reboot_restore: false update_firmware: false # IP-related capability (see/explore/modify) is implied by internal=true, not a flag. ``` Flat file or SQLite the gateway owns. Managed via an internal-only `/admin` UI. ## 7. Gateway Enforcement **Request flow:** 1. Browser → tunnel → gateway. No valid session → magic-link login. 2. Gateway serves the static SPA bundle. 3. App issues `/api…?ip=` or `/Control…`. Gateway intercepts every request: a. Resolve user + capabilities from the session. b. **Classify the target IP:** curated-live / explorable-subnet / out-of-range. c. Apply policy (table below). Deny → `403 "not permitted in this demo."` d. Allowed → proxy to the real meter; relay response. **Policy (live-meter traffic only):** | Action | Endpoints | Allowed for | |---|---|---| | Read telemetry/config | `GET /api/*`, `GET /Control` | everyone (curated-live); internal (explorable) | | Target an explorable IP | any, with `?ip=` outside curated list | **internal only**, and **only within the meter subnet allowlist** | | Write config | `PUT/POST /api/Config/*` | internal + `write_config` | | Reboot / restore | `POST /Control/SystemRestart`, `/Control/RestoreToDefault` | internal + `reboot_restore` | | Firmware update | `PUT /Control/FirmwareUpdate`, `POST /UpdateFile/*` | internal + `update_firmware` | **SSRF prevention:** the gateway proxies **only** to IPs inside a configured **meter subnet allowlist** — never arbitrary hosts. This structurally eliminates the open-proxy behavior in the current `dev_server.py`. External users are further restricted to the **curated** live list (a subset of the allowlist). ## 8. Hosting & Network - **Host the gateway INSIDE the Dataforth network** (small VM/container on the meter VLAN) so it can reach live meters directly. - **Expose only the gateway** publicly via an **outbound tunnel** (Cloudflare Tunnel or ACG NPM) → `PWM.dataforth.com`. No meter is ever directly reachable from the internet. This is the correct answer to "need an internal IP available for an external connection" — publish the *gateway*, not a meter IP. - **TLS:** terminated at the tunnel/edge (Cloudflare or Let's Encrypt). - **DNS:** `PWM.dataforth.com` → edge → tunnel → gateway. ## 9. Build / Publish The SPA is static; "build" = produce a clean bundle: - Remove dev/forensic artifacts: `search_transcript_*.py`, `query_all_devices.py`, and the `original_ui/` duplicate tree (also leaks internal meter IPs + a developer username — must not ship externally). - Pin CDN dependencies (jQuery, Chart.js) **locally** for reliability and offline operation (a customer-site app shouldn't depend on public CDNs). - Gateway serves the bundle; live data flows through the policy proxy. ## 10. Feedback for Georg (consolidated from the code review) **A. Demo-publish items** 1. **Simulation disclaimer** — mark simulated units with a clear "SIMULATION — representative of real product data" badge. (Keep the demo data; just label it.) 2. **No open proxy** — the public demo must use the allowlisted gateway, never the current `dev_server.py` (binds all interfaces, proxies arbitrary `ip=`). 3. **Strip dev/forensic artifacts** before any external handoff (see §9). 4. **Enforce permissions at the gateway**, not via client-side button-disabling (which is cosmetic and bypassable). **B. Product-ship items (for the eventual client product, not the demo)** - On-device **TLS** (login/creds/SMTP currently traverse plain HTTP). - **Firmware image signature verification** on the meter (the UI validates nothing). - Confirm `RestoreToDefault` is server-authorized (callable without a session client-side). - **Escape device-supplied strings** before `innerHTML` (stored DOM-XSS via TagName/name). - Replace the **numeric session token** with an opaque/expiring token; add idle timeout. - De-duplicate the hardcoded IP allowlists (copied across 5–6 files) and collapse the `original_ui/` + `_v2` duplicate trees. - Separate the client-side mock engine from the live data path so real meters never show fabricated values. ## 11. Open Parameters (need Georg / Dataforth input) 1. **Curated live-meter allowlist** — which real meters are visible in the demo (names + IPs), and which are external-visible vs internal-only. 2. **Meter subnet allowlist** — the IP range `explore_ip` (internal) may target. 3. **Session lifetime** — Georg's call (+ external auto-expiry?). 4. **Magic-link delivery** — manual copy for v1, or wire M365 SMTP now? 5. **Hosting specifics** — VM/container location on the DTF network; tunnel choice (Cloudflare Tunnel vs ACG NPM). ## 12. Future / Out of Scope (noted, not required for the demo) - **Opaque device handles** (gateway issues IDs; maps ID→IP) for *true* IP hiding from external users — deferred; v1 uses simple UI-hiding (`see_ip` nice-to-have). - On-device TLS, firmware signing, full session hardening — product-phase. --- *Living draft — iterating with Mike before handoff to Georg.*