# HEART Hub

**Watch the health of any device from a web page, for free, with open code**

**Author:** Milind K. Patil, Syncaissa Systems Inc.
**Status:** Working draft 1, November 2026. A profile of Part 4 (Connections), with privacy rules from Part 5, in the [HEART Standards Family](HEART-Standards-Family.md).
**Version:** 1.0 (draft)
**Public site:** `https://heartcortex.ai` · **Code:** `heart-monitor/` (Apache-2.0)
**Track:** Basic: "In one minute", Sections 2, 3 and 5 to 8 (not 7.1), if you report to a monitor site. Intermediate adds 7.1, 9 and 11. Sections 4 and 10 are for people running a site.

---

## In one minute

A product with a HEART already knows whether it is healthy. **HEART Hub** lets its owner see that from anywhere:

```
device ──(reports, outbound only)──► monitor site ──► https://heartcortex.ai/device/3f9c0a71b2d4e865
```

1. The device makes a random **device ID** (16 hex characters) and a **claim code** the first time it starts, and shows the claim code to its owner: on a label, a screen, or the console.
2. The owner **registers** the device on the monitor site with the ID and the claim code. The claim code proves they hold the device.
3. From then on the device **reports** its Pulse and alerts. The owner sees them on the device's page, gets an email when the device goes silent, and can share the page if they want.

A small sensor may send a plain JSON **light report** instead of envelopes (Section 5.4), over HTTPS or through an MQTT bridge (Section 5.6), and may say how long it will sleep (Section 7).

Three rules make it safe:

1. **Nothing is shown until the owner registers it**, and a registered device's page is **private** until the owner shares it.
2. **Only public data is reported**: status and fault codes, never signal values, names or anything private.
3. **Anyone can run their own monitor site.** `heartcortex.ai` runs the same open code as everyone else.

---

## 1. Words

| Word | Meaning |
|---|---|
| **Monitor site** | A website that receives reports and shows device pages. `heartcortex.ai` is one. Anyone may run another. |
| **Device ID** | 16 lower-case hex characters (64 random bits), such as `3f9c0a71b2d4e865`. Made on the device at first start. |
| **Claim code** | 20 characters in four groups of five (100 random bits), such as `K7Q2M-9XMBA-3RTVW-HP6DZ`. Made on the device, and shown only to the person holding it. |
| **Device key** | A secret the device makes and keeps (256 random bits). It signs nothing: it proves that a report comes from the same device that said hello. |
| **Owner** | The person who registered the device. They have an **account**: a random ID that never changes, opened by any of its 1 to 10 email addresses |

## 2. Making the ID, claim code and key

A device MUST make all three from a cryptographic random source, the first time it uses a monitor site, and keep them in storage that survives restarts. They MUST NOT be derived from a serial number, MAC address or anything else public, so that nobody can work them out.

The claim code uses the characters `0123456789ABCDEFGHJKMNPQRSTVWXYZ` (Crockford Base32: no I, L, O or U, so nobody misreads them). Sites ignore case, spaces and hyphens when checking it.

The device shows the claim code only to a person in front of it: printed on a label inside the packaging, on its screen, or on its console. It MUST NOT send the claim code to anyone. It sends only a hash.

## 3. Saying hello

Before its first report, the device introduces itself:

```http
POST /v1/devices/3f9c0a71b2d4e865/hello
Content-Type: application/json

{"claimHash": "<SHA-256 hex of 'heart-claim:' + claim code, upper case, no hyphens>",
 "keyHash":   "<SHA-256 hex of the device key>",
 "template":  "heart/embeddedstandard/python/v1.2611"}
```

| Answer | Meaning |
|---|---|
| `201 {"known": true, "siteKey"}` | Welcome: the site now knows this ID |
| `200 {"known": true, "siteKey"}` | Already known, with the same hashes. Saying hello again is harmless. |
| `409 conflict` | This ID is already taken with different hashes. The device makes a new ID, claim code and key, and tries again. |
| `429 rate-limited` | More than 60 new IDs from one IP address in an hour. Try again in an hour. |

