# 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.*
