Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cloud-tunnel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": minor
---

Connect through FTW Cloud with the same Noise tunnel as iPhone. Existing web installations pair again with a current QR. There is no relay fallback.
47 changes: 18 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Your home's energy, from wherever you are.

This is FTW's installable web client. It talks to the FTW box in your home,
which is the authority on your
energy system. Sourceful's relay carries the traffic and cannot read it.
energy system. Its encrypted session runs through FTW Cloud, as on iPhone.

## Product direction and contributions

Expand All @@ -22,24 +22,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
## The shape of it

```
┌──────────────────────────────────────────────────────┐
│ FTW webapp │
│ UI · local cache · encrypted keys · passkey │
└──────────────┬─────────────────────┬─────────────────┘
│ │
LAN (later) encrypted relay
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ FTW at home │ │ Sourceful cloud plane │
│ │ │ │
│ control · safety │ │ serves this bundle │
│ full history │ │ blind connection relay │
│ devices · optimizer │ │ opaque site presence │
│ identity · grants │ │ │
└──────────────────────┘ └──────────────────────────┘
webapp → Noise to FTW Cloud → tun → Noise to the home box
│
state, safety, control
```


Three claims hold this together:

**The box is the record.** It measures, controls, stores the full history and
Expand All @@ -50,15 +38,16 @@ cached projection of it, never the original.
on screen in the first frame, timestamped honestly, while fresher data is
fetched behind them. There is no spinner on a white background.