`siteKey` is the site's Ed25519 public key (base64url, 32 bytes), which signs approval decisions (Section 7.1). A device that may take approvals from the site **pins** it at its first successful hello over HTTPS, and from then on accepts decisions signed only by that key. A later hello that names a different key is ignored for approvals. A site that offers no approvals sends no `siteKey`.

Hello carries no data about the device apart from its template ID. An ID that is not registered within 30 days is forgotten.

## 4. Registering (the owner)

1. The owner signs in to the monitor site (Section 4.1).
2. They enter the device ID and claim code at `/register` (a QR code can fill both in: `https://heartcortex.ai/register?id=3f9c0a71b2d4e865`).
3. The site checks the claim code against the hash from hello. After five wrong codes for one device, it waits an hour.
4. The device now belongs to the owner's account, and nobody else can register it until they let it go.

### 4.1 Signing in

There are no passwords. Proving you can read an inbox is the only way in.

1. The person enters an email address. The site emails a **6-digit code**. The same form signs in and makes a new account.
2. They type the code. The site checks it, finds the account the address belongs to (or makes one), and starts a session.

**Accounts, not addresses.** Every account has a random ID of 12 hex characters. Devices and sessions belong to that ID, never to an address. An account holds 1 to 10 verified addresses, and any of them can sign in. One is the **primary**: emails from the site go there. An address belongs to one account at a time. To change address: add the new one (a code goes to it), make it primary, remove the old one. Nothing else changes.

**One person, one account.** For a new account, `J.Smith+x@googlemail.com` counts as `jsmith@gmail.com`: Gmail ignores dots, and `+tags` reach the same inbox. If that form already has an account, the site says so with a masked hint (`j…@gmail.com`). Disposable-mail addresses cannot make accounts.

**All the limits** (per address and per network; on top of these the whole site accepts at most 60000 code requests and 120000 code tries in any 10 minutes, as a flood guard):

| Limit | Value |
|---|---|
| A code works | Once, for 10 minutes |
| Wrong tries per code | 15, then it is void |
| Between two codes to one address | 30 seconds |
| Codes per address | 5 an hour |
| Requests per address in 24 hours | 30, then the address is paused for 24 hours. One email tells its owner; later requests are refused silently. |
| Wrong codes per address | 15 a day (UTC), and 50 in 30 days. That allows about 600 guesses a year out of a million codes. |
| From one IP address, and from one network (/24 or /48) | 10 code requests in 10 minutes and 40 a day; 20 tries in 10 minutes and 80 a day; 5 added addresses an hour and 10 a day (also per account) |
| New accounts from one IP address | 3 a day |
| Human check | Cloudflare Turnstile on every code request from a browser |

Each wrong-code counter takes its place **before** the code is compared, and a right code is used up with a condition. So guesses sent all at once cannot get past the limits, and a code cannot be used twice. A right code gives its place back and clears the counters for that address and network.

**Sessions** last 30 days, in a cookie that scripts cannot read (`HttpOnly; Secure; SameSite=Lax`). The account page lists every browser that is signed in, and can sign out one or all the others. A sign-in from a new browser or country is emailed to the primary address.

## 5. Reporting (the device)

```http
POST /v1/devices/3f9c0a71b2d4e865/report
Authorization: Bearer <device key>
Content-Type: application/json

{"pulse":  <the device's public Pulse envelope>,
 "alerts": [<public Arrhythmia envelopes, newest first, at most 16>]}
```

This is the **full report**. A device may send the **light report** of Section 5.4 instead. The site tells them apart by the `pulse` key: a report without one is light.

