Skip to content
Draft
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-carrier-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

Send the same sealed relay frames to FTW Cloud when `VITE_CARRIER=cloud`, and fall back to relay.ftw.energy if that host does not speak them. The default build still dials the relay.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ 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.

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.
The default build still dials the relay. `VITE_CARRIER=cloud` sends the same
sealed frames to `wss://cloud.ftw.energy` and falls back to `relay.ftw.energy`
when that host does not speak them. That is not the iPhone's readable FTW
Cloud session at `/v1/app`. FTW Cloud, when a box owner turns the grant on,
can read what the owner enabled; it never sends control through that session.
[ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md) records it, and the relay and escrow cannot read the sealed frames either way.

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 Down
12 changes: 8 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@ 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.
[srcfl/ftw ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md). If a box owner turns it on, it
holds a readable copy of what the owner enabled, and it never sends control
through that session (`wss://cloud.ftw.energy/v1/app`). This app does not open
that session. A build can set `VITE_CARRIER=cloud` to send the same sealed
relay frames to `wss://cloud.ftw.energy` and fall back to `relay.ftw.energy`
when that host does not speak them. The default build still dials the relay.
The relay and escrow stay blind either way.

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 +62,7 @@ Behind a common session abstraction sit three carriers:

| Carrier | Role | Status |
|---|---|---|
| Relay | Default, and the only remote path | v1 |
| Relay frames | Default remote path (`relay.ftw.energy`). `VITE_CARRIER=cloud` prefers `cloud.ftw.energy` for the same frames and falls back to the relay | v1 |
| Cache | Read-only view when nothing reaches the box | v1 |
| WebRTC DataChannel | Opportunistic LAN shortcut | after measurement |

Expand Down
13 changes: 7 additions & 6 deletions public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
# the IndexedDB read before the bundle is parsed. Moving it out would cost
# the thing it exists for.
#
# connect-src names every destination explicitly — the relay, and the
# escrow that holds the sealed recovery copy. If a compromised build ever
# tried to send readings somewhere else, the browser refuses. The escrow
# 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
# connect-src names every destination explicitly — the relay, FTW Cloud
# (the same sealed frames, when a build prefers it), and the escrow that
# holds the sealed recovery copy. If a compromised build ever tried to
# send readings somewhere else, the browser refuses. The escrow 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://relay.ftw.energy 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
48 changes: 48 additions & 0 deletions src/lib/carrier/relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,4 +321,52 @@ describe('backing off from a relay that accepts and then drops', () => {

carrier.close()
})

it('dials the fallback once when the first host never speaks the relay protocol', () => {
const carrier = new RelayCarrier({
url: 'ws://cloud.invalid',
fallbackUrl: 'ws://relay.invalid',
secret: SECRET,
WebSocketImpl: StubSocket as unknown as typeof WebSocket,
random: () => 0,
})
expect(StubSocket.all[0]!.url).toContain('cloud.invalid/r/')
StubSocket.all[0]!.drop()
expect(StubSocket.all).toHaveLength(2)
expect(StubSocket.all[1]!.url).toContain('relay.invalid/r/')
expect(carrier.status.phase).toBe('connecting')
StubSocket.all[1]!.accept()
expect(carrier.status.phase).toBe('open')
carrier.close()
})

it('stays on a host that closes with a relay code', () => {
const carrier = new RelayCarrier({
url: 'ws://cloud.invalid',
fallbackUrl: 'ws://relay.invalid',
secret: SECRET,
WebSocketImpl: StubSocket as unknown as typeof WebSocket,
random: () => 0,
})
StubSocket.all[0]!.readyState = 3
StubSocket.all[0]!.onclose?.({ code: 4410, reason: '12' })
vi.advanceTimersByTime(1)
expect(StubSocket.all.every((socket) => socket.url.includes('cloud.invalid'))).toBe(true)
carrier.close()
})

it('does not leave a host that has already said ready', () => {
const carrier = new RelayCarrier({
url: 'ws://cloud.invalid',
fallbackUrl: 'ws://relay.invalid',
secret: SECRET,
WebSocketImpl: StubSocket as unknown as typeof WebSocket,
random: () => 0,
})
StubSocket.all[0]!.accept()
StubSocket.all[0]!.drop()
vi.advanceTimersByTime(1)
expect(StubSocket.all.at(-1)!.url).toContain('cloud.invalid')
carrier.close()
})
})
57 changes: 56 additions & 1 deletion src/lib/carrier/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,16 @@ const CORRECTION_LIMIT = 2
const MAX_EPOCH_CORRECTION = 1