**The relay is blind.** Sourceful serves this bundle, and its relay carries
encrypted frames and knows which opaque handle is online. The relay cannot read
a watt, a device name or a command. This is enforced by the protocol, and a CI
test dumps the relay's database and fails if anything recognisable is in it.
**The box session stays encrypted.** FTW Cloud cannot read or forge the
session inside `tun`. It sees a stable home id, the box's address and traffic
timing. Remote access works with the readable copy off. If an owner enables
that copy, Sourceful can read the data it contains; this web client still
reads through its own box session. [ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md)
and the [Cloud protocol](https://github.com/srcfl/ftw/blob/master/docs/protocol-cloud.md)
describe that boundary. The relay and escrow remain blind.

This app does not use FTW Cloud. FTW Cloud is a separate service for the
iPhone app, proposed and not yet built. If a box owner turns it on, Sourceful
can read what the owner enabled; it never sends control.
[ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md) records it, and the relay and escrow stay blind either way.
Pairing needs a current box and QR v3. Existing web installations must pair
again. The passkey origin stays `app.ftw.energy`. There is no relay fallback.

If a household asks, the escrow will also hold one sealed copy of its home, so a
new phone gets back in with a passkey instead of a trip to the box. Sourceful
Expand All @@ -75,8 +64,8 @@ record earlier delivery stages; use the current implementation, tests and
release evidence to establish what a particular box and app support.
The product vision is a target, not an availability list.

Not yet built: cloud MCP agent access and the WebRTC LAN carrier. The vision
sets the direction for agent access; the architecture records the LAN work.
Not yet built in this web client: the WebRTC LAN carrier, Cloud push for
browsers and reading the optional signed copy. This change carries the box session.

## Running it

Expand All @@ -100,14 +89,14 @@ Runs type checks, tests and a production build. Green before every handoff.
| Path | What lives there |
|---|---|
| `src/lib/protocol` | Wire format, capability negotiation, snapshot and deltas |
| `src/lib/carrier` | The carrier abstraction: relay, cache, and later WebRTC |
| `src/lib/carrier` | The Cloud tunnel and local cache; relay code remains for server tests |
| `src/lib/crypto` | Noise IK, the passkey key hierarchy |
| `src/lib/identity` | Enrollment, principals, grants |
| `src/lib/store` | IndexedDB — the local projection |
| `src/lib/ui` | Components |
| `src/views` | Screens |
| `contract/` | The shared registry: scopes, capabilities, error codes, field ids |
| `relay/` | The blind relay itself — the server this app's frames pass through |
| `relay/` | The old blind relay, pending service retirement |
| `escrow/` | The sealed escrow — one slot per household, and it cannot open any of them |

`contract/registry.yaml` is the single source for every name shared with the
Expand Down
44 changes: 27 additions & 17 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ and temporary control that needs renewal.

## The model

**Edge-authoritative, client-local, cloud-blind.**
**The box decides; the client caches; the tunnel stays encrypted.**

The box at home is the system of record. It measures, controls, stores the
full history, holds identity and decides what is safe. It works with the
Expand All @@ -32,14 +32,24 @@ This app is a cached projection of the box. It renders from its own store
first and replaces that with fresher data when a carrier answers. It expresses
intent; it never decides.

Sourceful's cloud serves this bundle and relays encrypted frames. On that path
it knows that an opaque handle is online and roughly how much traffic passed.
It cannot read a watt, a device name or a command.

FTW Cloud is a separate service for the iPhone app, proposed in
[srcfl/ftw ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md) and not yet built. If a box owner turns it on, it
holds a readable copy of what the owner enabled, and it never sends control.
This app does not use it. The relay and escrow stay blind either way.
The web app and iPhone reach the same FTW Cloud service. The browser opens
`/v1/app?home=…` over WebSocket and runs Noise IK to the service's pinned key.
Its own Noise session with the box rides inside that session as `tun`. The
box's first handshake may ride in `now.tun`, as on iPhone. Cloud cannot read
or forge that inner session. It sees the stable home id, addresses and timing.

The readable copy is an owner opt-in, separate from remote access. Sourceful
can read that copy when enabled; this client uses the tunnel for reads,
history and commands, and does not display the copy's envelopes. See
[srcfl/ftw ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md)
and the normative [Cloud protocol](https://github.com/srcfl/ftw/blob/master/docs/protocol-cloud.md).
The relay and escrow remain blind.

Pairing requires QR v3, with the Cloud home id. Existing installations without
it pair again; no bridge or relay fallback is built. The origin and RP ID stay
`app.ftw.energy`. A visible tab holds one Web Lock for its home's connection;
a second tab waits. A hidden tab closes its session and releases the lock.
A replaced session does not try to take the device slot back in a loop.

A separate daily fleet-report endpoint is the stated exception. Unless the box
owner opts out, it receives six coarse labels with no box id and reduces them
Expand All @@ -58,7 +68,7 @@ Behind a common session abstraction sit three carriers:

| Carrier | Role | Status |
|---|---|---|
| Relay | Default, and the only remote path | v1 |
| Cloud tunnel | The remote box session, over WebSocket | current |
| Cache | Read-only view when nothing reaches the box | v1 |
| WebRTC DataChannel | Opportunistic LAN shortcut | after measurement |

Expand Down Expand Up @@ -223,18 +233,18 @@ cover FTW Cloud, which can read what a box owner turns on;
### What is not yet true

An adversarial review of the crypto, identity and relay layers found the
content half of that promise sound and the metadata half not. These are open,
and listed here rather than in a tracker because a privacy claim with quiet
exceptions is worse than no claim.
content half of that promise sound and the metadata half not. The relay
findings below record that earlier path; the web client now uses the Cloud
tunnel described above.

- **The rendezvous secret cannot yet be rotated from a session.** The v2 QR
payload carries one, so the handle is no longer a hash of a key that never
changes, but replacing it still means scanning a new code. The field exists
so that a `rendezvous.rotate` command can arrive later without another
payload version.
- **The box authenticates nobody.** Noise IK proves the box's identity to the
app, not the reverse. The pairing code that would close it is parsed and
then unused. So today the guarantee is one-way.
- **Device admission now runs at both ends.** Cloud admits the device key
from the signed roster, or once from a pending pairing proof. The box
checks the actual code inside its own Noise handshake.
- **The app-to-box direction has constant frame size but not constant
cadence.** Frames leave only on user action, so the relay can see when
someone opened the app and how many times — a much thinner channel than the
Expand Down Expand Up @@ -290,7 +300,7 @@ remove it. Its claim is:
> beside it.**

The copy is the recovery blob: the device's Noise static and, per home, the box
key and the rendezvous secret. It is AES-GCM under a key derived from PRF
key, Cloud home id and the QR's rendezvous secret. It is AES-GCM under a key derived from PRF
output and padded to a fixed 512 bytes, so its length says nothing about how
many homes are inside. The id is HKDF over the same PRF secret under a
different `info` string — a sibling of the key, never derived from it — so a
Expand Down
7 changes: 7 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ fragments. Cadence changes only after an explicit visibility `sub`: 1 Hz while
the document is visible and 0.2 Hz while it is hidden. It never changes with
the telemetry it carries. If the box has nothing to say it sends `tick` anyway.

Through FTW Cloud's tunnel the box sends a padded frame when a reading lands,
plus a beat while quiet. The relay's fixed cadence applies only to the relay.
The tunnel's envelope, limits and session ids follow Core's
[Cloud protocol](https://github.com/srcfl/ftw/blob/master/docs/protocol-cloud.md#tunnel).
The browser uses the same Noise and CBOR as iPhone over the service's existing
WebSocket endpoint; its custom QUIC transport is not a WebTransport endpoint.

This constancy is a privacy control, not a performance choice. A delta that
does not fit sends the highest-priority fields and sets `TRUNC`; the rest
follows on the next tick or as a fresh snapshot on the bulk lane. It never
Expand Down
2 changes: 1 addition & 1 deletion public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# was once missing from this list, and the recovery flow's fetch died in
# the browser as "could not be reached" while every probe from outside
# passed; tests/csp-contract.test.ts now holds the list to the code.
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss://relay.ftw.energy https://escrow.ftw.energy; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; manifest-src 'self'
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss://cloud.ftw.energy https://escrow.ftw.energy; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; manifest-src 'self'
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
# The camera is used to scan the pairing code and nothing else asks for
Expand Down
10 changes: 5 additions & 5 deletions src/App.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ async function pairPhone(siteId: string) {
const row: StoredSite = {
siteId,
label: 'Home',
cloudHome: 'fjzyr67buzpyncqmjliwgezvde',
boxStaticKey: new Uint8Array(32).fill(7),
rendezvousSecret: new Uint8Array(32).fill(9),
addedAtMs: Date.UTC(2026, 6, 1),
Expand Down Expand Up @@ -222,6 +223,7 @@ describe('switching homes while the first carrier is still being built', () => {
await pairPhone(HOME_A)

const offered = buildEnrollmentUrl({
home: 'fjzyr67buzpyncqmjliwgezvde',
boxStaticPublic: new Uint8Array(32).fill(8),
pairingCode: new Uint8Array(16).fill(4),
rendezvousSecret: new Uint8Array(32).fill(5),
Expand Down Expand Up @@ -708,7 +710,7 @@ describe('the way back, for a phone that cannot get in', () => {
siteId: HOME_ID,
label: 'Home',
boxStaticKey: new Uint8Array(32).fill(7),
...(opts.rendezvous === false ? {} : { rendezvousSecret: new Uint8Array(32).fill(9) }),
...(opts.rendezvous === false ? {} : { cloudHome: 'fjzyr67buzpyncqmjliwgezvde', rendezvousSecret: new Uint8Array(32).fill(9) }),
addedAtMs: Date.UTC(2026, 6, 1),
lastSeenAtMs: Date.now(),
}
Expand Down Expand Up @@ -850,13 +852,11 @@ describe('the way back, for a phone that cannot get in', () => {
})

it('reaches it from a row that cannot find its box at all', async () => {
// Paired before the rendezvous secret existed, so this phone cannot derive
// a handle and there is nothing to connect to. Only a fresh scan or the
// sealed copy carries the missing bytes.
// A row from before Cloud has no home id. The owner pairs again.
await homeOnDisk({ rendezvous: false })
render(App)

await vi.waitFor(() => expect(said()).toMatch(/before this app could reach it privately/i), {
await vi.waitFor(() => expect(said()).toMatch(/pair this home again/i), {
timeout: 4_000,
})
await takeTheWayBack(/get this phone back in/i)
Expand Down
3 changes: 3 additions & 0 deletions src/lib/carrier/carrier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export interface Carrier {

send(frame: Uint8Array): void

/** Draw a new tunnel id before each box handshake. Other carriers need none. */
beginSession?(): void

/** Returns an unsubscribe function. */
onFrame(handler: (frame: Uint8Array) => void): () => void
onStatus(handler: (status: CarrierStatus) => void): () => void
Expand Down
Loading
Loading