| Answer | What the device does |
|---|---|
| `200 {"claimed": true, "nextSec": 30}` | Stored. Report again after `nextSec` seconds, or at once when something changes. `nextSec` is the report's `intervalSec`, its `sleepSec` (Section 7), or 2 while the faster channel is open (Section 5.5). |
| `202 {"claimed": false, "nextSec": 300}` | Not registered yet. **Nothing was stored.** Try again in five minutes. |
| `401` | Wrong key. Stop, and tell the owner. |
| `404` | The site has forgotten this ID. Say hello again. |
| `413`, `400` | Too large, or not a valid report. Fix the report, never retry it unchanged. |
| `429` | Too often. Wait `nextSec` seconds. |

**Rules for reports:**

1. Every envelope MUST be a valid public message of its type (the Valve, Part 5). The site refuses the whole report with `400` unless all of these hold, and checks nothing more:
   - each envelope is an object with `specversion` `"1.0"`, `type` `heart.pulse` or `heart.arrhythmia`, a string `id` (1 to 200 characters), `source` of the form `/heart/{systemId}` or `/heart/{systemId}/{instance}`, `time` in RFC 3339, `datacontenttype` `"application/json"`, `heartversion` `1.{minor}`, `heartprivacy` `"public"`, and an object `data`;
   - Pulse `status` is `healthy`, `degraded`, `failing` or `unknown`; `sequence` is a whole number 0 or more; `intervalSec` is 1 to 86400;
   - each alert has a UUID `id`, a fault `code` (Part 3 form, such as `system.main-function-lost`), a `severity` and `status` from the standard lists, an RFC 3339 `detectedAt`, and `resolvedAt` in RFC 3339 if present;
   - at most 16 alerts and 4 approval requests (Section 7.1), each request complete;
   - `sleepSec`, if present, is a whole number from 1 to 604800 (Section 7).

   A device SHOULD still send messages that pass the official schemas; a site MAY check them in full.
2. Only these fields are kept: Pulse `status`, `sequence` and `intervalSec`; for each alert its `id`, `code`, `severity`, `status`, `detectedAt` and `resolvedAt`; and the report's `sleepSec`. The site drops everything else, even if public.
3. A report is at most 16 KB (16384 bytes). Reports less than 10 seconds after the last stored one get `429` with `nextSec`. While the faster channel is open (Section 5.5), the gap is 2 seconds.
4. Reports go out over HTTPS, directly or through an MQTT bridge (Section 5.6). A device never accepts commands from a monitor site: reporting is one-way, with two narrow exceptions. One is an owner's request for a setting the device itself offered (Section 7.2). The other is a Command Center ([HEART Command Center 1.0](HEART-Command-Center-1.0.md)), which may be run on the same site: it may send signed signals that only lower risk, such as cutting a device's brain (API Standard 7.4 rule 8). Nothing a monitor site or Command Center sends can make a device act, approve anything or raise anything.

### 5.3 Figures and settings the device chooses to share

A device MAY add two lists to its report. Both are off unless its maker or owner turns them on **on the device**, and both carry only numbers and short words, never names, logs or personal data:

```json
"figures":  [{"name": "host.containers-running", "title": "Containers running", "value": 3}],
"settings": [{"name": "web.cpus", "title": "CPUs for web", "value": 1, "min": 0.5, "max": 4, "step": 0.5, "unit": "CPUs"}]
```

| List | At most | Each item | Shown |
|---|---|---|---|
| `figures` | 64 | `name` (lower-case, digits, `.` and `-`), `title` (60 characters), a number `value`, optional `unit` (12 characters) | On the device page, to whoever may see it |
| `settings` | 8 | As a figure, plus `min`, `max` and `step`: the range the device will accept from its owner | To the owner only, with a form to change each one (Section 7.2) |

The site keeps only the latest lists, and drops them with the device. The figures are data the owner chose to publish: the Valve rule of Section 8 holds for everything else. A figure is shown to everyone the page is shared with, so a device shares only what its owner would show.

### 5.4 The light report

A small device need not build envelopes. It may send a **light report**: plain JSON, with no `pulse` key. The site builds the rest.

```json
{"status":   "degraded",
 "alerts":   [{"code": "power.battery-low", "severity": "warning"}],
 "figures":  {"temperature": 21.5, "battery.volts": 3.71},
 "sleepSec": 900}
```