export interface RelayCarrierOptions {
/** Relay origin, e.g. `wss://relay.sourceful.energy`. No path. */
/** Relay origin, e.g. `wss://relay.ftw.energy`. No path. */
url: string
/**
* Dialled once when `url` closes before it has spoken the relay protocol.
*
* The transition path: `wss://cloud.ftw.energy` until that host serves
* these frames, then `wss://relay.ftw.energy`. A host that sends `ready`,
* `gone`, a frame, or a relay close code is the protocol, and we stay.
*/
fallbackUrl?: string
/** Shared with the box optically at enrollment. Never sent anywhere. */
secret: Uint8Array
/** Injected by tests. Defaults to the platform WebSocket. */
Expand All @@ -85,6 +93,10 @@ export class RelayCarrier extends CarrierBase implements Carrier {
readonly kind: CarrierState = 'relay'

#url: string
/** Cleared once used, or once the first host proves it speaks the protocol. */
#fallbackUrl: string | undefined
/** True after ready, gone, a frame, or a relay close code. */
#protocolSeen = false
#secret: Uint8Array
#WS: typeof WebSocket
#now: () => number
Expand All @@ -110,6 +122,8 @@ export class RelayCarrier extends CarrierBase implements Carrier {
constructor(opts: RelayCarrierOptions) {
super()
this.#url = opts.url.replace(/\/+$/, '')
const fallback = opts.fallbackUrl?.replace(/\/+$/, '')
this.#fallbackUrl = fallback && fallback !== this.#url ? fallback : undefined
this.#secret = opts.secret
this.#WS = opts.WebSocketImpl ?? globalThis.WebSocket
this.#now = opts.now ?? (() => Date.now())
Expand Down Expand Up @@ -209,18 +223,21 @@ export class RelayCarrier extends CarrierBase implements Carrier {
#onMessage(ev: MessageEvent): void {
if (typeof ev.data === 'string') {
if (ev.data === CTRL_READY) {
this.#noteProtocol()
this.#corrections = 0
if (this.#status.phase !== 'open') {
markLinkPhase('relay-ready', { relayDialMs: this.#rttMs })
}
this.#setStatus({ phase: 'open', sinceMs: this.#now() })
} else if (ev.data === CTRL_GONE) {
// The box left. Keep the socket; the relay will say when it is back.
this.#noteProtocol()
this.#setStatus({ phase: 'closed', reason: 'box offline', retryable: true })
}
return
}

this.#noteProtocol()
if (this.#status.phase !== 'open') return
const bytes = new Uint8Array(ev.data as ArrayBuffer)
addLinkCount('relayRxFrames')
Expand All @@ -238,6 +255,14 @@ export class RelayCarrier extends CarrierBase implements Carrier {
this.#ws = null
this.#rttMs = null

if (this.#takeFallback(code)) {
// The preferred host never spoke. Dial the relay now, still
// `connecting`: nothing has been open to lose.
this.#attempt = 0
this.#dial()
return
}

let delayMs: number
if (code === CLOSE_ROTATED) {
this.#adoptEpoch(reason)
Expand All @@ -259,6 +284,36 @@ export class RelayCarrier extends CarrierBase implements Carrier {
this.#schedule(delayMs)
}

/** The host has spoken the relay protocol, so the fallback is no longer a candidate. */
#noteProtocol(): void {
this.#protocolSeen = true
this.#fallbackUrl = undefined
}

/**
* Move to the fallback host when this close is not the relay protocol.
*
* Close codes 4400, 4409, 4410 and 4429 are the relay's own, including a
* refusal. Those mean the host is the protocol and a different origin
* would only hide that. An abnormal close before any of them, and before
* `ready` or `gone`, is what an undeployed cloud path looks like.
*/
#takeFallback(code: number): boolean {
if (this.#protocolSeen || !this.#fallbackUrl) return false
if (
code === CLOSE_BAD_JOIN ||
code === CLOSE_EPOCH ||
code === CLOSE_ROTATED ||
code === CLOSE_BUSY
) {
this.#noteProtocol()
return false
}
this.#url = this.#fallbackUrl
this.#fallbackUrl = undefined
return true
}

/**
* Take the relay's announced epoch as a clock correction, never as an order.
*
Expand Down
50 changes: 50 additions & 0 deletions src/lib/identity/origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { CLOUD_URL, RELAY_URL, resolveCarrier } from './origin'

describe('resolveCarrier', () => {
it('keeps the relay when nothing asks for the cloud', () => {
expect(resolveCarrier()).toEqual({ name: 'relay', url: RELAY_URL })
expect(resolveCarrier({ carrier: 'relay' })).toEqual({ name: 'relay', url: RELAY_URL })
expect(resolveCarrier({ carrier: '' }).fallbackUrl).toBeUndefined()
})

it('honours a relay override without growing a fallback', () => {
expect(resolveCarrier({ relayUrl: 'ws://127.0.0.1:8787/' })).toEqual({
name: 'relay',
url: 'ws://127.0.0.1:8787',
})
})

it('prefers the cloud and keeps the relay as the one fallback', () => {
expect(resolveCarrier({ carrier: 'cloud' })).toEqual({
name: 'cloud',
url: CLOUD_URL,
fallbackUrl: RELAY_URL,
})
})

it('lets a build name both hosts', () => {
expect(
resolveCarrier({
carrier: 'cloud',
cloudUrl: 'wss://cloud.example/v1/',
relayUrl: 'wss://relay.example',
})
).toEqual({
name: 'cloud',
url: 'wss://cloud.example/v1',
fallbackUrl: 'wss://relay.example',
})
})

it('does not fall back onto the same origin', () => {
expect(resolveCarrier({ carrier: 'cloud', cloudUrl: RELAY_URL, relayUrl: RELAY_URL })).toEqual({
name: 'relay',
url: RELAY_URL,
})
})

it('treats an unknown flag as the relay', () => {
expect(resolveCarrier({ carrier: 'stockholm' }).name).toBe('relay')
})
})
56 changes: 55 additions & 1 deletion src/lib/identity/origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,64 @@ export const APP_ORIGIN = `https://${APP_HOST}`
export const RP_ID = APP_HOST
export const RP_NAME = 'FTW'

/** The relay. One value, no setting — see CLAUDE.md. */
/** The relay. The default remote path until FTW Cloud speaks these frames. */
export const RELAY_HOST = 'relay.ftw.energy'
export const RELAY_URL = `wss://${RELAY_HOST}`

/**
* FTW Cloud, the host the iPhone app dials (`cloud.ftw.energy` in
* srcfl/ftw-app `Origin.swift` / `CloudEndpoint`).
*
* This app does not open the iPhone's readable session at
* `wss://cloud.ftw.energy/v1/app?home=`. A parallel port of the relay
* protocol onto that host keeps this app's frame format, so the switch is
* which origin the existing `/r/<epoch>/<handle>/app` join uses.
*/
export const CLOUD_HOST = 'cloud.ftw.energy'
export const CLOUD_URL = `wss://${CLOUD_HOST}`

export type CarrierName = 'relay' | 'cloud'

export interface CarrierChoice {
/** Which host the build prefers. `relay` is the default. */
name: CarrierName
/** Origin to dial first, with no path. */
url: string
/**
* The relay, when `url` is the cloud host and the two differ.
*
* Used once, when that host never speaks the relay protocol. Absent means
* stay on `url`, which is today's behaviour.
*/
fallbackUrl?: string
}

/**
* Which host carries reads and commands.
*
* `carrier` is `VITE_CARRIER`: `cloud` prefers {@link CLOUD_URL} and falls
* back to the relay; anything else, including unset, keeps the relay. That
* is the transition default — the cloud path is not deployed until the
* relay frames land on `cloud.ftw.energy`.
*/
export function resolveCarrier(env: {
carrier?: string
cloudUrl?: string
relayUrl?: string
} = {}): CarrierChoice {
const relay = stripSlash(env.relayUrl?.trim() || RELAY_URL)
const cloud = stripSlash(env.cloudUrl?.trim() || CLOUD_URL)
const flag = (env.carrier ?? '').trim().toLowerCase()
if (flag === 'cloud' && cloud !== relay) {
return { name: 'cloud', url: cloud, fallbackUrl: relay }
}
return { name: 'relay', url: flag === 'cloud' ? cloud : relay }
}

function stripSlash(url: string): string {
return url.replace(/\/+$/, '')
}

/**
* Development runs on localhost, where the RP ID must be the literal hostname.
*
Expand Down
26 changes: 26 additions & 0 deletions src/lib/protocol/api-req.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { API_MAX_BYTES, apiReqFields } from './messages'

describe('api.req, as the iPhone app encodes it', () => {
it('sends the ceiling, the method and the path, and nothing empty', () => {
expect(apiReqFields({ method: 'GET', path: '/api/status', query: {}, stepUp: false })).toEqual({
maxBytes: API_MAX_BYTES,
method: 'GET',
path: '/api/status',
})
})

it('sorts query keys and keeps a caller ceiling and a real step-up', () => {
const fields = apiReqFields({
method: 'GET',
path: '/api/energy/history',
query: { scope: 'system', bucket: '1h', since: '1', until: '2' },
maxBytes: 64,
stepUp: true,
})
expect(Object.keys(fields)).toEqual(['maxBytes', 'method', 'path', 'query', 'stepUp'])
expect(Object.keys(fields.query!)).toEqual(['bucket', 'scope', 'since', 'until'])
expect(fields.maxBytes).toBe(64)
expect(fields.stepUp).toBe(true)
})
})
Loading
Loading