From de218785d0a5d94fddfd26fadbb9dc43248d35e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 13:02:52 +0000 Subject: [PATCH] feat: dial FTW Cloud for the same relay frames, relay as fallback VITE_CARRIER=cloud sends the sealed session to wss://cloud.ftw.energy and falls back to relay.ftw.energy when that host does not speak the protocol. The default build still dials the relay. api.req query keys match the iPhone app. Co-authored-by: Fredrik Ahlgren --- .changeset/cloud-carrier-endpoint.md | 5 +++ README.md | 10 +++-- docs/architecture.md | 12 ++++-- public/_headers | 13 ++++--- src/lib/carrier/relay.test.ts | 48 +++++++++++++++++++++++ src/lib/carrier/relay.ts | 57 +++++++++++++++++++++++++++- src/lib/identity/origin.test.ts | 50 ++++++++++++++++++++++++ src/lib/identity/origin.ts | 56 ++++++++++++++++++++++++++- src/lib/protocol/api-req.test.ts | 26 +++++++++++++ src/lib/protocol/messages.ts | 45 ++++++++++++++++++++++ src/lib/protocol/session.ts | 8 ++-- src/lib/state/connect.ts | 33 ++++++++++++---- tests/csp-contract.test.ts | 3 +- 13 files changed, 338 insertions(+), 28 deletions(-) create mode 100644 .changeset/cloud-carrier-endpoint.md create mode 100644 src/lib/identity/origin.test.ts create mode 100644 src/lib/protocol/api-req.test.ts diff --git a/.changeset/cloud-carrier-endpoint.md b/.changeset/cloud-carrier-endpoint.md new file mode 100644 index 0000000..fbf6a2f --- /dev/null +++ b/.changeset/cloud-carrier-endpoint.md @@ -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. diff --git a/README.md b/README.md index 521de6b..7cb0266 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 759ecbe..3196de7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -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 | diff --git a/public/_headers b/public/_headers index 9bb9736..640859f 100644 --- a/public/_headers +++ b/public/_headers @@ -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 diff --git a/src/lib/carrier/relay.test.ts b/src/lib/carrier/relay.test.ts index 2e92c9e..1e2cb06 100644 --- a/src/lib/carrier/relay.test.ts +++ b/src/lib/carrier/relay.test.ts @@ -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() + }) }) diff --git a/src/lib/carrier/relay.ts b/src/lib/carrier/relay.ts index a94b821..06a4801 100644 --- a/src/lib/carrier/relay.ts +++ b/src/lib/carrier/relay.ts @@ -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. */ @@ -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 @@ -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()) @@ -209,6 +223,7 @@ 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 }) @@ -216,11 +231,13 @@ export class RelayCarrier extends CarrierBase implements Carrier { 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') @@ -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) @@ -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. * diff --git a/src/lib/identity/origin.test.ts b/src/lib/identity/origin.test.ts new file mode 100644 index 0000000..4482fc5 --- /dev/null +++ b/src/lib/identity/origin.test.ts @@ -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') + }) +}) diff --git a/src/lib/identity/origin.ts b/src/lib/identity/origin.ts index bddb5b2..83f4932 100644 --- a/src/lib/identity/origin.ts +++ b/src/lib/identity/origin.ts @@ -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///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. * diff --git a/src/lib/protocol/api-req.test.ts b/src/lib/protocol/api-req.test.ts new file mode 100644 index 0000000..7e4f65f --- /dev/null +++ b/src/lib/protocol/api-req.test.ts @@ -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) + }) +}) diff --git a/src/lib/protocol/messages.ts b/src/lib/protocol/messages.ts index f9a66b5..fd2da86 100644 --- a/src/lib/protocol/messages.ts +++ b/src/lib/protocol/messages.ts @@ -451,6 +451,51 @@ export const API_CHUNK_BYTES = 12288 */ export const API_MAX_BYTES = 8 * 1024 * 1024 +/** + * The `api.req` body, in the same field set the iPhone app sends. + * + * `maxBytes`, `method` and `path` always. `query` only when it has entries, + * with keys sorted so the CBOR map matches `APIRequest.cbor` in + * srcfl/ftw-app. `body` only when the caller supplied one. `stepUp` only + * when it is true — a false flag is the absence of a ceremony, which the + * box already treats as "not set". + */ +export function apiReqFields(req: ApiReq): { + maxBytes: number + method: ApiMethod + path: string + query?: Record + body?: Uint8Array + stepUp?: true +} { + const fields: { + maxBytes: number + method: ApiMethod + path: string + query?: Record + body?: Uint8Array + stepUp?: true + } = { + maxBytes: req.maxBytes ?? API_MAX_BYTES, + method: req.method, + path: req.path, + } + const query = sortedQuery(req.query) + if (query) fields.query = query + if (req.body) fields.body = req.body + if (req.stepUp) fields.stepUp = true + return fields +} + +function sortedQuery(query: Record | undefined): Record | undefined { + if (!query) return undefined + const keys = Object.keys(query).sort() + if (keys.length === 0) return undefined + const out: Record = {} + for (const key of keys) out[key] = query[key]! + return out +} + /** * What the passthrough will carry: JSON and text, and nothing else. * diff --git a/src/lib/protocol/session.ts b/src/lib/protocol/session.ts index eccae3d..7e7f540 100644 --- a/src/lib/protocol/session.ts +++ b/src/lib/protocol/session.ts @@ -48,7 +48,7 @@ import { type ApiEnd, type Role, ROLE_OWNER, - API_MAX_BYTES, + apiReqFields, CMD_ACK_TIMEOUT_MS, CMD_CONFIRM_TIMEOUT_MS, } from './messages' @@ -678,16 +678,16 @@ export class Session { // Sent before anything is registered: a payload the largest bucket // cannot carry throws out of the executor, rejecting the promise with // no entry and no timer left behind for the deadline to sweep up. + const fields = apiReqFields(req) this.#sendBulk({ t: 'api.req', id, b: { - maxBytes: API_MAX_BYTES, - ...req, + ...fields, // The caller encoded this with a TextEncoder, and a typed array // from another realm is written as a list of numbers rather than a // byte string. See wireBytes. - ...(req.body ? { body: wireBytes(req.body) } : {}), + ...(fields.body ? { body: wireBytes(fields.body) } : {}), }, }) diff --git a/src/lib/state/connect.ts b/src/lib/state/connect.ts index 3846257..1dc8793 100644 --- a/src/lib/state/connect.ts +++ b/src/lib/state/connect.ts @@ -1,6 +1,6 @@ /* Building the carrier stack for a paired site. * - * Session → NoiseCarrier → RelayCarrier → the relay + * Session → NoiseCarrier → RelayCarrier → the relay, or FTW Cloud * * Everything above the session sees state; everything below the Noise carrier * sees opaque bytes. This file is the one place the three are wired together, @@ -19,15 +19,31 @@ import { isEnrolled, } from '$lib/identity/vault' import { db, type StoredSite } from '$lib/store/db' -import { RELAY_URL as DEFAULT_RELAY_URL } from '$lib/identity/origin' +import { resolveCarrier, RELAY_URL as DEFAULT_RELAY_URL } from '$lib/identity/origin' import { markLinkPhase } from '$lib/perf/link' -/** Overridable only for development against a local relay. */ +/** + * The host this build dials first. + * + * `VITE_RELAY_URL` still overrides the relay, including when it is the + * fallback. `VITE_CARRIER=cloud` prefers `VITE_CLOUD_URL` or + * `wss://cloud.ftw.energy` and falls back to the relay when that host does + * not speak the relay protocol. Unset keeps the relay, which is the + * behaviour until the cloud path is deployed. + */ +const CARRIER = resolveCarrier({ + carrier: import.meta.env['VITE_CARRIER'], + cloudUrl: import.meta.env['VITE_CLOUD_URL'], + relayUrl: import.meta.env['VITE_RELAY_URL'] ?? DEFAULT_RELAY_URL, +}) + +/** The relay origin, after `VITE_RELAY_URL`. Not necessarily the host we dial. */ export const RELAY_URL = import.meta.env['VITE_RELAY_URL'] ?? DEFAULT_RELAY_URL export interface ConnectOptions { /** Injected by tests. Defaults to a real relay carrier. */ - makeInner?: (opts: { url: string; secret: Uint8Array }) => Carrier + makeInner?: (opts: { url: string; fallbackUrl?: string; secret: Uint8Array }) => Carrier + /** Pins one host and disables the cloud fallback. Tests use this. */ relayUrl?: string } @@ -104,10 +120,13 @@ export async function connectToSite(siteId: string, opts: ConnectOptions = {}): const device = await deviceKey(vault, wrapping) markLinkPhase('keys-ready') - const relayUrl = opts.relayUrl ?? RELAY_URL + // A caller that names a host wants that host. The cloud fallback belongs + // to the build's own choice, not to a test relay. + const relayUrl = opts.relayUrl ?? CARRIER.url + const fallbackUrl = opts.relayUrl ? undefined : CARRIER.fallbackUrl const inner = opts.makeInner - ? opts.makeInner({ url: relayUrl, secret }) - : new RelayCarrier({ url: relayUrl, secret }) + ? opts.makeInner({ url: relayUrl, ...(fallbackUrl ? { fallbackUrl } : {}), secret }) + : new RelayCarrier({ url: relayUrl, ...(fallbackUrl ? { fallbackUrl } : {}), secret }) return new NoiseCarrier({ inner, diff --git a/tests/csp-contract.test.ts b/tests/csp-contract.test.ts index fa4a41c..06fbbe8 100644 --- a/tests/csp-contract.test.ts +++ b/tests/csp-contract.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' import { ESCROW_ORIGIN } from '../src/lib/identity/escrow' -import { RELAY_HOST } from '../src/lib/identity/origin' +import { CLOUD_HOST, RELAY_HOST } from '../src/lib/identity/origin' function connectSrc(): string { const headers = readFileSync('public/_headers', 'utf8') @@ -27,6 +27,7 @@ describe('the connect-src list', () => { const list = connectSrc() expect(list, 'the escrow is unreachable from a browser').toContain(ESCROW_ORIGIN) expect(list, 'the relay is unreachable from a browser').toContain(RELAY_HOST) + expect(list, 'FTW Cloud is unreachable from a browser').toContain(CLOUD_HOST) }) it('never grows a wildcard', () => {