| Field | Needed | Content |
|---|---|---|
| `status` | Yes | `healthy`, `degraded`, `failing` or `unknown` |
| `alerts` | No | Every problem the device has now: at most 16, and at most one per fault code. Each has a fault `code` (Part 3 form) and a `severity` (`info`, `warning`, `error` or `critical`). It may also have an `id` (a UUID), a `status` (`open` or `resolved`) and a `detectedAt` (RFC 3339). Left out, it means no problems. |
| `figures` | No | An object of at most 64 names and numbers, such as `{"temperature": 21.5}`. Or the list of Section 5.3. |
| `settings`, `results` | No | As in Sections 5.3 and 7.2 |
| `sleepSec` | No | How long the device will now be silent, in seconds (Section 7) |

**Rules for light reports:**

1. The site refuses the whole report with `400` if it has any other field, or if any value breaks the table above. A name follows Section 5.3. Every value is a finite number. The other rules of Section 5 still apply: the device key, 16 KB, and the gap between reports.
2. The site keeps exactly what it keeps for a full report (Section 5, rule 2), made like this:
   - `sequence` counts up by one with every light report the site stores for the device, from 0;
   - `intervalSec` is 30, and the site answers with `nextSec` 30 (or 2, or `sleepSec`, as in Section 5);
   - a figure sent as a name and a number gets that name as its `title`, and no unit. To give titles and units, send the list of Section 5.3.
3. **The alert list is the whole truth.** The site matches each alert to an open one by its fault code:
   - An alert with the code of an open alert is the same alert. It keeps its `id` and `detectedAt`, and takes the new `severity`. If it brings a different `id`, the open one is resolved and a new one opens.
   - A new alert gets the `id` it brings. Without one, its `id` is the UUID made from the SHA-256 of `heart-light-alert:{deviceId}:{code}:{detectedAt}` (the first 128 bits, with the version and variant bits of a version 8 UUID). Its `detectedAt` is the one it brings, or else the time the report arrived. Its status is `open`.
   - An open alert whose code is missing from the list, or that comes with `status` `resolved`, is resolved. Its `resolvedAt` is the time the report arrived.
   - A `resolved` alert that matches no open alert is stored only if it brings an `id`. Otherwise it is ignored.
4. Approval requests (Section 7.1) need the full report.

### 5.5 The faster channel

A device reports every 30 seconds or so. That is too slow when its owner has just changed a setting. So the site opens a **faster channel** for that device while its owner is busy with it:

1. **When.** For 5 minutes after the owner asks for a code to change a setting (Section 7.2) or to approve a change (Section 7.1). For 2 minutes after the owner confirms a change, approves or denies one. And while a confirmed change waits for the device's answer, which is at most 10 minutes (Section 7.2). A request that only waits for its owner does not open it.
2. **What.** While it is open, the site answers that device's reports with `nextSec: 2`, and accepts reports 2 seconds apart instead of 10.
3. **Bounded.** The site answers at most 300 reports of one device in an hour this way: 10 minutes at 2 seconds. After that the usual times apply.
4. **The device decides.** A device SHOULD follow `nextSec`. A device that sleeps may stay awake for a while to do so, or keep its own times: then the change waits for its next report.

So a confirmed change reaches a listening device within about 2 seconds, and its result is on the owner's page within about 4, plus the time on the network. Nothing else changes: the change is still signed by the site and checked by the device.

### 5.6 Over MQTT

A monitor site is a web service, not an MQTT broker. A device that already speaks MQTT may send its hello and reports through a broker. A **bridge** relays them to the site over HTTPS, and relays the answers back:

```
device ──MQTT──► broker ──► bridge ──HTTPS──► monitor site
       ◄─ reply ─ broker ◄─ bridge ◄─ answer ─┘
```

