diff --git a/build.js b/build.js index ea598dc..9a6481d 100644 --- a/build.js +++ b/build.js @@ -24,7 +24,10 @@ async function build() { }, minify: !isDev, sourcemap: isDev ? 'inline' : false, - external: [], + // Native keyring bindings cannot be inlined. npm installs resolve this + // normally; release archives copy the current platform's @napi-rs tree + // beside the bundle so Node can load the OS credential backend. + external: ['@napi-rs/keyring'], logLevel: 'info', // Remove shebangs from source files during bundling loader: { diff --git a/config.json.example b/config.json.example index db496f4..df8b0d1 100644 --- a/config.json.example +++ b/config.json.example @@ -19,13 +19,16 @@ "privateKey": "YOUR_ED25519_PRIVATE_KEY__base64_PKCS8_DER_recommended__or_32byte_raw_seed_as_hex_or_base64__run_ff-cli_setup_to_generate", "role": "agent" }, + "ff1Relayer": { + "baseUrl": "https://tv-cast-coordination.autonomy-system.workers.dev" + }, "ff1Devices": { "devices": [ { "name": "DISPLAY_NAME_FOR_YOUR_FF1_DEVICE", + "id": "FF1-DEVICE_ID", "host": "http://YOUR_FF1_IP_ADDRESS:1111", - "apiKey": "YOUR_FF1_DEVICE_API_KEY_FROM_DEVICE_SETTINGS", - "topicID": "YOUR_FF1_DEVICE_TOPIC_ID_FROM_DEVICE_SETTINGS" + "apiKey": "YOUR_FF1_DEVICE_API_KEY_FROM_DEVICE_SETTINGS" } ] } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 18b610d..4d4fabe 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -120,6 +120,9 @@ Configure devices you want to play content on. - `ff1Devices.devices` (array of objects): - `name` (string): Friendly device label. Free‑form; pick anything memorable. + - `id` (string): Stable physical ID discovered from FF1 (for example + `FF1-SKYZ2E3A`). Relayer pairing stores the topic under this ID in the OS + credential vault. - `host` (string): Device base URL. For LAN devices, use `http://:1111`. The device typically listens on port `1111`. During `ff-cli setup`, the CLI will attempt local discovery via mDNS (`_ff1._tcp`). If devices are found, you can pick one and the host will be filled in automatically. If discovery returns nothing, setup falls back to manual entry. @@ -127,15 +130,20 @@ During `ff-cli setup`, the CLI will attempt local discovery via mDNS (`_ff1._tcp > **mDNS is best-effort.** It frequently does not cross subnets — a common case with mesh routers (e.g. eero) that put wired and Wi-Fi clients on different `/24`s. If discovery comes up empty even though the device is reachable by IP, skip it entirely and add the device directly: > > ```bash -> ff-cli device add --host http://:1111 --name +> ff-cli device add --host http://:1111 --name --id FF1-XXXXXXXX > ``` You can also manage devices independently with: -- `ff-cli device add` – Add a device interactively (with mDNS discovery), or non-interactively with `--host` and `--name`. +- `ff-cli device add` – Add a device interactively (with mDNS discovery), or + non-interactively with `--host`, `--name`, and the physical `--id` shown in + Feral File mobile. Manual devices need the ID before relayer pairing. - `ff-cli device list` – Show all configured devices. - `ff-cli device remove ` – Remove a device by name. - `ff-cli device default ` – Promote a device to the top of the list so it is used when `-d` is omitted. +- `ff-cli device pair [name]` – Display a one-time handoff code and key-bound + security check, then securely receive that device's relayer topic from Feral + File mobile. Confirm both values match before approving in mobile. Setup and `device add` both preserve existing devices. Adding a device with the same host as an existing one updates it in place. @@ -164,6 +172,26 @@ Content-Type: application/json The `intent` field is **required** — without it the device returns `{"message":{"ok":false}}` with no further explanation. `dp1_call` may be the playlist inline (as above) or a hosted playlist URL string; ff-cli sends the resolved playlist object. +When LAN delivery is unreachable, `play` prints an explicit notice and retries +the same command through `ff1Relayer.baseUrl`. The per-device topic is read from +the operating system credential vault, never `config.json`. Pair it from the +mobile app first: + +```bash +ff-cli device pair office +``` + +The CLI and mobile confirmation both show the same 12-hex-digit security check. +Comparing it authenticates the CLI public key out of band, so an active broker +cannot silently substitute its own encryption key. + +The default relayer is +`https://tv-cast-coordination.autonomy-system.workers.dev`. Override it with +`ff1Relayer.baseUrl` or `FF1_RELAYER_URL`. Signed topic-only relayer deployments +need no additional key. Deployments that retain the cloud-edge compatibility +gate may receive `ff1Relayer.apiKey` or `FF1_RELAYER_API_KEY`; ff-cli never +bundles that shared key. + On the first cast after boot the device may return an empty body. ff-cli retries this automatically and, if it persists, reports a clear "accepted the request but returned no usable response" error rather than crashing on a JSON parse. Compatibility checks: @@ -207,6 +235,7 @@ Minimal `config.json` example (selected fields): "devices": [ { "name": "Living Room Display", + "id": "FF1-ABCD1234", "host": "http://192.168.1.100:1111" } ] diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md index e57c37b..8cb2bbc 100644 --- a/docs/PROJECT_SPEC.md +++ b/docs/PROJECT_SPEC.md @@ -110,6 +110,12 @@ Based on the code today, the CLI is responsible for: - signing playlists when a private key is configured - publishing validated playlists to configured feed servers - discovering configured FF1 devices and sending playlists or direct media playback requests +- pairing a device's signed relayer topic through a short-lived end-to-end + encrypted mobile handoff, authenticating the CLI key with a user-compared + security check, and storing it in the operating system credential vault +- falling back from unreachable LAN delivery to the shared FF1 relayer with an + explicit terminal notice, so sandboxed and cross-subnet control remains + observable to the mobile app - performing FF1 OS compatibility preflight checks before display and SSH flows ## Architecture boundaries diff --git a/docs/README.md b/docs/README.md index d69bb1b..c9737bf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -117,8 +117,9 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1 - `ssh ` – Manage SSH access on an FF1 device - Options: `-d, --device `, `--pubkey `, `--ttl ` - `device list` – List all configured FF1 devices -- `device add` – Add a new FF1 device (with mDNS discovery) - - Options: `--host `, `--name ` +- `device add` – Add a device with mDNS or manual `--host`, `--name`, and `--id` + - Options: `--host `, `--name `, `--id ` +- `device pair [name]` – Securely receive relayer access from Feral File mobile - `device remove ` – Remove a configured FF1 device - `device default ` – Set the default FF1 device (used when `-d` is omitted) - `config ` – Manage configuration @@ -271,7 +272,7 @@ ff-cli device list ff-cli device add # Add a device non-interactively -ff-cli device add --host 192.168.1.100 --name kitchen +ff-cli device add --host 192.168.1.100 --name kitchen --id FF1-XXXXXXXX # Remove a device by name ff-cli device remove kitchen diff --git a/package-lock.json b/package-lock.json index 229ce32..e245ea4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@feralfile/source-resolver": "git+https://github.com/feral-file/ff-source-resolver.git#54be943014c8cf7860114510257cdd713bbf4e79", + "@napi-rs/keyring": "1.3.0", "axios": "^1.6.2", "bonjour-service": "^1.3.0", "chalk": "^4.1.2", @@ -692,6 +693,240 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "license": "MIT" }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", diff --git a/package.json b/package.json index 94090aa..abe84ed 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "author": "", "license": "MIT", "dependencies": { + "@napi-rs/keyring": "1.3.0", "@feralfile/source-resolver": "git+https://github.com/feral-file/ff-source-resolver.git#54be943014c8cf7860114510257cdd713bbf4e79", "axios": "^1.6.2", "bonjour-service": "^1.3.0", diff --git a/scripts/release/build-asset-windows.ps1 b/scripts/release/build-asset-windows.ps1 index 86f1c21..9e92140 100644 --- a/scripts/release/build-asset-windows.ps1 +++ b/scripts/release/build-asset-windows.ps1 @@ -93,6 +93,8 @@ try { Copy-Item (Join-Path $ROOT_DIR "dist\ff-cli.js") (Join-Path $PACKAGE_DIR "lib\ff-cli.js") Copy-Item (Join-Path $ROOT_DIR "package.json") (Join-Path $PACKAGE_DIR "package.json") Copy-Item (Join-Path $ROOT_DIR "LICENSE") (Join-Path $PACKAGE_DIR "LICENSE") + New-Item -ItemType Directory -Path (Join-Path $PACKAGE_DIR "lib\node_modules") -Force | Out-Null + Copy-Item -Recurse (Join-Path $ROOT_DIR "node_modules\@napi-rs") (Join-Path $PACKAGE_DIR "lib\node_modules\@napi-rs") $ffCliCmd = @" @echo off diff --git a/scripts/release/build-asset.sh b/scripts/release/build-asset.sh index 32b940a..9c367d3 100644 --- a/scripts/release/build-asset.sh +++ b/scripts/release/build-asset.sh @@ -58,6 +58,11 @@ cp "$ROOT_DIR/LICENSE" "$PACKAGE_DIR/LICENSE" # can seed a config. The installer extracts this to the package root, which the # CLI resolves via a `/lib/../config.json.example` lookup candidate. cp "$ROOT_DIR/config.json.example" "$PACKAGE_DIR/config.json.example" +# The single-file bundle keeps @napi-rs/keyring external because it loads a +# platform-native .node binary. npm installs only the current platform package, +# so copying this namespace keeps release archives small and functional. +mkdir -p "$PACKAGE_DIR/lib/node_modules" +cp -R "$ROOT_DIR/node_modules/@napi-rs" "$PACKAGE_DIR/lib/node_modules/@napi-rs" cat > "$PACKAGE_DIR/bin/ff-cli" <<'EOF' #!/usr/bin/env bash diff --git a/src/commands/device.ts b/src/commands/device.ts index dcd62a3..3dce8e0 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -8,9 +8,62 @@ import { promoteDeviceToDefault } from '../utilities/device-default'; import { readConfigFile, resolveExistingConfigPath } from './helpers/config-files'; import { discoverAndSelectDevice } from './helpers/device-discovery'; import { createPrompt, promptYesNo } from './helpers/prompt'; +import { resolveConfiguredDevice } from '../utilities/ff1-compatibility'; +import { TopicHandoffReceiver } from '../utilities/handoff-client'; +import { storeTopicId } from '../utilities/topic-store'; +import { persistDeviceRemoval } from './helpers/device-removal'; const deviceCommand = new Command('device').description('Manage configured FF1 devices'); +deviceCommand + .command('pair') + .description('Securely receive relayer access for a configured FF1 from the mobile app') + .argument('[name]', 'Configured device name (uses the default device when omitted)') + .action(async (name?: string) => { + let receiver: TopicHandoffReceiver | undefined; + try { + const selected = resolveConfiguredDevice(name); + if (!selected.success || !selected.device) { + throw new Error(selected.error || 'FF1 device is not configured correctly'); + } + const device = selected.device; + if (!device.id) { + throw new Error( + `Device "${device.name || device.host}" has no stable device ID. Re-add it with ff-cli device add before pairing.` + ); + } + + receiver = await TopicHandoffReceiver.create(); + console.log(chalk.blue('\nPair ff-cli with Feral File mobile\n')); + console.log(` Device: ${chalk.bold(device.name || device.id)}`); + console.log(` Device ID: ${chalk.dim(device.id)}`); + console.log("\nIn the mobile app, open this Art Computer's options and choose"); + console.log(chalk.bold('“Pair with ff-cli”'), 'then enter:'); + console.log(`\n ${chalk.bold(receiver.shortCode)}\n`); + console.log('Before sharing, confirm the mobile security check matches:'); + console.log(`\n ${chalk.bold(receiver.verificationCode)}\n`); + console.log(chalk.dim(`This one-time code expires at ${receiver.expiresAt}. Waiting...`)); + + const payload = await receiver.receiveTopic({ expectedDeviceId: device.id }); + storeTopicId(device.id, payload.topicId); + console.log( + chalk.green('\n✓ Relayer access stored in the operating system secure storage.\n') + ); + } catch (error) { + console.error(chalk.red('\nPairing failed:'), (error as Error).message); + process.exitCode = 1; + } finally { + if (receiver) { + try { + await receiver.close(); + } catch { + // The channel is short-lived and expires automatically. A close + // failure must not erase or misreport an already stored credential. + } + } + } + }); + deviceCommand .command('list') .description('List all configured FF1 devices') @@ -43,8 +96,8 @@ deviceCommand if (device.apiKey) { console.log(` API key: ${chalk.green('Set')}`); } - if (device.topicID) { - console.log(` Topic: ${chalk.dim(device.topicID)}`); + if (device.id) { + console.log(` Device ID: ${chalk.dim(device.id)}`); } if (isFirst) { console.log(` ${chalk.dim('(default)')}`); @@ -62,7 +115,8 @@ deviceCommand .description('Add a new FF1 device (with mDNS discovery)') .option('--host ', 'Device host (skip discovery)') .option('--name ', 'Device name') - .action(async (options: { host?: string; name?: string }) => { + .option('--id ', 'Stable FF1 device ID (required for manual relayer pairing)') + .action(async (options: { host?: string; name?: string; id?: string }) => { // Lazy prompt: non-interactive paths (--host + --name) must never block on stdin. let prompt: ReturnType | null = null; const ask = async (question: string): Promise => { @@ -90,7 +144,14 @@ deviceCommand let hostValue = ''; let discoveredName = ''; - let discoveredId: string | undefined; + const suppliedId = options.id?.trim().toUpperCase(); + if (suppliedId && !/^FF1-[A-Z0-9]{4,}$/.test(suppliedId)) { + throw new Error('Device ID must use the FF1-XXXXXXXX format'); + } + if (suppliedId && !options.host) { + throw new Error('--id is only needed with --host; discovered devices provide their ID'); + } + let discoveredId: string | undefined = suppliedId; let discoveredAddresses: string[] | undefined; if (options.host) { @@ -261,11 +322,12 @@ deviceCommand process.exit(1); } - const removed = existingDevices[deviceIndex]; - const updatedDevices = existingDevices.filter((_, i) => i !== deviceIndex); - config.ff1Devices = { devices: updatedDevices }; - - await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); + const { removed, updatedDevices } = await persistDeviceRemoval(config, deviceIndex, { + persistConfig: async (updatedConfig) => { + await fs.writeFile(configPath, `${JSON.stringify(updatedConfig, null, 2)}\n`, 'utf-8'); + }, + warn: (message) => console.warn(chalk.yellow(message)), + }); console.log(chalk.green(`\nRemoved device: ${removed.name || removed.host}`)); console.log(chalk.dim(`Remaining devices: ${updatedDevices.length}\n`)); } catch (error) { diff --git a/src/commands/helpers/device-discovery.ts b/src/commands/helpers/device-discovery.ts index 75c03a3..921acfb 100644 --- a/src/commands/helpers/device-discovery.ts +++ b/src/commands/helpers/device-discovery.ts @@ -13,6 +13,25 @@ export interface DeviceDiscoverySelection { skipped: boolean; } +/** Resolve manual input while retaining a stable ID for later relayer pairing. */ +export function resolveManualDeviceInput(rawInput: string): DeviceDiscoverySelection { + const trimmed = rawInput.trim(); + if (!trimmed) { + return { hostValue: '', discoveredName: '', skipped: false }; + } + const lower = trimmed.toLowerCase(); + const looksLikeHost = lower.includes('.') || lower.includes(':') || lower.startsWith('http'); + const discoveredId = looksLikeHost + ? undefined + : (lower.startsWith('ff1-') ? lower : `ff1-${lower}`).toUpperCase(); + return { + hostValue: normalizeDeviceIdToHost(trimmed), + discoveredName: '', + discoveredId, + skipped: false, + }; +} + /** * Run mDNS discovery and prompt the user to pick a device, fall back to * manual entry, or skip when an existing device is already configured. @@ -38,7 +57,7 @@ export async function discoverAndSelectDevice( console.log( chalk.dim( ' Tip: you can skip discovery entirely with ' + - 'ff-cli device add --host http://:1111 --name ' + 'ff-cli device add --host http://:1111 --name --id FF1-XXXXXXXX' ) ); } else if (discoveryResult.error) { @@ -144,7 +163,8 @@ export async function discoverAndSelectDevice( console.log( chalk.dim( ' Tip: mDNS often does not cross subnets. If the device is reachable by IP, ' + - 'add it directly: ff-cli device add --host http://:1111 --name ' + 'add it directly: ff-cli device add --host http://:1111 ' + + '--name --id FF1-XXXXXXXX' ) ); } @@ -154,5 +174,5 @@ export async function discoverAndSelectDevice( if (!idAnswer) { return { hostValue: '', discoveredName: '', skipped: false }; } - return { hostValue: normalizeDeviceIdToHost(idAnswer), discoveredName: '', skipped: false }; + return resolveManualDeviceInput(idAnswer); } diff --git a/src/commands/helpers/device-removal.ts b/src/commands/helpers/device-removal.ts new file mode 100644 index 0000000..f924640 --- /dev/null +++ b/src/commands/helpers/device-removal.ts @@ -0,0 +1,59 @@ +import type { Config, FF1Device } from '../../types'; +import { deleteTopicId, getTopicId } from '../../utilities/topic-store'; + +interface DeviceRemovalDependencies { + persistConfig: (config: Config) => Promise; + getTopicId?: (deviceId: string) => string | null; + deleteTopicId?: (deviceId: string) => boolean; + warn?: (message: string) => void; +} + +interface DeviceRemovalResult { + removed: FF1Device; + updatedDevices: FF1Device[]; +} + +/** + * persistDeviceRemoval saves the authoritative config change before attempting + * best-effort credential cleanup. + * + * The operating-system credential vault can be locked or temporarily + * unavailable. That must not trap a stale device in config.json; the user can + * remove the orphaned credential later after the vault becomes available. + * + * @param {Config} config - Parsed CLI configuration to update + * @param {number} deviceIndex - Index of the configured device to remove + * @param {DeviceRemovalDependencies} dependencies - Persistence and credential operations + * @returns {Promise} Removed device and remaining device list + */ +export async function persistDeviceRemoval( + config: Config, + deviceIndex: number, + dependencies: DeviceRemovalDependencies +): Promise { + const existingDevices = config.ff1Devices?.devices ?? []; + const removed = existingDevices[deviceIndex]; + if (!removed) { + throw new Error('Configured FF1 device no longer exists'); + } + + const updatedDevices = existingDevices.filter((_, index) => index !== deviceIndex); + config.ff1Devices = { devices: updatedDevices }; + await dependencies.persistConfig(config); + + if (removed.id) { + try { + const readTopic = dependencies.getTopicId ?? getTopicId; + const deleteTopic = dependencies.deleteTopicId ?? deleteTopicId; + if (readTopic(removed.id)) { + deleteTopic(removed.id); + } + } catch { + (dependencies.warn ?? console.warn)( + 'Warning: device was removed, but its secure topic credential could not be deleted.' + ); + } + } + + return { removed, updatedDevices }; +} diff --git a/src/config.ts b/src/config.ts index 953968a..96fa009 100644 --- a/src/config.ts +++ b/src/config.ts @@ -209,7 +209,7 @@ export function getFeedConfig(): { * @returns {Array} returns.devices - Array of configured FF1 devices * @returns {string} returns.devices[].host - Device host URL * @returns {string} [returns.devices[].apiKey] - Optional device API key - * @returns {string} [returns.devices[].topicID] - Optional device topic ID + * @returns {string} [returns.devices[].id] - Optional stable physical device ID * @returns {string} [returns.devices[].name] - Optional device name */ export function getFF1DeviceConfig(): FF1DeviceConfig { @@ -221,6 +221,59 @@ export function getFF1DeviceConfig(): FF1DeviceConfig { }; } +const DEFAULT_FF1_RELAYER_URL = 'https://tv-cast-coordination.autonomy-system.workers.dev'; + +/** + * Get the FF1 relayer endpoint using repository config priority. + * + * `config.json` wins over environment variables, which win over the production + * default. The API key remains optional because the signed topic is the + * per-device credential; it exists only for deployments that retain a separate + * cloud-edge compatibility gate. + */ +export function getFF1RelayerConfig(): { baseUrl: string; apiKey?: string } { + return resolveFF1RelayerConfig(getConfig()); +} + +/** Resolve and validate the effective FF1 relayer configuration. */ +function resolveFF1RelayerConfig(config: Config): { baseUrl: string; apiKey?: string } { + const configured = config.ff1Relayer as unknown; + if ( + configured !== undefined && + (configured === null || typeof configured !== 'object' || Array.isArray(configured)) + ) { + throw new Error('ff1Relayer must be an object'); + } + const fields = (configured || {}) as Record; + if (fields.baseUrl !== undefined && typeof fields.baseUrl !== 'string') { + throw new Error('ff1Relayer.baseUrl must be a string'); + } + if (fields.apiKey !== undefined && typeof fields.apiKey !== 'string') { + throw new Error('ff1Relayer.apiKey must be a string'); + } + const baseUrl = ( + (fields.baseUrl as string | undefined) || + process.env.FF1_RELAYER_URL || + DEFAULT_FF1_RELAYER_URL + ) + .trim() + .replace(/\/$/, ''); + let parsed: URL; + try { + parsed = new URL(baseUrl); + } catch { + throw new Error('ff1Relayer.baseUrl must be a valid HTTP(S) URL'); + } + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('ff1Relayer.baseUrl must be a valid HTTP(S) URL'); + } + const apiKey = ((fields.apiKey as string | undefined) || process.env.FF1_RELAYER_API_KEY)?.trim(); + return { + baseUrl, + apiKey: apiKey || undefined, + }; +} + /** * Validate configuration * @@ -282,6 +335,12 @@ export function validateConfig(): ValidationResult { } } + try { + resolveFF1RelayerConfig(config); + } catch (error) { + errors.push((error as Error).message); + } + return { valid: errors.length === 0, errors, diff --git a/src/types.ts b/src/types.ts index 9b12bd7..13b37f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,14 +26,22 @@ export interface FeedServer { export interface FF1Device { host: string; apiKey?: string; - topicID?: string; name?: string; + /** Stable physical FF1 identifier used as the secure topic-store account. */ + id?: string; + addresses?: string[]; } export interface FF1DeviceConfig { devices: FF1Device[]; } +export interface FF1RelayerConfig { + baseUrl?: string; + /** Optional deployment compatibility gate; never required by the topic credential model. */ + apiKey?: string; +} + export interface IndexerConfig { endpoint: string; apiKey?: string; @@ -52,6 +60,7 @@ export interface Config { feedServers?: FeedServer[]; // New: array of feed servers with individual API keys playlist?: PlaylistConfig; ff1Devices?: FF1DeviceConfig; + ff1Relayer?: FF1RelayerConfig; indexer?: IndexerConfig; } diff --git a/src/utilities/device-lookup.ts b/src/utilities/device-lookup.ts index 6120679..924ee29 100644 --- a/src/utilities/device-lookup.ts +++ b/src/utilities/device-lookup.ts @@ -28,10 +28,12 @@ export function findExistingDeviceEntry( discoveredId?: string, discoveredAddresses?: string[] ): { host?: string; name?: string; id?: string; addresses?: string[] } | undefined { + const sameDeviceId = (left?: string, right?: string) => + Boolean(left && right && left.trim().toUpperCase() === right.trim().toUpperCase()); // 1. mDNS device ID — stable hardware identity, checked before URL so stale // host entries for other devices at the same IP do not shadow the result. if (discoveredId) { - const byId = existingDevices.find((d) => d.id === discoveredId); + const byId = existingDevices.find((d) => sameDeviceId(d.id, discoveredId)); if (byId) { return byId; } @@ -75,7 +77,7 @@ export function findExistingDeviceEntry( // the stored entry has an id; the address is the best identity signal available. if (discoveredAddresses && discoveredAddresses.length > 0) { const byDiscoveredAddress = existingDevices.find((d) => { - if (d.id && discoveredId && d.id !== discoveredId) { + if (d.id && discoveredId && !sameDeviceId(d.id, discoveredId)) { return false; // different physical devices — do not conflate } try { diff --git a/src/utilities/device-upsert.ts b/src/utilities/device-upsert.ts index a2f815a..0ee2588 100644 --- a/src/utilities/device-upsert.ts +++ b/src/utilities/device-upsert.ts @@ -4,7 +4,6 @@ export interface DeviceEntry { /** mDNS device ID (e.g. 'ff1-hh9jsnoc'). Stored so host-change lookups can match by ID. */ id?: string; apiKey?: string; - topicID?: string; /** Resolved IP addresses last observed for this device. Stored so --host can match * an existing .local entry without requiring a new mDNS scan. */ addresses?: string[]; @@ -44,7 +43,17 @@ function applyPatch(existing: DeviceEntry, patch: Partial): DeviceE } else { addresses = existing.addresses; // same host, no new IPs: keep stored set } - return { ...existing, ...patch, addresses }; + const updated = { ...existing, ...patch }; + // Rebuild the row from supported fields so an old plaintext topicID cannot + // survive a normal device update. Topics now belong exclusively in the OS + // credential vault under the stable device ID. + return { + host: updated.host, + name: updated.name, + id: updated.id, + apiKey: updated.apiKey, + addresses, + }; } /** @@ -67,7 +76,6 @@ export function upsertDevice( host: string; id?: string; apiKey?: string; - topicID?: string; addresses?: string[]; }, /** Pre-resolved row index from findExistingDeviceEntry. When provided, the @@ -75,7 +83,11 @@ export function upsertDevice( matchedIndex?: number ): { devices: DeviceEntry[]; updated: boolean } { const devices = [...existingDevices]; - const patch = withoutUndefined(newDevice); + const normalizedNewDevice = { + ...newDevice, + id: newDevice.id?.trim().toUpperCase(), + }; + const patch = withoutUndefined(normalizedNewDevice); // Case 0: caller already resolved the match — update directly. if (matchedIndex !== undefined && matchedIndex >= 0 && matchedIndex < devices.length) { @@ -85,8 +97,10 @@ export function upsertDevice( } // Case 1: same mDNS device ID — update in-place even when host changed. - if (newDevice.id) { - const sameIdIndex = devices.findIndex((d) => d.id === newDevice.id); + if (normalizedNewDevice.id) { + const sameIdIndex = devices.findIndex( + (d) => d.id?.trim().toUpperCase() === normalizedNewDevice.id + ); if (sameIdIndex !== -1) { const isSameHost = devices[sameIdIndex].host === newDevice.host; devices[sameIdIndex] = applyPatch(devices[sameIdIndex], patch); @@ -102,7 +116,7 @@ export function upsertDevice( } // Case 3: same name, different host — replace in-place to preserve array order. - // Spread existing entry first so apiKey/topicID survive a host change. + // Spread existing entry first so non-secret device metadata survives a host change. const staleNameIndex = devices.findIndex((d) => d.name === newDevice.name); if (staleNameIndex !== -1) { devices[staleNameIndex] = applyPatch(devices[staleNameIndex], patch); diff --git a/src/utilities/ff1-device.ts b/src/utilities/ff1-device.ts index 4832219..9836825 100644 --- a/src/utilities/ff1-device.ts +++ b/src/utilities/ff1-device.ts @@ -4,8 +4,11 @@ */ import * as logger from '../logger'; -import type { Playlist } from '../types'; +import { getFF1RelayerConfig } from '../config'; +import type { FF1Device, Playlist } from '../types'; import { assertFF1CommandCompatibility, resolveConfiguredDevice } from './ff1-compatibility'; +import { sendPlaylistViaRelayer } from './ff1-relayer'; +import { getTopicId } from './topic-store'; const SEND_RETRY_ATTEMPTS = 3; const SEND_RETRY_BASE_DELAY_MS = 750; @@ -27,6 +30,35 @@ interface SendPlaylistResult { message?: string; error?: string; details?: string; + transport?: 'lan' | 'relayer'; +} + +export interface FF1DeviceDependencies { + fetchFn?: typeof fetch; + getTopicIdFn?: (deviceId: string) => string | null; + getRelayerConfigFn?: () => { baseUrl: string; apiKey?: string }; + waitFn?: (delayMs: number) => Promise; + noticeFn?: (message: string) => void; +} + +interface DeliveryDependencies { + fetchFn: typeof fetch; + getTopicIdFn: (deviceId: string) => string | null; + getRelayerConfigFn: () => { baseUrl: string; apiKey?: string }; + waitFn: (delayMs: number) => Promise; + noticeFn: (message: string) => void; +} + +/** Find an FF1 `ok` flag through the nested cast response envelopes. */ +function nestedCastOk(value: unknown): boolean | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const record = value as Record; + if (typeof record.ok === 'boolean') { + return record.ok; + } + return nestedCastOk(record.message); } /** @@ -156,11 +188,18 @@ export function parseRetryAfterMs(headerValue: string | null | undefined): numbe * deviceName: 'Living Room Display' * }); */ -export async function sendPlaylistToDevice({ - playlist, - deviceName, -}: SendPlaylistParams): Promise { - let device: { host: string; name?: string; apiKey?: string; topicID?: string } | undefined; +export async function sendPlaylistToDevice( + { playlist, deviceName }: SendPlaylistParams, + overrides: FF1DeviceDependencies = {} +): Promise { + const dependencies: DeliveryDependencies = { + fetchFn: overrides.fetchFn ?? globalThis.fetch.bind(globalThis), + getTopicIdFn: overrides.getTopicIdFn ?? getTopicId, + getRelayerConfigFn: overrides.getRelayerConfigFn ?? getFF1RelayerConfig, + waitFn: overrides.waitFn ?? waitForRetry, + noticeFn: overrides.noticeFn ?? logger.always, + }; + let device: FF1Device | undefined; try { // Validate input if (!playlist || typeof playlist !== 'object') { @@ -179,7 +218,9 @@ export async function sendPlaylistToDevice({ } device = resolved.device; - const compatibility = await assertFF1CommandCompatibility(device, 'displayPlaylist'); + const compatibility = await assertFF1CommandCompatibility(device, 'displayPlaylist', { + fetchFn: dependencies.fetchFn, + }); if (!compatibility.compatible) { return { success: false, @@ -190,12 +231,7 @@ export async function sendPlaylistToDevice({ logger.info(`Sending playlist to FF1 device: ${device.host}`); - // Construct API URL with optional topicID - let apiUrl = `${device.host}/api/cast`; - if (device.topicID && device.topicID.trim() !== '') { - apiUrl += `?topicID=${encodeURIComponent(device.topicID)}`; - logger.debug(`Using topicID: ${device.topicID}`); - } + const apiUrl = `${device.host}/api/cast`; // Wrap playlist in required structure const requestBody = { @@ -226,7 +262,7 @@ export async function sendPlaylistToDevice({ let lastBodyIssue: string | null = null; for (let attempt = 1; attempt <= SEND_RETRY_ATTEMPTS; attempt += 1) { try { - response = await fetch(apiUrl, { + response = await dependencies.fetchFn(apiUrl, { method: 'POST', headers, body: JSON.stringify(requestBody), @@ -245,7 +281,7 @@ export async function sendPlaylistToDevice({ `Device rate-limited the command (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}); retrying in ${retryDelay}ms` ); response = null; - await waitForRetry(retryDelay); + await dependencies.waitFn(retryDelay); continue; } @@ -277,7 +313,7 @@ export async function sendPlaylistToDevice({ `${lastBodyIssue} (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}); retrying in ${retryDelay}ms` ); response = null; - await waitForRetry(retryDelay); + await dependencies.waitFn(retryDelay); continue; } // Out of attempts; let the post-loop handler report the body issue. @@ -293,19 +329,24 @@ export async function sendPlaylistToDevice({ `Transient network error while sending playlist (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}): ${(error as Error).message}` ); logger.debug(`Retrying playlist send in ${retryDelay}ms`); - await waitForRetry(retryDelay); + await dependencies.waitFn(retryDelay); } } if (!response) { const deviceLabel = device.name || device.host; - return { - success: false, - error: `Could not reach device "${deviceLabel}" at ${device.host}`, - details: - 'Check that the device is powered on and reachable on your network. ' + - 'If the device IP changed (e.g. after a factory reset), run: ff-cli setup', - }; + return fallbackToRelayer({ + playlist, + device, + dependencies, + lanFailure: { + success: false, + error: `Could not reach device "${deviceLabel}" at ${device.host}`, + details: + 'Check that the device is powered on and reachable on your network. ' + + 'If the device IP changed (e.g. after a factory reset), run: ff-cli setup', + }, + }); } // Check response status @@ -348,6 +389,14 @@ export async function sendPlaylistToDevice({ }; } + if (nestedCastOk(responseData) === false) { + return { + success: false, + error: `Device "${device.name || device.host}" rejected the display command`, + details: JSON.stringify(responseData), + }; + } + logger.info('Successfully sent playlist to FF1 device'); logger.debug(`Device response: ${JSON.stringify(responseData)}`); @@ -357,6 +406,7 @@ export async function sendPlaylistToDevice({ deviceName: device.name || device.host, response: responseData, message: 'Playlist successfully sent to FF1 device', + transport: 'lan', }; } catch (error) { const errorMessage = (error as Error).message; @@ -364,13 +414,18 @@ export async function sendPlaylistToDevice({ if (device && isTransientDeviceNetworkError(error)) { const deviceLabel = device.name || device.host; - return { - success: false, - error: `Could not reach device "${deviceLabel}" at ${device.host}`, - details: - 'Check that the device is powered on and reachable on your network. ' + - 'If the device IP changed (e.g. after a factory reset), run: ff-cli setup', - }; + return fallbackToRelayer({ + playlist, + device, + dependencies, + lanFailure: { + success: false, + error: `Could not reach device "${deviceLabel}" at ${device.host}`, + details: + 'Check that the device is powered on and reachable on your network. ' + + 'If the device IP changed (e.g. after a factory reset), run: ff-cli setup', + }, + }); } return { @@ -379,3 +434,77 @@ export async function sendPlaylistToDevice({ }; } } + +/** + * Fall back to the relayer only after LAN reachability is exhausted. + * + * Topic lookup is keyed by the stable physical device ID and happens only at + * the fallback boundary, keeping the credential out of config and normal LAN + * requests. Application/HTTP errors do not enter this path. + */ +async function fallbackToRelayer(input: { + playlist: Playlist; + device: FF1Device; + dependencies: DeliveryDependencies; + lanFailure: SendPlaylistResult; +}): Promise { + if (!input.device.id) { + return { + ...input.lanFailure, + details: `${input.lanFailure.details} Pair the relayer with: ff-cli device pair ${input.device.name ?? ''}`, + }; + } + + let topicId: string | null; + try { + topicId = input.dependencies.getTopicIdFn(input.device.id); + } catch (error) { + return { + ...input.lanFailure, + details: `${input.lanFailure.details} Secure topic storage is unavailable: ${(error as Error).message}`, + }; + } + if (!topicId) { + return { + ...input.lanFailure, + details: `${input.lanFailure.details} Pair this device for relayer fallback with: ff-cli device pair ${input.device.name ?? input.device.id}`, + }; + } + + input.dependencies.noticeFn( + `Notice: LAN device is unreachable; falling back to the FF1 relayer for ${input.device.name ?? input.device.id}.` + ); + let relayer: { baseUrl: string; apiKey?: string }; + try { + relayer = input.dependencies.getRelayerConfigFn(); + } catch (error) { + return { + success: false, + error: 'Invalid FF1 relayer configuration', + details: (error as Error).message, + transport: 'relayer', + }; + } + const result = await sendPlaylistViaRelayer({ + ...relayer, + topicId, + playlist: input.playlist, + fetchFn: input.dependencies.fetchFn, + }); + if (!result.success) { + return { + success: false, + error: result.error, + details: result.details, + transport: 'relayer', + }; + } + return { + success: true, + device: relayer.baseUrl, + deviceName: input.device.name ?? input.device.id, + response: result.response, + message: 'Playlist successfully sent through the FF1 relayer', + transport: 'relayer', + }; +} diff --git a/src/utilities/ff1-relayer.ts b/src/utilities/ff1-relayer.ts new file mode 100644 index 0000000..f39119e --- /dev/null +++ b/src/utilities/ff1-relayer.ts @@ -0,0 +1,101 @@ +import type { Playlist } from '../types'; + +export interface RelayerCastResult { + success: boolean; + response?: Record; + error?: string; + details?: string; +} + +function nestedOk(value: unknown): boolean | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const record = value as Record; + if (typeof record.ok === 'boolean') { + return record.ok; + } + return nestedOk(record.message); +} + +/** Send a display command through the mobile-compatible FF1 relayer contract. */ +export async function sendPlaylistViaRelayer(input: { + baseUrl: string; + apiKey?: string; + topicId: string; + playlist: Playlist; + fetchFn?: typeof fetch; +}): Promise { + const fetchFn = input.fetchFn ?? globalThis.fetch.bind(globalThis); + let url: URL; + try { + url = new URL('/api/cast', input.baseUrl); + } catch { + return { + success: false, + error: 'Invalid FF1 relayer configuration', + details: 'ff1Relayer.baseUrl must be a valid HTTP(S) URL', + }; + } + url.searchParams.set('topicID', input.topicId); + const headers: Record = { 'Content-Type': 'application/json' }; + if (input.apiKey) { + headers['API-KEY'] = input.apiKey; + } + + let response: Response; + try { + response = await fetchFn(url, { + // The relayer accepts GET or POST with the same body. Node's standards- + // compliant fetch rejects GET bodies, so CLI uses POST while preserving + // the mobile request shape and topic query parameter. + method: 'POST', + headers, + body: JSON.stringify({ + command: 'displayPlaylist', + request: { + dp1_call: input.playlist, + intent: { action: 'now_display' }, + }, + }), + }); + } catch (error) { + return { + success: false, + error: 'Could not reach the FF1 relayer', + details: (error as Error).message, + }; + } + + const bodyText = (await response.text()).trim(); + let data: Record = {}; + if (bodyText) { + try { + data = JSON.parse(bodyText) as Record; + } catch { + return { + success: false, + error: `FF1 relayer returned non-JSON HTTP ${response.status}`, + details: bodyText.slice(0, 200), + }; + } + } + if (!response.ok) { + const needsCompatibilityKey = response.status === 401 && !input.apiKey; + return { + success: false, + error: `FF1 relayer returned HTTP ${response.status}`, + details: needsCompatibilityKey + ? 'This relayer deployment still requires its optional FF1_RELAYER_API_KEY compatibility gate.' + : bodyText || response.statusText, + }; + } + if (nestedOk(data) === false) { + return { + success: false, + error: 'FF1 rejected the relayer display command', + details: bodyText, + }; + } + return { success: true, response: data }; +} diff --git a/src/utilities/functions.js b/src/utilities/functions.js index 1a46e6e..69f7784 100644 --- a/src/utilities/functions.js +++ b/src/utilities/functions.js @@ -253,7 +253,7 @@ async function verifyAddresses(params) { * @returns {Array} returns.devices - Array of device configurations * @returns {string} returns.devices[].name - Device name * @returns {string} returns.devices[].host - Device host URL - * @returns {string} [returns.devices[].topicID] - Optional topic ID + * @returns {string} [returns.devices[].id] - Optional physical device ID * @returns {string} [returns.error] - Error message if no devices configured * @example * const result = await getConfiguredDevices(); @@ -290,7 +290,7 @@ async function getConfiguredDevices() { const devices = deviceConfig.devices.map((d) => ({ name: d.name || d.host, host: d.host, - topicID: d.topicID, + id: d.id, })); return { diff --git a/src/utilities/handoff-client.ts b/src/utilities/handoff-client.ts new file mode 100644 index 0000000..3f1b804 --- /dev/null +++ b/src/utilities/handoff-client.ts @@ -0,0 +1,197 @@ +import { webcrypto } from 'node:crypto'; +import { + HANDOFF_ALGORITHM, + decryptHandoffMessage, + exportHandoffPublicJwk, + generateHandoffKeyPair, + handoffVerificationCode, + type BrokerHandoffMessage, +} from './handoff-crypto'; + +const DEFAULT_HANDOFF_URL = 'https://handoff.feralfile.com'; + +interface CreateChannelResponse { + channelId: string; + minterToken: string; + shortCode: string; + expiresAt: string; +} + +interface PollResponse { + channelId: string; + messages: BrokerHandoffMessage[]; +} + +export interface TopicHandoffPayload { + v: 1; + type: 'ff1_topic_handoff'; + channelId: string; + deviceId: string; + deviceName?: string; + topicId: string; + sentAt: string; +} + +function brokerBaseUrl(value?: string): string { + return (value || process.env.FF1_HANDOFF_URL || DEFAULT_HANDOFF_URL).replace(/\/$/, ''); +} + +async function responseJson(response: Response, operation: string): Promise { + if (!response.ok) { + throw new Error(`${operation} failed with HTTP ${response.status}`); + } + return response.json() as Promise; +} + +function parseCreated(value: unknown): CreateChannelResponse { + const record = value as Partial | null; + if ( + !record || + typeof record.channelId !== 'string' || + !record.channelId.startsWith('ch_') || + typeof record.minterToken !== 'string' || + typeof record.shortCode !== 'string' || + !/^\d{6}$/.test(record.shortCode) || + typeof record.expiresAt !== 'string' + ) { + throw new Error('handoff channel response is invalid'); + } + return record as CreateChannelResponse; +} + +function parsePoll(value: unknown, channelId: string): PollResponse { + const record = value as Partial | null; + if (record?.channelId !== channelId || !Array.isArray(record.messages)) { + throw new Error('handoff poll response is invalid'); + } + return record as PollResponse; +} + +function parseTopicPayload( + value: unknown, + channelId: string, + expectedDeviceId: string +): TopicHandoffPayload { + const payload = value as Partial | null; + if ( + payload?.v !== 1 || + payload.type !== 'ff1_topic_handoff' || + payload.channelId !== channelId || + typeof payload.deviceId !== 'string' || + payload.deviceId.trim().toUpperCase() !== expectedDeviceId.trim().toUpperCase() || + typeof payload.topicId !== 'string' || + payload.topicId.trim().length === 0 || + typeof payload.sentAt !== 'string' || + Number.isNaN(Date.parse(payload.sentAt)) + ) { + throw new Error('received topic handoff does not match the selected FF1 device'); + } + return payload as TopicHandoffPayload; +} + +/** One short-lived CLI-side channel waiting for a mobile topic transfer. */ +export class TopicHandoffReceiver { + private constructor( + readonly baseUrl: string, + readonly channelId: string, + readonly shortCode: string, + readonly verificationCode: string, + readonly expiresAt: string, + private readonly minterToken: string, + private readonly privateKey: webcrypto.CryptoKey, + private readonly fetchFn: typeof fetch + ) {} + + /** Ask the handoff broker for a one-time six-digit pairing channel. */ + static async create( + options: { + baseUrl?: string; + fetchFn?: typeof fetch; + } = {} + ): Promise { + const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis); + const baseUrl = brokerBaseUrl(options.baseUrl); + const keyPair = await generateHandoffKeyPair(); + const publicKey = await exportHandoffPublicJwk(keyPair.publicKey); + const response = await fetchFn(new URL('/v1/channels', baseUrl), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + algorithm: HANDOFF_ALGORITHM, + minterPublicKeyJwk: publicKey, + idleTtlSeconds: 300, + shortCodeRequested: true, + }), + }); + const created = parseCreated(await responseJson(response, 'handoff channel creation')); + const verificationCode = handoffVerificationCode({ + channelId: created.channelId, + minterPublicKeyJwk: publicKey, + }); + return new TopicHandoffReceiver( + baseUrl, + created.channelId, + created.shortCode, + verificationCode, + created.expiresAt, + created.minterToken, + keyPair.privateKey, + fetchFn + ); + } + + /** Poll until mobile sends a valid encrypted topic for the selected device. */ + async receiveTopic(options: { + expectedDeviceId: string; + pollIntervalMs?: number; + maxWaitMs?: number; + }): Promise { + const deadline = Date.now() + (options.maxWaitMs ?? 300_000); + let afterSeq = 0; + while (Date.now() <= deadline) { + const url = new URL( + `/v1/channels/${encodeURIComponent(this.channelId)}/messages`, + this.baseUrl + ); + url.searchParams.set('afterSeq', String(afterSeq)); + const response = await this.fetchFn(url, { + headers: { Authorization: `Bearer ${this.minterToken}` }, + }); + const poll = parsePoll(await responseJson(response, 'handoff poll'), this.channelId); + for (const message of poll.messages) { + afterSeq = Math.max(afterSeq, message.seq); + if ( + message.sender !== 'browser' || + message.recipient !== 'minter' || + !message.senderPublicKeyJwk + ) { + continue; + } + const plaintext = await decryptHandoffMessage({ + privateKey: this.privateKey, + peerPublicKeyJwk: message.senderPublicKeyJwk, + channelId: this.channelId, + seq: message.seq, + message, + }); + return parseTopicPayload(plaintext, this.channelId, options.expectedDeviceId); + } + await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 1000)); + } + throw new Error('Timed out waiting for the mobile app topic transfer'); + } + + /** Close the broker channel and invalidate its participant credentials. */ + async close(): Promise { + const response = await this.fetchFn( + new URL(`/v1/channels/${encodeURIComponent(this.channelId)}`, this.baseUrl), + { + method: 'DELETE', + headers: { Authorization: `Bearer ${this.minterToken}` }, + } + ); + if (!response.ok && response.status !== 404 && response.status !== 410) { + throw new Error(`handoff channel close failed with HTTP ${response.status}`); + } + } +} diff --git a/src/utilities/handoff-crypto.ts b/src/utilities/handoff-crypto.ts new file mode 100644 index 0000000..876f324 --- /dev/null +++ b/src/utilities/handoff-crypto.ts @@ -0,0 +1,220 @@ +import { createHash, randomBytes, webcrypto } from 'node:crypto'; + +/** Wire algorithm required by the handoff broker protocol. */ +export const HANDOFF_ALGORITHM = 'P256-HKDF-SHA256-AES-256-GCM' as const; + +export type HandoffRole = 'browser' | 'minter'; + +export interface EncryptedHandoffMessage { + messageId: string; + sender: HandoffRole; + recipient: HandoffRole; + algorithm: typeof HANDOFF_ALGORITHM; + aad: string; + nonce: string; + ciphertext: string; + senderPublicKeyJwk?: webcrypto.JsonWebKey; +} + +export interface BrokerHandoffMessage extends EncryptedHandoffMessage { + seq: number; +} + +interface HandoffAAD { + v: 1; + channelId: string; + messageId: string; + seq: number; + sender: HandoffRole; + recipient: HandoffRole; + algorithm: typeof HANDOFF_ALGORITHM; +} + +/** canonicalJson serializes JSON with recursively sorted object keys. */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(',')}]`; + } + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b) + ); + return `{${entries + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) + .join(',')}}`; +} + +/** Create the out-of-band key commitment shown by both CLI and mobile. */ +export function handoffVerificationCode(input: { + channelId: string; + minterPublicKeyJwk: webcrypto.JsonWebKey; +}): string { + const commitment = createHash('sha256') + .update( + canonicalJson({ + v: 1, + algorithm: HANDOFF_ALGORITHM, + channelId: input.channelId, + minterPublicKeyJwk: input.minterPublicKeyJwk, + }) + ) + .digest('hex') + .slice(0, 12) + .toUpperCase(); + return `${commitment.slice(0, 4)}-${commitment.slice(4, 8)}-${commitment.slice(8, 12)}`; +} + +/** Generate an ephemeral P-256 ECDH key pair for one broker channel. */ +export async function generateHandoffKeyPair(): Promise { + return (await webcrypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, [ + 'deriveBits', + ])) as webcrypto.CryptoKeyPair; +} + +/** Export the public half of an ephemeral handoff key as JWK. */ +export async function exportHandoffPublicJwk( + publicKey: webcrypto.CryptoKey +): Promise { + return webcrypto.subtle.exportKey('jwk', publicKey); +} + +function base64UrlEncode(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64url'); +} + +function base64UrlDecode(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64url')); +} + +function handoffAAD(input: { + channelId: string; + messageId: string; + seq: number; + sender: HandoffRole; + recipient: HandoffRole; +}): HandoffAAD { + return { + v: 1, + channelId: input.channelId, + messageId: input.messageId, + seq: input.seq, + sender: input.sender, + recipient: input.recipient, + algorithm: HANDOFF_ALGORITHM, + }; +} + +async function deriveAesKey(input: { + privateKey: webcrypto.CryptoKey; + peerPublicKeyJwk: webcrypto.JsonWebKey; + channelId: string; +}): Promise { + const peer = await webcrypto.subtle.importKey( + 'jwk', + input.peerPublicKeyJwk, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + const sharedBits = await webcrypto.subtle.deriveBits( + { name: 'ECDH', public: peer }, + input.privateKey, + 256 + ); + const hkdfKey = await webcrypto.subtle.importKey('raw', sharedBits, 'HKDF', false, ['deriveKey']); + const saltInput = canonicalJson({ + algorithm: HANDOFF_ALGORITHM, + channelId: input.channelId, + v: 1, + }); + const salt = await webcrypto.subtle.digest('SHA-256', Buffer.from(saltInput)); + return webcrypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt, + info: Buffer.from('ff-mint-pairing/v1/aes-gcm'), + }, + hkdfKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** Encrypt a JSON payload into the broker's authenticated envelope format. */ +export async function encryptHandoffMessage(input: { + privateKey: webcrypto.CryptoKey; + senderPublicKeyJwk?: webcrypto.JsonWebKey; + peerPublicKeyJwk: webcrypto.JsonWebKey; + channelId: string; + messageId: string; + sender: HandoffRole; + recipient: HandoffRole; + plaintext: unknown; +}): Promise { + const aad = handoffAAD({ ...input, seq: 0 }); + const aadBytes = Buffer.from(canonicalJson(aad)); + const nonce = randomBytes(12); + const key = await deriveAesKey(input); + const ciphertext = await webcrypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce, additionalData: aadBytes, tagLength: 128 }, + key, + Buffer.from(canonicalJson(input.plaintext)) + ); + return { + messageId: input.messageId, + sender: input.sender, + recipient: input.recipient, + algorithm: HANDOFF_ALGORITHM, + aad: base64UrlEncode(aadBytes), + nonce: base64UrlEncode(nonce), + ciphertext: base64UrlEncode(new Uint8Array(ciphertext)), + ...(input.senderPublicKeyJwk ? { senderPublicKeyJwk: input.senderPublicKeyJwk } : {}), + }; +} + +/** Decrypt and validate one channel-bound broker message. */ +export async function decryptHandoffMessage(input: { + privateKey: webcrypto.CryptoKey; + peerPublicKeyJwk: webcrypto.JsonWebKey; + channelId: string; + seq: number; + message: BrokerHandoffMessage; +}): Promise { + const { message } = input; + if (message.algorithm !== HANDOFF_ALGORITHM) { + throw new Error('encrypted message algorithm mismatch'); + } + const aadBytes = base64UrlDecode(message.aad); + const aad = JSON.parse(Buffer.from(aadBytes).toString('utf8')) as Partial; + const valid = + aad.v === 1 && + aad.channelId === input.channelId && + aad.messageId === message.messageId && + aad.sender === message.sender && + aad.recipient === message.recipient && + aad.algorithm === HANDOFF_ALGORITHM && + (aad.seq === 0 || aad.seq === input.seq); + if (!valid) { + throw new Error('encrypted message channel binding mismatch'); + } + const nonce = base64UrlDecode(message.nonce); + if (nonce.length !== 12) { + throw new Error('AES-GCM nonce must be 12 bytes'); + } + const key = await deriveAesKey(input); + const plaintext = await webcrypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce, additionalData: aadBytes, tagLength: 128 }, + key, + base64UrlDecode(message.ciphertext) + ); + return JSON.parse(Buffer.from(plaintext).toString('utf8')) as unknown; +} + +/** Create a collision-resistant broker message identifier. */ +export function randomHandoffMessageId(): string { + return `msg_${randomBytes(16).toString('base64url')}`; +} diff --git a/src/utilities/ssh-access.ts b/src/utilities/ssh-access.ts index 2043d1c..be5467d 100644 --- a/src/utilities/ssh-access.ts +++ b/src/utilities/ssh-access.ts @@ -76,11 +76,7 @@ export async function sendSshAccessCommand({ }; } - let apiUrl = `${device.host}/api/cast`; - if (device.topicID && device.topicID.trim() !== '') { - apiUrl += `?topicID=${encodeURIComponent(device.topicID)}`; - logger.debug(`Using topicID: ${device.topicID}`); - } + const apiUrl = `${device.host}/api/cast`; const request: Record = { enabled, diff --git a/src/utilities/topic-store.ts b/src/utilities/topic-store.ts new file mode 100644 index 0000000..1ac8149 --- /dev/null +++ b/src/utilities/topic-store.ts @@ -0,0 +1,52 @@ +import { Entry } from '@napi-rs/keyring'; + +const TOPIC_STORE_SERVICE = 'com.feralfile.ff-cli.topic'; + +/** Minimal keyring entry surface used for testable OS-backed topic storage. */ +export interface KeyringEntry { + setPassword(value: string): void; + getPassword(): string | null; + deletePassword(): boolean; +} + +export type KeyringEntryFactory = (service: string, account: string) => KeyringEntry; + +const defaultEntryFactory: KeyringEntryFactory = (service, account) => new Entry(service, account); + +/** Normalize a physical FF1 identifier into its one keyring account name. */ +export function topicStoreAccount(deviceId: string): string { + const normalized = deviceId.trim().toUpperCase(); + if (!normalized) { + throw new Error('FF1 device ID is required for secure topic storage'); + } + return normalized; +} + +/** Store a device topic in the operating system credential vault. */ +export function storeTopicId( + deviceId: string, + topicId: string, + entryFactory: KeyringEntryFactory = defaultEntryFactory +): void { + const normalizedTopic = topicId.trim(); + if (!normalizedTopic) { + throw new Error('FF1 topic ID is required for secure topic storage'); + } + entryFactory(TOPIC_STORE_SERVICE, topicStoreAccount(deviceId)).setPassword(normalizedTopic); +} + +/** Read a device topic from the operating system credential vault. */ +export function getTopicId( + deviceId: string, + entryFactory: KeyringEntryFactory = defaultEntryFactory +): string | null { + return entryFactory(TOPIC_STORE_SERVICE, topicStoreAccount(deviceId)).getPassword(); +} + +/** Delete a device topic from the operating system credential vault. */ +export function deleteTopicId( + deviceId: string, + entryFactory: KeyringEntryFactory = defaultEntryFactory +): boolean { + return entryFactory(TOPIC_STORE_SERVICE, topicStoreAccount(deviceId)).deletePassword(); +} diff --git a/tests/device-add-cli.test.ts b/tests/device-add-cli.test.ts new file mode 100644 index 0000000..490f779 --- /dev/null +++ b/tests/device-add-cli.test.ts @@ -0,0 +1,71 @@ +/** Integration coverage for manual device IDs used by secure relayer pairing. */ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, test } from 'node:test'; + +const projectRoot = resolve(__dirname, '..'); +const tsxCli = resolve(projectRoot, 'node_modules/tsx/dist/cli.mjs'); +const cliEntry = resolve(projectRoot, 'index.ts'); + +interface TestDevice { + name?: string; + host: string; + id?: string; +} + +function runDeviceAdd(cwd: string, ...args: string[]) { + return spawnSync(process.execPath, [tsxCli, cliEntry, 'device', 'add', ...args], { + cwd, + env: { ...process.env, XDG_CONFIG_HOME: join(cwd, '.xdg') }, + encoding: 'utf-8', + }); +} + +function withConfig(devices: TestDevice[], run: (cwd: string, configPath: string) => void): void { + const cwd = mkdtempSync(join(tmpdir(), 'ff1-device-add-')); + const configPath = join(cwd, 'config.json'); + writeFileSync(configPath, JSON.stringify({ ff1Devices: { devices } }), 'utf8'); + try { + run(cwd, configPath); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +} + +describe('ff1 device add manual ID — CLI integration', () => { + test('stores a normalized stable ID for a manual host', () => { + withConfig([], (cwd, configPath) => { + const result = runDeviceAdd( + cwd, + '--host', + '192.168.1.20', + '--name', + 'office', + '--id', + 'ff1-skyz2e3a' + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + ff1Devices: { devices: TestDevice[] }; + }; + assert.equal(config.ff1Devices.devices[0].id, 'FF1-SKYZ2E3A'); + }); + }); + + test('manual host update without --id preserves the paired device ID', () => { + withConfig( + [{ name: 'office', host: 'http://192.168.1.20:1111', id: 'FF1-SKYZ2E3A' }], + (cwd, configPath) => { + const result = runDeviceAdd(cwd, '--host', '192.168.1.21', '--name', 'office'); + assert.equal(result.status, 0, result.stderr || result.stdout); + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + ff1Devices: { devices: TestDevice[] }; + }; + assert.equal(config.ff1Devices.devices[0].id, 'FF1-SKYZ2E3A'); + } + ); + }); +}); diff --git a/tests/device-removal.test.ts b/tests/device-removal.test.ts new file mode 100644 index 0000000..4563403 --- /dev/null +++ b/tests/device-removal.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { Config } from '../src/types'; +import { persistDeviceRemoval } from '../src/commands/helpers/device-removal'; + +function testConfig(): Config { + return { + defaultDuration: 30, + generativeDuration: 60, + browser: { timeout: 30_000, sanitizationLevel: 'strict' }, + ff1Devices: { + devices: [ + { name: 'office', host: 'http://ff1-office.local:1111', id: 'FF1-OFFICE' }, + { name: 'studio', host: 'http://ff1-studio.local:1111', id: 'FF1-STUDIO' }, + ], + }, + }; +} + +describe('device remove credential cleanup', () => { + for (const failurePoint of ['lookup', 'delete'] as const) { + test(`persists removal when keyring ${failurePoint} fails`, async () => { + const config = testConfig(); + let persistedNames: Array = []; + const warnings: string[] = []; + + const result = await persistDeviceRemoval(config, 0, { + persistConfig: async (updated) => { + persistedNames = updated.ff1Devices?.devices.map((device) => device.name) ?? []; + }, + getTopicId: () => { + if (failurePoint === 'lookup') { + throw new Error('keyring locked'); + } + return 'paired-topic'; + }, + deleteTopicId: () => { + if (failurePoint === 'delete') { + throw new Error('keyring unavailable'); + } + return true; + }, + warn: (message) => warnings.push(message), + }); + + assert.equal(result.removed.name, 'office'); + assert.deepEqual(persistedNames, ['studio']); + assert.deepEqual( + config.ff1Devices?.devices.map((device) => device.name), + ['studio'] + ); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /device was removed.*secure topic credential/i); + }); + } +}); diff --git a/tests/device-upsert.test.ts b/tests/device-upsert.test.ts index 6371333..170d74c 100644 --- a/tests/device-upsert.test.ts +++ b/tests/device-upsert.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { upsertDevice } from '../src/utilities/device-upsert'; +import type { DeviceEntry } from '../src/utilities/device-upsert'; describe('upsertDevice', () => { test('inserts a new device', () => { @@ -48,10 +49,15 @@ describe('upsertDevice', () => { assert.equal(devices[existingIndex].name, 'kitchen'); }); - // Regression: same-name/different-host replace used to drop apiKey and topicID - test('preserves apiKey and topicID when same-name device moves to a new host', () => { + // Regression: same-name/different-host replace used to drop non-secret metadata. + test('preserves apiKey but drops legacy plaintext topic when a device moves', () => { const existing = [ - { name: 'kitchen', host: 'http://10.0.0.1:1111', apiKey: 'key-k', topicID: 'topic-k' }, + { + name: 'kitchen', + host: 'http://10.0.0.1:1111', + apiKey: 'key-k', + topicID: 'legacy-topic-k', + } as DeviceEntry & { topicID: string }, ]; const { devices } = upsertDevice(existing, { name: 'kitchen', @@ -59,7 +65,7 @@ describe('upsertDevice', () => { }); assert.equal(devices[0].host, 'http://10.0.0.99:1111'); assert.equal(devices[0].apiKey, 'key-k'); - assert.equal(devices[0].topicID, 'topic-k'); + assert.equal((devices[0] as DeviceEntry & { topicID?: string }).topicID, undefined); }); // Regression: setup flow used discoveredName as default, clobbering stored labels @@ -238,7 +244,7 @@ describe('upsertDevice', () => { assert.equal(updated, false, 'host changed so not "updated" in same-host sense'); assert.equal(devices.length, 2, 'must not create a duplicate'); assert.equal(devices[0].host, 'http://ff1-hh9jsnoc.local:1111', 'host must be updated'); - assert.equal(devices[0].id, 'ff1-hh9jsnoc', 'id must be preserved'); + assert.equal(devices[0].id, 'FF1-HH9JSNOC', 'id must be stored canonically'); }); // Regression: rename + host-change with no id — without matchedIndex, none of the diff --git a/tests/device-workflow.test.ts b/tests/device-workflow.test.ts index 597d052..0750c04 100644 --- a/tests/device-workflow.test.ts +++ b/tests/device-workflow.test.ts @@ -10,6 +10,7 @@ import { describe, test } from 'node:test'; import { parseAvahiBrowseOutput } from '../src/utilities/ff1-discovery'; import { findExistingDeviceEntry } from '../src/utilities/device-lookup'; import { upsertDevice } from '../src/utilities/device-upsert'; +import { resolveManualDeviceInput } from '../src/commands/helpers/device-discovery'; /** Simulate the selection step: map a discovered device to the host URL used by the CLI. */ function toHostValue(device: { host: string; port: number }): string { @@ -17,6 +18,47 @@ function toHostValue(device: { host: string; port: number }): string { } describe('device add / setup workflow (avahi → lookup → upsert)', () => { + test('no-mDNS manual ID is retained for relayer pairing', () => { + const selection = resolveManualDeviceInput('ff1-skyz2e3a'); + const { devices } = upsertDevice([], { + name: 'office', + host: selection.hostValue, + id: selection.discoveredId, + }); + + assert.equal(selection.hostValue, 'http://ff1-skyz2e3a.local:1111'); + assert.equal(devices[0].id, 'FF1-SKYZ2E3A'); + assert.ok(devices[0].id, 'device pair can proceed with the retained stable ID'); + }); + + test('uppercase manual ID matches lowercase mDNS rediscovery', () => { + const stored = [ + { + name: 'office', + host: 'http://192.168.1.20:1111', + id: 'FF1-SKYZ2E3A', + }, + ]; + const discoveredId = 'ff1-skyz2e3a'; + const matched = findExistingDeviceEntry( + stored, + 'http://ff1-skyz2e3a.local:1111', + 'office', + discoveredId, + ['192.168.1.20'] + ); + assert.equal(matched, stored[0]); + + const { devices } = upsertDevice(stored, { + name: 'office', + host: 'http://ff1-skyz2e3a.local:1111', + id: discoveredId, + addresses: ['192.168.1.20'], + }); + assert.equal(devices.length, 1); + assert.equal(devices[0].id, 'FF1-SKYZ2E3A'); + }); + // Regression: re-running setup after a device is already configured must update // in-place and must not append a duplicate entry. test('avahi re-discovery does not duplicate a fully configured entry', () => { @@ -96,7 +138,7 @@ describe('device add / setup workflow (avahi → lookup → upsert)', () => { assert.equal(devices.length, 1, 'no duplicate must be created'); assert.equal(devices[0].host, newHost, 'host must be migrated to .local'); - assert.equal(devices[0].id, 'ff1-hh9jsnoc', 'id must be persisted for future lookups'); + assert.equal(devices[0].id, 'FF1-HH9JSNOC', 'id must be persisted for future lookups'); }); // Regression: dual-stack device emits IPv4 + IPv6 resolved records; the merged @@ -196,7 +238,7 @@ describe('same-friendly-name collision: lookup + add flow', () => { ); assert.equal( devices[0].id, - newId, + newId.toUpperCase(), 'the original kitchen entry would be overwritten without the guard' ); }); diff --git a/tests/ff1-compatibility.test.ts b/tests/ff1-compatibility.test.ts index c8e2d9c..7d45ddd 100644 --- a/tests/ff1-compatibility.test.ts +++ b/tests/ff1-compatibility.test.ts @@ -13,7 +13,7 @@ interface FF1DeviceForTest { host?: string; name?: string; apiKey?: string; - topicID?: string; + id?: string; } interface MockApiResponse { diff --git a/tests/handoff-client.test.ts b/tests/handoff-client.test.ts new file mode 100644 index 0000000..ee142e9 --- /dev/null +++ b/tests/handoff-client.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { webcrypto } from 'node:crypto'; +import { describe, test } from 'node:test'; +import { TopicHandoffReceiver } from '../src/utilities/handoff-client'; +import { + encryptHandoffMessage, + exportHandoffPublicJwk, + generateHandoffKeyPair, +} from '../src/utilities/handoff-crypto'; + +describe('TopicHandoffReceiver', () => { + test('receives only the selected device topic and closes the channel', async () => { + let minterPublicKeyJwk: webcrypto.JsonWebKey | undefined; + let closed = false; + const browser = await generateHandoffKeyPair(); + const browserPublicKeyJwk = await exportHandoffPublicJwk(browser.publicKey); + + const fetchFn: typeof fetch = async (input, init) => { + const url = new URL(input.toString()); + if (url.pathname === '/v1/channels' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as { + minterPublicKeyJwk: webcrypto.JsonWebKey; + }; + minterPublicKeyJwk = body.minterPublicKeyJwk; + return Response.json( + { + channelId: 'ch_test', + minterToken: 'mt_test', + pairingToken: 'pt_test', + shortCode: '123456', + expiresAt: '2026-07-10T01:00:00Z', + qrPayload: {}, + }, + { status: 201 } + ); + } + if (url.pathname.endsWith('/messages')) { + assert.ok(minterPublicKeyJwk); + const encrypted = await encryptHandoffMessage({ + privateKey: browser.privateKey, + senderPublicKeyJwk: browserPublicKeyJwk, + peerPublicKeyJwk: minterPublicKeyJwk, + channelId: 'ch_test', + messageId: 'msg_test', + sender: 'browser', + recipient: 'minter', + plaintext: { + v: 1, + type: 'ff1_topic_handoff', + channelId: 'ch_test', + deviceId: 'FF1-SKYZ2E3A', + deviceName: 'Office', + topicId: 'topic-secret', + sentAt: '2026-07-10T00:00:00Z', + }, + }); + return Response.json({ + channelId: 'ch_test', + expiresAt: '2026-07-10T01:00:00Z', + messages: [{ ...encrypted, seq: 1 }], + }); + } + if (url.pathname === '/v1/channels/ch_test' && init?.method === 'DELETE') { + closed = true; + return Response.json({ status: 'closed' }); + } + return new Response('not found', { status: 404 }); + }; + + const receiver = await TopicHandoffReceiver.create({ + baseUrl: 'https://handoff.example', + fetchFn, + }); + const payload = await receiver.receiveTopic({ + expectedDeviceId: 'ff1-skyz2e3a', + pollIntervalMs: 1, + maxWaitMs: 100, + }); + await receiver.close(); + + assert.equal(receiver.shortCode, '123456'); + assert.equal(payload.topicId, 'topic-secret'); + assert.equal(closed, true); + }); +}); diff --git a/tests/handoff-crypto.test.ts b/tests/handoff-crypto.test.ts new file mode 100644 index 0000000..4c7f77a --- /dev/null +++ b/tests/handoff-crypto.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + decryptHandoffMessage, + encryptHandoffMessage, + exportHandoffPublicJwk, + generateHandoffKeyPair, + handoffVerificationCode, +} from '../src/utilities/handoff-crypto'; + +describe('handoff crypto', () => { + test('creates a stable out-of-band key commitment', () => { + const code = handoffVerificationCode({ + channelId: 'ch_test', + minterPublicKeyJwk: { + kty: 'EC', + crv: 'P-256', + x: 'axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpY', + y: 'T-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU', + }, + }); + + assert.equal(code, '941E-AAAA-23EB'); + }); + + test('encrypts and decrypts a channel-bound topic payload', async () => { + const sender = await generateHandoffKeyPair(); + const recipient = await generateHandoffKeyPair(); + const senderPublicKeyJwk = await exportHandoffPublicJwk(sender.publicKey); + const recipientPublicKeyJwk = await exportHandoffPublicJwk(recipient.publicKey); + const plaintext = { + v: 1, + type: 'ff1_topic_handoff', + channelId: 'ch_test', + deviceId: 'FF1-TEST1234', + topicId: 'topic-secret', + sentAt: '2026-07-10T00:00:00.000Z', + }; + + const encrypted = await encryptHandoffMessage({ + privateKey: sender.privateKey, + senderPublicKeyJwk, + peerPublicKeyJwk: recipientPublicKeyJwk, + channelId: 'ch_test', + messageId: 'msg_test', + sender: 'browser', + recipient: 'minter', + plaintext, + }); + const decrypted = await decryptHandoffMessage({ + privateKey: recipient.privateKey, + peerPublicKeyJwk: senderPublicKeyJwk, + channelId: 'ch_test', + seq: 1, + message: { ...encrypted, seq: 1 }, + }); + + assert.deepEqual(decrypted, plaintext); + }); + + test('rejects an envelope rebound to another channel', async () => { + const sender = await generateHandoffKeyPair(); + const recipient = await generateHandoffKeyPair(); + const senderPublicKeyJwk = await exportHandoffPublicJwk(sender.publicKey); + const recipientPublicKeyJwk = await exportHandoffPublicJwk(recipient.publicKey); + const encrypted = await encryptHandoffMessage({ + privateKey: sender.privateKey, + peerPublicKeyJwk: recipientPublicKeyJwk, + channelId: 'ch_one', + messageId: 'msg_test', + sender: 'browser', + recipient: 'minter', + plaintext: { v: 1 }, + }); + + await assert.rejects( + decryptHandoffMessage({ + privateKey: recipient.privateKey, + peerPublicKeyJwk: senderPublicKeyJwk, + channelId: 'ch_two', + seq: 1, + message: { ...encrypted, senderPublicKeyJwk, seq: 1 }, + }), + /channel binding mismatch/ + ); + }); +}); diff --git a/tests/multi-device.test.ts b/tests/multi-device.test.ts index b8483ef..611f894 100644 --- a/tests/multi-device.test.ts +++ b/tests/multi-device.test.ts @@ -10,7 +10,7 @@ interface FF1DeviceForTest { host?: string; name?: string; apiKey?: string; - topicID?: string; + id?: string; } let fixtureDir: string; @@ -100,16 +100,16 @@ describe('multi-device resolution', () => { assert.equal(result.device?.host, 'http://192.168.1.11:1111'); }); - test('preserves device-specific apiKey and topicID', () => { + test('preserves device-specific apiKey and stable ID', () => { writeDeviceConfig([ - { name: 'kitchen', host: 'http://192.168.1.10:1111', apiKey: 'key-k', topicID: 'topic-k' }, - { name: 'office', host: 'http://192.168.1.11:1111', apiKey: 'key-o', topicID: 'topic-o' }, + { name: 'kitchen', host: 'http://192.168.1.10:1111', apiKey: 'key-k', id: 'FF1-K' }, + { name: 'office', host: 'http://192.168.1.11:1111', apiKey: 'key-o', id: 'FF1-O' }, ]); const result = resolveConfiguredDevice('office'); assert.equal(result.success, true); assert.equal(result.device?.apiKey, 'key-o'); - assert.equal(result.device?.topicID, 'topic-o'); + assert.equal(result.device?.id, 'FF1-O'); }); test('works with device that has no name when selecting by default', () => { diff --git a/tests/relayer-config.test.ts b/tests/relayer-config.test.ts new file mode 100644 index 0000000..c39fd77 --- /dev/null +++ b/tests/relayer-config.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, test } from 'node:test'; +import { getFF1RelayerConfig, validateConfig } from '../src/config'; + +const originalCwd = process.cwd(); +const originalUrl = process.env.FF1_RELAYER_URL; +const originalKey = process.env.FF1_RELAYER_API_KEY; +let fixtureDir: string; + +describe('FF1 relayer configuration', () => { + beforeEach(() => { + fixtureDir = mkdtempSync(join(tmpdir(), 'ff1-relayer-config-')); + process.chdir(fixtureDir); + delete process.env.FF1_RELAYER_URL; + delete process.env.FF1_RELAYER_API_KEY; + }); + + afterEach(() => { + process.chdir(originalCwd); + if (originalUrl === undefined) { + delete process.env.FF1_RELAYER_URL; + } else { + process.env.FF1_RELAYER_URL = originalUrl; + } + if (originalKey === undefined) { + delete process.env.FF1_RELAYER_API_KEY; + } else { + process.env.FF1_RELAYER_API_KEY = originalKey; + } + rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test('uses config over environment and strips one trailing slash', () => { + process.env.FF1_RELAYER_URL = 'https://env.example'; + process.env.FF1_RELAYER_API_KEY = 'env-key'; + writeFileSync( + join(fixtureDir, 'config.json'), + JSON.stringify({ + ff1Relayer: { baseUrl: 'https://config.example/', apiKey: 'config-key' }, + }) + ); + + assert.deepEqual(getFF1RelayerConfig(), { + baseUrl: 'https://config.example', + apiKey: 'config-key', + }); + }); + + test('uses environment before the production default', () => { + process.env.FF1_RELAYER_URL = 'https://env.example/'; + process.env.FF1_RELAYER_API_KEY = 'env-key'; + assert.deepEqual(getFF1RelayerConfig(), { + baseUrl: 'https://env.example', + apiKey: 'env-key', + }); + }); + + test('rejects malformed relayer configuration during validation and use', () => { + writeFileSync( + join(fixtureDir, 'config.json'), + JSON.stringify({ ff1Relayer: { baseUrl: 42, apiKey: ['bad'] } }) + ); + + const validation = validateConfig(); + assert.equal(validation.valid, false); + assert.match(validation.errors.join('\n'), /ff1Relayer\.baseUrl must be a string/); + assert.throws(getFF1RelayerConfig, /ff1Relayer\.baseUrl must be a string/); + }); +}); diff --git a/tests/relayer-fallback.test.ts b/tests/relayer-fallback.test.ts new file mode 100644 index 0000000..8f0d944 --- /dev/null +++ b/tests/relayer-fallback.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, test } from 'node:test'; +import { sendPlaylistToDevice } from '../src/utilities/ff1-device'; + +const originalCwd = process.cwd(); +let fixtureDir: string; + +const playlist = { + dpVersion: '1.1.0', + id: 'fallback-test', + title: 'Fallback test', + items: [], +} as unknown as Parameters[0]['playlist']; + +function isTestLanUrl(input: string | URL | Request): boolean { + return new URL(input.toString()).origin === 'http://ff1-skyz2e3a.local:1111'; +} + +describe('relayer fallback', () => { + beforeEach(() => { + fixtureDir = mkdtempSync(path.join(os.tmpdir(), 'ff1-relayer-fallback-')); + process.chdir(fixtureDir); + writeFileSync( + path.join(fixtureDir, 'config.json'), + JSON.stringify({ + ff1Relayer: { baseUrl: 'https://relayer.example', apiKey: 'optional-key' }, + ff1Devices: { + devices: [ + { + name: 'Office', + id: 'FF1-SKYZ2E3A', + host: 'http://ff1-skyz2e3a.local:1111', + }, + ], + }, + }) + ); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (fixtureDir && existsSync(fixtureDir)) { + rmSync(fixtureDir, { recursive: true, force: true }); + } + }); + + test('falls back after LAN reachability failure and prints an explicit notice', async () => { + const requests: Array<{ url: string; headers: HeadersInit | undefined }> = []; + const notices: string[] = []; + const fetchFn: typeof fetch = async (input, init) => { + const url = input.toString(); + requests.push({ url, headers: init?.headers }); + if (isTestLanUrl(url)) { + throw new Error('fetch failed', { cause: { code: 'ENOTFOUND' } }); + } + return new Response(JSON.stringify({ message: { message: { ok: true } } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + const result = await sendPlaylistToDevice( + { playlist }, + { + fetchFn, + getTopicIdFn: () => 'topic-secret', + waitFn: async () => undefined, + noticeFn: (message) => notices.push(message), + } + ); + + assert.equal(result.success, true); + assert.equal(result.transport, 'relayer'); + assert.equal(notices.length, 1); + assert.match(notices[0], /LAN.*unreachable.*relayer/i); + const relayerRequest = requests.at(-1)!; + assert.equal(relayerRequest.url, 'https://relayer.example/api/cast?topicID=topic-secret'); + assert.equal(new Headers(relayerRequest.headers).get('API-KEY'), 'optional-key'); + }); + + test('does not fall back when no topic is paired for the selected device', async () => { + const result = await sendPlaylistToDevice( + { playlist }, + { + fetchFn: async () => { + throw new Error('fetch failed', { cause: { code: 'ENOTFOUND' } }); + }, + getTopicIdFn: () => null, + waitFn: async () => undefined, + noticeFn: () => assert.fail('notice should not be printed without a fallback'), + } + ); + + assert.equal(result.success, false); + assert.match(result.error ?? '', /Could not reach device/); + assert.match(result.details ?? '', /device pair/); + }); + + test('does not fall back after a LAN HTTP failure', async () => { + const urls: string[] = []; + const result = await sendPlaylistToDevice( + { playlist }, + { + fetchFn: async (input) => { + urls.push(input.toString()); + return new Response('device rejected request', { status: 500 }); + }, + getTopicIdFn: () => assert.fail('topic lookup means fallback was attempted'), + waitFn: async () => undefined, + } + ); + + assert.equal(result.success, false); + assert.match(result.error ?? '', /Device returned error 500/); + assert.ok(urls.every(isTestLanUrl)); + }); + + test('does not fall back after a LAN application rejection', async () => { + const result = await sendPlaylistToDevice( + { playlist }, + { + fetchFn: async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { command?: string }; + return body.command === 'getDeviceStatus' + ? new Response(JSON.stringify({ message: { installedVersion: '1.0.0' } })) + : new Response(JSON.stringify({ message: { ok: false } })); + }, + getTopicIdFn: () => assert.fail('topic lookup means fallback was attempted'), + waitFn: async () => undefined, + } + ); + + assert.equal(result.success, false); + assert.match(result.error ?? '', /rejected the display command/); + }); + + test('returns structured failures for invalid config and relayer HTTP errors', async () => { + const unreachable: typeof fetch = async () => { + throw new Error('fetch failed', { cause: { code: 'ENOTFOUND' } }); + }; + const invalidConfig = await sendPlaylistToDevice( + { playlist }, + { + fetchFn: unreachable, + getTopicIdFn: () => 'topic-secret', + getRelayerConfigFn: () => { + throw new Error('ff1Relayer.baseUrl must be a valid HTTP(S) URL'); + }, + waitFn: async () => undefined, + } + ); + assert.equal(invalidConfig.success, false); + assert.equal(invalidConfig.transport, 'relayer'); + assert.match(invalidConfig.error ?? '', /Invalid FF1 relayer configuration/); + + const relayerHttpFailure = await sendPlaylistToDevice( + { playlist }, + { + fetchFn: async (input) => { + if (isTestLanUrl(input)) { + throw new Error('fetch failed', { cause: { code: 'ENOTFOUND' } }); + } + return new Response('relayer unavailable', { status: 503 }); + }, + getTopicIdFn: () => 'topic-secret', + waitFn: async () => undefined, + } + ); + assert.equal(relayerHttpFailure.success, false); + assert.equal(relayerHttpFailure.transport, 'relayer'); + assert.match(relayerHttpFailure.error ?? '', /HTTP 503/); + }); +}); diff --git a/tests/topic-store.test.ts b/tests/topic-store.test.ts new file mode 100644 index 0000000..a269ff5 --- /dev/null +++ b/tests/topic-store.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + deleteTopicId, + getTopicId, + storeTopicId, + topicStoreAccount, + type KeyringEntry, + type KeyringEntryFactory, +} from '../src/utilities/topic-store'; + +class MemoryEntry implements KeyringEntry { + constructor( + private readonly values: Map, + private readonly key: string + ) {} + + setPassword(value: string): void { + this.values.set(this.key, value); + } + + getPassword(): string | null { + return this.values.get(this.key) ?? null; + } + + deletePassword(): boolean { + return this.values.delete(this.key); + } +} + +describe('topic store', () => { + test('maps a normalized device id to one OS keyring item', () => { + const values = new Map(); + const factory: KeyringEntryFactory = (service, account) => + new MemoryEntry(values, `${service}:${account}`); + + storeTopicId(' ff1-skyz2e3a ', 'topic-secret', factory); + + assert.equal(getTopicId('FF1-SKYZ2E3A', factory), 'topic-secret'); + assert.equal(values.size, 1); + assert.equal(topicStoreAccount('ff1-skyz2e3a'), 'FF1-SKYZ2E3A'); + assert.equal(deleteTopicId('FF1-SKYZ2E3A', factory), true); + assert.equal(getTopicId('FF1-SKYZ2E3A', factory), null); + }); + + test('rejects empty device and topic identifiers', () => { + const factory: KeyringEntryFactory = () => new MemoryEntry(new Map(), 'unused'); + assert.throws(() => storeTopicId('', 'topic', factory), /device ID is required/); + assert.throws(() => storeTopicId('FF1-TEST', '', factory), /topic ID is required/); + }); +});