| Topic | Published by | Content |
|---|---|---|
| `heart/v1/hub/{deviceId}/hello` | The device | The hello body of Section 3 |
| `heart/v1/hub/{deviceId}/report` | The device | `{"key": <device key>, "report": <a light or full report>}` |
| `heart/v1/hub/{deviceId}/reply` | The bridge | `{"to": "hello" or "report", "status": <HTTP status>, "body": <the site's answer>}` |

1. Every message uses QoS 1 and is never retained. MQTT 3.1.1 or 5 will do. Payloads are UTF-8 JSON, at most 16 KB for the report itself.
2. The broker's access rules MUST let only that device publish on its `hello` and `report` topics and read its `reply` topic, and only the bridge read `hello` and `report` and publish `reply`. The device key travels in each report, so the broker and the bridge can see it, as an HTTPS proxy could. Connections to the broker MUST use TLS (port 8883) unless they never leave the device's own network.
3. The bridge posts each message to `/v1/devices/{deviceId}/hello` or `/report`, with the report's key as `Authorization: Bearer`, and publishes the site's answer on the reply topic, status and body unchanged. If the site cannot be reached, the answer is status `503` with body `null`. The bridge adds nothing to a report, keeps nothing, and drops a message with a malformed topic, payload or key, or one that is too large.
4. QoS 1 may deliver a message twice. A repeated report is refused by the gap between reports, or stored again: both are harmless.
5. Owner requests and decisions travel back only in that answer, signed by the site as in Sections 7.1 and 7.2. The device checks them exactly as over HTTPS. A bridge cannot make one, and cannot change one without the device noticing.

This is separate from the `mqtt` binding of API Standard 9.5, which carries a HEART's own envelopes on a broker. A bridge in one Python file, with no dependencies, is `iot-examples/heart_mqtt_bridge.py`.

## 6. The device page

`https://heartcortex.ai/device/3f9c0a71b2d4e865` shows:

- **Health**: Healthy, Degraded, Failing, or **Not heard from** (the Flatline)
- **Last heard from**, and how often it reports. A sleeping device shows **Asleep until** the time it gave (Section 7).
- **Open and past alerts**, each with its fault code explained in plain words (Part 3)
- The **template** the device started from

| Sharing | Who can see the page |
|---|---|
| **Private** (the default) | Only the owner. Everyone else gets "not found", so the page does not even say the device exists. |
| **Link** | The owner, and anyone with a secret link. Making a new link cancels the old one. |
| **Public** | Anyone who knows the device ID |

The owner can also **forget** a device: its page, its alerts, its approval requests, its name and its owner are deleted. The site keeps only what hello gave it (the ID, the hashes of the claim code and key, and the template), so the device may be registered again with its claim code. Those are forgotten too if nobody registers it within 30 days.

### 6.1 AI models and Ask AI (optional)

A site MAY let owners connect AI models with their own API keys (`/account/ai`): Claude or another provider, or the owner's own OpenAI-compatible server.

- **Keys are protected.** They are encrypted at rest, shown again only by their last four characters, never sent to a device, and used only for that owner's own devices: for their brains through a Command Center, and for Ask AI.
- **Ask AI answers the owner's questions.** On a device's page the owner may ask in plain words, such as "Does Docker need more CPU?". The model reads the device's data through read-only tools:
  - its status;
  - the figures it shared, with up to two days of their history;
  - the settings it offers, with their ranges;
  - its alerts;
  - the changes asked so far.
- **It only recommends.** It changes nothing: a change is still the owner's request of Section 7.2, which the device checks again.
- **The system prompt is open.** It names the commands and data the model may use; it is shown to the owner, who may change it.

## 7. Flatline and emails

If no report arrives for three reporting intervals (at least 90 seconds), the page shows **Not heard from**, the Flatline of Part 3 (`system.flatline`). The site emails the owner once per silence, and again when the device is back. Emails contain the device's name and page link, never anything else.

**A planned silence is not a Flatline.** A device that is about to sleep says so with `sleepSec` in its report: a whole number of seconds, from 1 to 604800 (7 days). Then:

1. The device is **asleep** until `sleepSec` after that report. Its page shows "Asleep until" that time, with its last health.
2. It becomes Flatline only after a **grace** as well: its usual Flatline time, or a tenth of `sleepSec`, whichever is longer. A device that sleeps for an hour is "Not heard from" 66 minutes after its last report.
3. No "gone quiet" email is sent before then.
4. Each report replaces the last one's `sleepSec`. A report without it means the device is awake.

### 7.1 Approvals

A device's HEART sometimes has a change waiting for its owner's yes: for example, a fix a brain proposed (Core Standard, Section 7). If the owner allows it, the device can ask on the monitor site.

1. **Off by default.** The owner turns approvals on for each device, on its page. While off, the site ignores approval requests and stores nothing.
2. **The device asks.** It adds at most 4 requests to a report. A request holds only what the owner needs to decide, never the Proposal, readings or names:

   | Field | Content |
   |---|---|
   | `actionId` | The waiting Action's ID (a UUID) |
   | `digest` | The Action's digest (64 hex characters), as in API Standard 6.3 |
   | `title` | What changes, at most 120 characters, such as "Ramp rate" |
   | `change` | The change in plain words, at most 200 characters, such as "2.0 → 1.6 °C per minute" |
   | `physical` | Whether it acts in the physical world |
   | `expiresAt` | When the request lapses. The site keeps it at most 7 days. |

3. **The owner is told.** The site emails the owner's primary address once per request, at most 20 new requests per device per day.
4. **Approving needs presence.** Signing in is not enough. The owner presses Approve, and the site emails a code bound to that Action and its digest. Entering it approves. A code for one request cannot approve another, or sign anyone in.
5. **Denying needs no code**, because saying no is always safe. Doing nothing changes nothing.
6. **The device gets the decision** in the answer to its next report: `{"approvals": "on", "decisions": [{"actionId", "decision": "approve" | "deny", "digest", "at", "signature"}]}`, for decisions of the last 24 hours. `at` is the time the site sent this answer, and `signature` is the site's Ed25519 signature (base64url) over the RFC 8785 JSON of `{"actionId", "at", "decision", "digest"}`, made with the key it gave at hello (Section 3). A site without a signing key MUST NOT offer approvals.
7. **The device checks before it acts.** This is presence method `remote-site` of API Standard 6.3. The device MUST act on a decision only if all of these hold, and otherwise ignore it:
   - the signature verifies with the site key the device pinned;
   - `digest` equals the digest of the Action that is waiting, and `actionId` its ID;
   - `at` is at most 300 seconds old by the device's clock (a device that does not know the time cannot accept decisions);
   - the Action is not `physical`, unless the owner turned on `remoteSitePhysical` on the device itself.

   It ignores decisions it has already handled, and its own gate, guards and limits still apply. `remote-site` is weaker than a passkey (`webauthn`): it proves the owner could read their inbox, not that a person stood at the device, and it trusts the site. That is why it is off by default, and why physical changes need a second opt-in at the device.
8. **In the templates.** The Python template offers `ask_owner(title, change, apply)`; the ESP32 starter offers a waiting action to its owner, alongside the device's button, with `CONFIG_HEART_MONITOR_APPROVALS`. Both are off by default, both verify every decision as in rule 7, and both offer physical changes only with a further setting (`MONITOR_APPROVALS_PHYSICAL`, `CONFIG_HEART_MONITOR_APPROVALS_PHYSICAL`). The ESP32 records an approval from the site as `presenceMethod: remote-site`, so its history shows it was approved from afar, and offers only actions on `public` or `internal` signals.

### 7.2 Owner requests: changing a setting from the site

The owner may change a setting that the device offered (Section 5.3), from the device's page. The site never invents a change and never sends one on its own: it relays one choice of the owner, signed.

1. **Both sides opt in.** The device offers the setting and its range. The owner turns on "Let me change settings from this site" on the device's page. The site needs `SITE_SIGNING_KEY`.
2. **The owner confirms with a fresh emailed code**, bound to the device, the setting and the value. A value outside `min` to `max`, or off the `step`, is refused before any code is sent.
3. **The device gets it in the reply to its next report**, at most 10 minutes after it was confirmed, and usually within seconds, because of the faster channel (Section 5.5). It comes as `"requests": [{"requestId", "device", "setting", "value", "at", "signature"}]`. `value` is a string. `signature` is Ed25519 with the site key over the RFC 8785 JSON of the other five fields.
4. **The device checks everything again**, and applies the change only if all of these hold:
   - it is signed with the site key it pinned at hello;
   - it names this device;
   - `at` is at most 300 seconds old;
   - the request was not seen before;
   - the setting is one the device offers;
   - the value is inside its own range.

   It applies the change as a person's Action with presence `remote-site` (API Standard 6.3), through its own gate and guards. A `physical` setting needs `remoteSitePhysical` on the device. The device may still refuse.
5. **It says what happened** in its next report: `"results": [{"requestId", "status": "applied" | "refused", "detail"}]`. The page shows the result next to the request.

At most 30 requests a day per device, 4 sent in one reply. A request that gets no answer expires after 10 minutes.

## 8. Privacy rules

| Rule | Why |
|---|---|
| A device reports nothing to any site unless its maker or owner turned reporting on | Watching is the owner's choice |
| Nothing is stored until the owner registers the device | An unregistered device leaves no trace beyond its hello |
| Only public fields are kept, and only those listed in Section 5 | Health never needs the private data |
| Figures are published data: a device shares only numbers its owner would show to everyone the page is shared with | A figure such as a room temperature can say when people are home |
| Pages are private by default | Even "healthy at 3 a.m." can reveal when people are home |
| The site keeps the latest Pulse and 90 days of alerts, then deletes them | Keep what helps; nothing more |
| Owners can forget a device at any time | Their data, their choice |
| Approval requests carry a title and a change in plain words, chosen by the device, and only if the owner turned approvals on | The owner sees what they decide on; nothing else leaves the device |
| The site's signing key is kept as a secret of the site, never in its database | A stolen database cannot forge approvals |
| Sign-in codes and session tokens are stored only as hashes, keyed by a server secret | A stolen database cannot be used to sign in |

## 9. Running your own monitor site

The code in `heart-monitor/` is a single Cloudflare Worker file, with its data in two Amazon DynamoDB tables (`heartaispec-user` and `heartaispec-monitor` on `heartcortex.ai`). Anyone can run it under their own domain, change its look, or write a different site that follows this profile. A site that offers approvals also needs its own Ed25519 signing key, kept as a secret (`SITE_SIGNING_KEY`). Devices are pointed at a site by one setting, `monitor_url`. Templates default to `https://heartcortex.ai`, with reporting **off** until the product maker turns it on.

## 10. All requests

| Request | Who | What |
|---|---|---|
| `POST /v1/devices/{id}/hello` | Device | Introduce itself (Section 3) |
| `POST /v1/devices/{id}/report` | Device, with its key | Report (Section 5) |
| `GET /login`, `POST /login` | Anyone | Ask for a sign-in code by email |
| `POST /login/verify` | Anyone with the code | Sign in |
| `POST /logout` | Owner | Sign out |
| `GET /account` | Owner | Email addresses and signed-in browsers |
| `POST /account/email/add`, `.../verify`, `.../primary`, `.../remove` | Owner | Manage addresses |
| `POST /account/sessions/signout` | Owner | Sign out one browser, or all the others |
| `GET /register`, `POST /register` | Owner | Register a device |
| `GET /devices` | Owner | My devices |
| `GET /device/{id}` | Owner, or as shared | The device page |
| `POST /device/{id}/share` | Owner | `private`, `link` or `public` |
| `POST /device/{id}/forget` | Owner | Delete everything about the device |
| `POST /device/{id}/approvals` | Owner | Turn approval requests on or off |
| `POST /device/{id}/approve`, `.../approve/verify` | Owner, with an emailed code | Approve one waiting change |
| `POST /device/{id}/deny` | Owner | Deny one waiting change |
| `POST /device/{id}/requests` | Owner | Turn changes from this site on or off (Section 7.2) |
| `POST /device/{id}/ask` | Owner | Ask the owner's own AI model about the device (Section 6.1) |
| `GET /account/ai`, `POST /account/ai/add`, `.../test`, `.../remove` | Owner | Connect AI models with the owner's own API keys (Section 6.1) |
| `POST /device/{id}/request`, `.../request/verify` | Owner, with an emailed code | Change one setting the device offered |
| `GET /v1/devices/{id}` | Owner, or as shared | The page's data as JSON, for apps and other sites |

The same sign-in is available as JSON, for programs and pages that use script: `POST /api/auth/start {email, turnstile}`, `POST /api/auth/verify {email, code}`, `POST /api/auth/signout`, `GET /api/me`, `GET /api/me/sessions`, `POST /api/me/sessions/signout {id | all_others}`, and `POST /api/me/emails/start | verify | primary | remove {email, code?}`. They accept only JSON, and never from another site.

Forms are plain HTML, and every form is checked to come from the same site. Pages run no scripts, except Cloudflare's human check on the sign-in pages. The menu (a "hamburger" button at the top right) needs no script either.

## 11. Threats

| Threat | Defence |
|---|---|
| Someone registers a device that is not theirs | They need the claim code, which only the person holding the device has. 100 random bits, and five tries an hour. |
| Someone guesses device IDs to find pages | Pages are private unless shared. An unshared page and a missing one look the same. |
| Someone says hello first with a device's ID | IDs are random and made on the device, so nobody knows one before its first hello. A second hello with different hashes is refused. |
| Someone sends false reports for a device | Reports need the device key. The site keeps only its hash. |
| A device leaks private data in a report | The site refuses non-public envelopes, and keeps only the listed fields |
| Someone guesses a sign-in code | 15 wrong codes a day and 50 in 30 days for each address, counted before comparing, so parallel guesses do not help. Per-network limits and a human check slow down bulk attempts. |
| Someone uses the site to email strangers | A human check, 5 codes an hour per address, a 24-hour pause after 30 requests, and per-network limits |
| A stolen session cookie | Scripts cannot read it. The owner can see every signed-in browser and sign it out, and is emailed when a new browser or country signs in. |
| Someone learns which addresses have accounts | Adding an address sends the code first and only then says it belongs to another account. Signing in does answer differently for a new address (a known gap, kept small by the human check and the limits). |
| A monitor site is hacked | It holds no device keys, no claim codes and no private data. It can show false health. It can never start a change on a device. If the attacker also has the site's signing key, and its owner turned approvals on, they could approve a change the device itself asked about, matching its exact digest, before it expires; physical changes only where the owner also allowed that at the device. The device's own gate, guards and limits still apply. |
| Someone replays or changes a decision on the way | Each decision is signed by the pinned site key, covers the digest, and is refused after 300 seconds. |
| Someone approves a change with a stolen session | Approving needs a fresh code from the owner's inbox, bound to that change. |
| Text from a device is used to attack a page | Everything shown is escaped, and pages allow no scripts |

## 12. Open items

| Item | Why it matters |
|---|---|
| **Passkey sign-in** | Email codes are only as safe as the inbox. Passkeys should be offered next. |
| **"Explain this alert"** | A button that asks a model through the site's Reasoning Gateway, within a budget |
| **Changing the site key** | A site that must replace `SITE_SIGNING_KEY` today needs every device to pin again. A statement signed by the old key, as in API Standard 10.1, would let devices follow. |
| **Signed reports** | Devices with an identity key (API Standard 10.1) could sign reports, so a site could prove where they came from |
| **More templates** | Reporting is in the Python and ESP32 templates first |
| **Sharing with named people** | Families and teams need to share without making a page public |
