@feralfile/cli 2.0.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@ const chalk_1 = __importDefault(require("chalk"));
10
10
  // eslint-disable-next-line @typescript-eslint/no-require-imports
11
11
  const { signPlaylistFile } = require('../utilities/playlist-signer');
12
12
  exports.signCommand = new commander_1.Command('sign')
13
- .description('Sign a DP1 playlist file with a DP-1 signature envelope')
13
+ .description('Sign a DP-1 playlist file with a DP-1 signature envelope')
14
14
  .argument('<file>', 'Path to the playlist file to sign')
15
15
  .option('-k, --key <privateKey>', 'Ed25519 private key in base64 format (overrides config)')
16
16
  .option('-r, --role <role>', 'DP-1 signing role (overrides config)')
@@ -10,6 +10,7 @@ exports.getBrowserConfig = getBrowserConfig;
10
10
  exports.getPlaylistConfig = getPlaylistConfig;
11
11
  exports.getFeedConfig = getFeedConfig;
12
12
  exports.getFF1DeviceConfig = getFF1DeviceConfig;
13
+ exports.getFF1RelayerConfig = getFF1RelayerConfig;
13
14
  exports.validateConfig = validateConfig;
14
15
  exports.createSampleConfig = createSampleConfig;
15
16
  const fs_1 = __importDefault(require("fs"));
@@ -188,7 +189,7 @@ function getFeedConfig() {
188
189
  * @returns {Array<Object>} returns.devices - Array of configured FF1 devices
189
190
  * @returns {string} returns.devices[].host - Device host URL
190
191
  * @returns {string} [returns.devices[].apiKey] - Optional device API key
191
- * @returns {string} [returns.devices[].topicID] - Optional device topic ID
192
+ * @returns {string} [returns.devices[].id] - Optional stable physical device ID
192
193
  * @returns {string} [returns.devices[].name] - Optional device name
193
194
  */
194
195
  function getFF1DeviceConfig() {
@@ -198,6 +199,53 @@ function getFF1DeviceConfig() {
198
199
  devices: (0, config_placeholders_1.configuredFF1Devices)(ff1Devices.devices || []),
199
200
  };
200
201
  }
202
+ const DEFAULT_FF1_RELAYER_URL = 'https://tv-cast-coordination.autonomy-system.workers.dev';
203
+ /**
204
+ * Get the FF1 relayer endpoint using repository config priority.
205
+ *
206
+ * `config.json` wins over environment variables, which win over the production
207
+ * default. The API key remains optional because the signed topic is the
208
+ * per-device credential; it exists only for deployments that retain a separate
209
+ * cloud-edge compatibility gate.
210
+ */
211
+ function getFF1RelayerConfig() {
212
+ return resolveFF1RelayerConfig(getConfig());
213
+ }
214
+ /** Resolve and validate the effective FF1 relayer configuration. */
215
+ function resolveFF1RelayerConfig(config) {
216
+ const configured = config.ff1Relayer;
217
+ if (configured !== undefined &&
218
+ (configured === null || typeof configured !== 'object' || Array.isArray(configured))) {
219
+ throw new Error('ff1Relayer must be an object');
220
+ }
221
+ const fields = (configured || {});
222
+ if (fields.baseUrl !== undefined && typeof fields.baseUrl !== 'string') {
223
+ throw new Error('ff1Relayer.baseUrl must be a string');
224
+ }
225
+ if (fields.apiKey !== undefined && typeof fields.apiKey !== 'string') {
226
+ throw new Error('ff1Relayer.apiKey must be a string');
227
+ }
228
+ const baseUrl = (fields.baseUrl ||
229
+ process.env.FF1_RELAYER_URL ||
230
+ DEFAULT_FF1_RELAYER_URL)
231
+ .trim()
232
+ .replace(/\/$/, '');
233
+ let parsed;
234
+ try {
235
+ parsed = new URL(baseUrl);
236
+ }
237
+ catch {
238
+ throw new Error('ff1Relayer.baseUrl must be a valid HTTP(S) URL');
239
+ }
240
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
241
+ throw new Error('ff1Relayer.baseUrl must be a valid HTTP(S) URL');
242
+ }
243
+ const apiKey = (fields.apiKey || process.env.FF1_RELAYER_API_KEY)?.trim();
244
+ return {
245
+ baseUrl,
246
+ apiKey: apiKey || undefined,
247
+ };
248
+ }
201
249
  /**
202
250
  * Validate configuration
203
251
  *
@@ -247,6 +295,12 @@ function validateConfig() {
247
295
  }
248
296
  }
249
297
  }
298
+ try {
299
+ resolveFF1RelayerConfig(config);
300
+ }
301
+ catch (error) {
302
+ errors.push(error.message);
303
+ }
250
304
  return {
251
305
  valid: errors.length === 0,
252
306
  errors,
@@ -25,10 +25,11 @@ exports.findExistingDeviceEntry = findExistingDeviceEntry;
25
25
  * the default when prompting, rather than falling back to the raw mDNS label.
26
26
  */
27
27
  function findExistingDeviceEntry(existingDevices, newHost, discoveredName, discoveredId, discoveredAddresses) {
28
+ const sameDeviceId = (left, right) => Boolean(left && right && left.trim().toUpperCase() === right.trim().toUpperCase());
28
29
  // 1. mDNS device ID — stable hardware identity, checked before URL so stale
29
30
  // host entries for other devices at the same IP do not shadow the result.
30
31
  if (discoveredId) {
31
- const byId = existingDevices.find((d) => d.id === discoveredId);
32
+ const byId = existingDevices.find((d) => sameDeviceId(d.id, discoveredId));
32
33
  if (byId) {
33
34
  return byId;
34
35
  }
@@ -69,7 +70,7 @@ function findExistingDeviceEntry(existingDevices, newHost, discoveredName, disco
69
70
  // the stored entry has an id; the address is the best identity signal available.
70
71
  if (discoveredAddresses && discoveredAddresses.length > 0) {
71
72
  const byDiscoveredAddress = existingDevices.find((d) => {
72
- if (d.id && discoveredId && d.id !== discoveredId) {
73
+ if (d.id && discoveredId && !sameDeviceId(d.id, discoveredId)) {
73
74
  return false; // different physical devices — do not conflate
74
75
  }
75
76
  try {
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renameDevice = renameDevice;
4
+ const device_normalize_1 = require("./device-normalize");
5
+ /**
6
+ * Rename a configured device without touching host, apiKey, topicID, or list
7
+ * position — a pure config edit that needs no discovery or network access
8
+ * (unlike `device add`, whose update path requires the host or mDNS).
9
+ *
10
+ * Matches by name (case-insensitive) or by host URL, mirroring `device remove`
11
+ * so unnamed legacy entries can still be targeted (renaming gives them a name
12
+ * for the first time).
13
+ *
14
+ * @throws {Error} When no device matches the identifier, the new name is
15
+ * empty, or the new name is already used by a different device
16
+ * (case-insensitive, so lookups elsewhere stay unambiguous).
17
+ */
18
+ function renameDevice(devices, identifier, newName) {
19
+ const trimmedName = newName.trim();
20
+ if (!trimmedName) {
21
+ throw new Error('New device name must not be empty');
22
+ }
23
+ const normalizedArg = identifier.toLowerCase();
24
+ let normalizedArgHost = '';
25
+ try {
26
+ normalizedArgHost = (0, device_normalize_1.normalizeDeviceHost)(identifier).toLowerCase();
27
+ }
28
+ catch {
29
+ // not a valid URL — host matching will not apply
30
+ }
31
+ const index = devices.findIndex((d) => (d.name && d.name.toLowerCase() === normalizedArg) ||
32
+ (d.host && d.host.toLowerCase() === normalizedArg) ||
33
+ (normalizedArgHost &&
34
+ d.host &&
35
+ (0, device_normalize_1.normalizeDeviceHost)(d.host).toLowerCase() === normalizedArgHost));
36
+ if (index === -1) {
37
+ throw new Error(`Device "${identifier}" not found`);
38
+ }
39
+ const conflict = devices.find((d, i) => i !== index && d.name && d.name.toLowerCase() === trimmedName.toLowerCase());
40
+ if (conflict) {
41
+ throw new Error(`Device name "${trimmedName}" is already used by another device (${conflict.host})`);
42
+ }
43
+ const target = devices[index];
44
+ if (target.name === trimmedName) {
45
+ return { devices: [...devices], renamed: target, previousName: target.name, unchanged: true };
46
+ }
47
+ const updated = [...devices];
48
+ updated[index] = { ...target, name: trimmedName };
49
+ return { devices: updated, renamed: updated[index], previousName: target.name, unchanged: false };
50
+ }
@@ -36,7 +36,17 @@ function applyPatch(existing, patch) {
36
36
  else {
37
37
  addresses = existing.addresses; // same host, no new IPs: keep stored set
38
38
  }
39
- return { ...existing, ...patch, addresses };
39
+ const updated = { ...existing, ...patch };
40
+ // Rebuild the row from supported fields so an old plaintext topicID cannot
41
+ // survive a normal device update. Topics now belong exclusively in the OS
42
+ // credential vault under the stable device ID.
43
+ return {
44
+ host: updated.host,
45
+ name: updated.name,
46
+ id: updated.id,
47
+ apiKey: updated.apiKey,
48
+ addresses,
49
+ };
40
50
  }
41
51
  /**
42
52
  * Insert or update a device in the configured device list.
@@ -56,7 +66,11 @@ function upsertDevice(existingDevices, newDevice,
56
66
  * heuristics below are skipped and this row is updated directly. */
57
67
  matchedIndex) {
58
68
  const devices = [...existingDevices];
59
- const patch = withoutUndefined(newDevice);
69
+ const normalizedNewDevice = {
70
+ ...newDevice,
71
+ id: newDevice.id?.trim().toUpperCase(),
72
+ };
73
+ const patch = withoutUndefined(normalizedNewDevice);
60
74
  // Case 0: caller already resolved the match — update directly.
61
75
  if (matchedIndex !== undefined && matchedIndex >= 0 && matchedIndex < devices.length) {
62
76
  const isSameHost = devices[matchedIndex].host === newDevice.host;
@@ -64,8 +78,8 @@ matchedIndex) {
64
78
  return { devices, updated: isSameHost };
65
79
  }
66
80
  // Case 1: same mDNS device ID — update in-place even when host changed.
67
- if (newDevice.id) {
68
- const sameIdIndex = devices.findIndex((d) => d.id === newDevice.id);
81
+ if (normalizedNewDevice.id) {
82
+ const sameIdIndex = devices.findIndex((d) => d.id?.trim().toUpperCase() === normalizedNewDevice.id);
69
83
  if (sameIdIndex !== -1) {
70
84
  const isSameHost = devices[sameIdIndex].host === newDevice.host;
71
85
  devices[sameIdIndex] = applyPatch(devices[sameIdIndex], patch);
@@ -79,7 +93,7 @@ matchedIndex) {
79
93
  return { devices, updated: true };
80
94
  }
81
95
  // Case 3: same name, different host — replace in-place to preserve array order.
82
- // Spread existing entry first so apiKey/topicID survive a host change.
96
+ // Spread existing entry first so non-secret device metadata survives a host change.
83
97
  const staleNameIndex = devices.findIndex((d) => d.name === newDevice.name);
84
98
  if (staleNameIndex !== -1) {
85
99
  devices[staleNameIndex] = applyPatch(devices[staleNameIndex], patch);
@@ -41,13 +41,27 @@ exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
41
41
  exports.parseRetryAfterMs = parseRetryAfterMs;
42
42
  exports.sendPlaylistToDevice = sendPlaylistToDevice;
43
43
  const logger = __importStar(require("../logger"));
44
+ const config_1 = require("../config");
44
45
  const ff1_compatibility_1 = require("./ff1-compatibility");
46
+ const ff1_relayer_1 = require("./ff1-relayer");
47
+ const topic_store_1 = require("./topic-store");
45
48
  const SEND_RETRY_ATTEMPTS = 3;
46
49
  const SEND_RETRY_BASE_DELAY_MS = 750;
47
50
  const RATE_LIMIT_STATUS = 429;
48
51
  // Cap how long a device-supplied Retry-After can stall the CLI, so a buggy or
49
52
  // hostile device cannot pin a command for minutes/hours.
50
53
  const MAX_RETRY_DELAY_MS = 30_000;
54
+ /** Find an FF1 `ok` flag through the nested cast response envelopes. */
55
+ function nestedCastOk(value) {
56
+ if (!value || typeof value !== 'object') {
57
+ return undefined;
58
+ }
59
+ const record = value;
60
+ if (typeof record.ok === 'boolean') {
61
+ return record.ok;
62
+ }
63
+ return nestedCastOk(record.message);
64
+ }
51
65
  /**
52
66
  * Sleep for a short duration before retrying transient network errors.
53
67
  *
@@ -162,14 +176,21 @@ function parseRetryAfterMs(headerValue) {
162
176
  * deviceName: 'Living Room Display'
163
177
  * });
164
178
  */
165
- async function sendPlaylistToDevice({ playlist, deviceName, }) {
179
+ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
180
+ const dependencies = {
181
+ fetchFn: overrides.fetchFn ?? globalThis.fetch.bind(globalThis),
182
+ getTopicIdFn: overrides.getTopicIdFn ?? topic_store_1.getTopicId,
183
+ getRelayerConfigFn: overrides.getRelayerConfigFn ?? config_1.getFF1RelayerConfig,
184
+ waitFn: overrides.waitFn ?? waitForRetry,
185
+ noticeFn: overrides.noticeFn ?? logger.always,
186
+ };
166
187
  let device;
167
188
  try {
168
189
  // Validate input
169
190
  if (!playlist || typeof playlist !== 'object') {
170
191
  return {
171
192
  success: false,
172
- error: 'Invalid playlist: must provide a valid DP1 playlist object',
193
+ error: 'Invalid playlist: must provide a valid DP-1 playlist object',
173
194
  };
174
195
  }
175
196
  const resolved = (0, ff1_compatibility_1.resolveConfiguredDevice)(deviceName);
@@ -180,7 +201,9 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
180
201
  };
181
202
  }
182
203
  device = resolved.device;
183
- const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'displayPlaylist');
204
+ const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'displayPlaylist', {
205
+ fetchFn: dependencies.fetchFn,
206
+ });
184
207
  if (!compatibility.compatible) {
185
208
  return {
186
209
  success: false,
@@ -189,12 +212,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
189
212
  };
190
213
  }
191
214
  logger.info(`Sending playlist to FF1 device: ${device.host}`);
192
- // Construct API URL with optional topicID
193
- let apiUrl = `${device.host}/api/cast`;
194
- if (device.topicID && device.topicID.trim() !== '') {
195
- apiUrl += `?topicID=${encodeURIComponent(device.topicID)}`;
196
- logger.debug(`Using topicID: ${device.topicID}`);
197
- }
215
+ const apiUrl = `${device.host}/api/cast`;
198
216
  // Wrap playlist in required structure
199
217
  const requestBody = {
200
218
  command: 'displayPlaylist',
@@ -221,7 +239,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
221
239
  let lastBodyIssue = null;
222
240
  for (let attempt = 1; attempt <= SEND_RETRY_ATTEMPTS; attempt += 1) {
223
241
  try {
224
- response = await fetch(apiUrl, {
242
+ response = await dependencies.fetchFn(apiUrl, {
225
243
  method: 'POST',
226
244
  headers,
227
245
  body: JSON.stringify(requestBody),
@@ -234,7 +252,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
234
252
  const retryDelay = Math.min(retryAfterMs ?? SEND_RETRY_BASE_DELAY_MS * attempt, MAX_RETRY_DELAY_MS);
235
253
  logger.warn(`Device rate-limited the command (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}); retrying in ${retryDelay}ms`);
236
254
  response = null;
237
- await waitForRetry(retryDelay);
255
+ await dependencies.waitFn(retryDelay);
238
256
  continue;
239
257
  }
240
258
  // Non-2xx (other than 429 handled above): surface via the post-loop
@@ -263,7 +281,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
263
281
  const retryDelay = SEND_RETRY_BASE_DELAY_MS * attempt;
264
282
  logger.warn(`${lastBodyIssue} (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}); retrying in ${retryDelay}ms`);
265
283
  response = null;
266
- await waitForRetry(retryDelay);
284
+ await dependencies.waitFn(retryDelay);
267
285
  continue;
268
286
  }
269
287
  // Out of attempts; let the post-loop handler report the body issue.
@@ -277,17 +295,22 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
277
295
  const retryDelay = SEND_RETRY_BASE_DELAY_MS * attempt;
278
296
  logger.warn(`Transient network error while sending playlist (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}): ${error.message}`);
279
297
  logger.debug(`Retrying playlist send in ${retryDelay}ms`);
280
- await waitForRetry(retryDelay);
298
+ await dependencies.waitFn(retryDelay);
281
299
  }
282
300
  }
283
301
  if (!response) {
284
302
  const deviceLabel = device.name || device.host;
285
- return {
286
- success: false,
287
- error: `Could not reach device "${deviceLabel}" at ${device.host}`,
288
- details: 'Check that the device is powered on and reachable on your network. ' +
289
- 'If the device IP changed (e.g. after a factory reset), run: ff-cli setup',
290
- };
303
+ return fallbackToRelayer({
304
+ playlist,
305
+ device,
306
+ dependencies,
307
+ lanFailure: {
308
+ success: false,
309
+ error: `Could not reach device "${deviceLabel}" at ${device.host}`,
310
+ details: 'Check that the device is powered on and reachable on your network. ' +
311
+ 'If the device IP changed (e.g. after a factory reset), run: ff-cli setup',
312
+ },
313
+ });
291
314
  }
292
315
  // Check response status
293
316
  if (!response.ok) {
@@ -321,6 +344,13 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
321
344
  'wait a few seconds and try again.',
322
345
  };
323
346
  }
347
+ if (nestedCastOk(responseData) === false) {
348
+ return {
349
+ success: false,
350
+ error: `Device "${device.name || device.host}" rejected the display command`,
351
+ details: JSON.stringify(responseData),
352
+ };
353
+ }
324
354
  logger.info('Successfully sent playlist to FF1 device');
325
355
  logger.debug(`Device response: ${JSON.stringify(responseData)}`);
326
356
  return {
@@ -329,6 +359,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
329
359
  deviceName: device.name || device.host,
330
360
  response: responseData,
331
361
  message: 'Playlist successfully sent to FF1 device',
362
+ transport: 'lan',
332
363
  };
333
364
  }
334
365
  catch (error) {
@@ -336,12 +367,17 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
336
367
  logger.error(`Error sending playlist to device: ${errorMessage}`);
337
368
  if (device && isTransientDeviceNetworkError(error)) {
338
369
  const deviceLabel = device.name || device.host;
339
- return {
340
- success: false,
341
- error: `Could not reach device "${deviceLabel}" at ${device.host}`,
342
- details: 'Check that the device is powered on and reachable on your network. ' +
343
- 'If the device IP changed (e.g. after a factory reset), run: ff-cli setup',
344
- };
370
+ return fallbackToRelayer({
371
+ playlist,
372
+ device,
373
+ dependencies,
374
+ lanFailure: {
375
+ success: false,
376
+ error: `Could not reach device "${deviceLabel}" at ${device.host}`,
377
+ details: 'Check that the device is powered on and reachable on your network. ' +
378
+ 'If the device IP changed (e.g. after a factory reset), run: ff-cli setup',
379
+ },
380
+ });
345
381
  }
346
382
  return {
347
383
  success: false,
@@ -349,3 +385,69 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
349
385
  };
350
386
  }
351
387
  }
388
+ /**
389
+ * Fall back to the relayer only after LAN reachability is exhausted.
390
+ *
391
+ * Topic lookup is keyed by the stable physical device ID and happens only at
392
+ * the fallback boundary, keeping the credential out of config and normal LAN
393
+ * requests. Application/HTTP errors do not enter this path.
394
+ */
395
+ async function fallbackToRelayer(input) {
396
+ if (!input.device.id) {
397
+ return {
398
+ ...input.lanFailure,
399
+ details: `${input.lanFailure.details} Pair the relayer with: ff-cli device pair ${input.device.name ?? '<device>'}`,
400
+ };
401
+ }
402
+ let topicId;
403
+ try {
404
+ topicId = input.dependencies.getTopicIdFn(input.device.id);
405
+ }
406
+ catch (error) {
407
+ return {
408
+ ...input.lanFailure,
409
+ details: `${input.lanFailure.details} Secure topic storage is unavailable: ${error.message}`,
410
+ };
411
+ }
412
+ if (!topicId) {
413
+ return {
414
+ ...input.lanFailure,
415
+ details: `${input.lanFailure.details} Pair this device for relayer fallback with: ff-cli device pair ${input.device.name ?? input.device.id}`,
416
+ };
417
+ }
418
+ input.dependencies.noticeFn(`Notice: LAN device is unreachable; falling back to the FF1 relayer for ${input.device.name ?? input.device.id}.`);
419
+ let relayer;
420
+ try {
421
+ relayer = input.dependencies.getRelayerConfigFn();
422
+ }
423
+ catch (error) {
424
+ return {
425
+ success: false,
426
+ error: 'Invalid FF1 relayer configuration',
427
+ details: error.message,
428
+ transport: 'relayer',
429
+ };
430
+ }
431
+ const result = await (0, ff1_relayer_1.sendPlaylistViaRelayer)({
432
+ ...relayer,
433
+ topicId,
434
+ playlist: input.playlist,
435
+ fetchFn: input.dependencies.fetchFn,
436
+ });
437
+ if (!result.success) {
438
+ return {
439
+ success: false,
440
+ error: result.error,
441
+ details: result.details,
442
+ transport: 'relayer',
443
+ };
444
+ }
445
+ return {
446
+ success: true,
447
+ device: relayer.baseUrl,
448
+ deviceName: input.device.name ?? input.device.id,
449
+ response: result.response,
450
+ message: 'Playlist successfully sent through the FF1 relayer',
451
+ transport: 'relayer',
452
+ };
453
+ }
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sendPlaylistViaRelayer = sendPlaylistViaRelayer;
4
+ function nestedOk(value) {
5
+ if (!value || typeof value !== 'object') {
6
+ return undefined;
7
+ }
8
+ const record = value;
9
+ if (typeof record.ok === 'boolean') {
10
+ return record.ok;
11
+ }
12
+ return nestedOk(record.message);
13
+ }
14
+ /**
15
+ * sendPlaylistViaRelayer sends a display command through the mobile-compatible
16
+ * FF1 relayer contract.
17
+ *
18
+ * The result preserves the relayer's HTTP failure status before interpreting
19
+ * any response body. Successful responses must contain JSON when non-empty;
20
+ * non-2xx responses may use plain text or HTML without obscuring the original
21
+ * gateway failure.
22
+ *
23
+ * @param input - Relayer endpoint, optional compatibility key, paired topic,
24
+ * playlist, and optional fetch implementation.
25
+ * @returns A structured result for handled request failures, HTTP statuses,
26
+ * and JSON parsing errors.
27
+ */
28
+ async function sendPlaylistViaRelayer(input) {
29
+ const fetchFn = input.fetchFn ?? globalThis.fetch.bind(globalThis);
30
+ let url;
31
+ try {
32
+ url = new URL('/api/cast', input.baseUrl);
33
+ }
34
+ catch {
35
+ return {
36
+ success: false,
37
+ error: 'Invalid FF1 relayer configuration',
38
+ details: 'ff1Relayer.baseUrl must be a valid HTTP(S) URL',
39
+ };
40
+ }
41
+ url.searchParams.set('topicID', input.topicId);
42
+ const headers = { 'Content-Type': 'application/json' };
43
+ if (input.apiKey) {
44
+ headers['API-KEY'] = input.apiKey;
45
+ }
46
+ let response;
47
+ try {
48
+ response = await fetchFn(url, {
49
+ // The relayer accepts GET or POST with the same body. Node's standards-
50
+ // compliant fetch rejects GET bodies, so CLI uses POST while preserving
51
+ // the mobile request shape and topic query parameter.
52
+ method: 'POST',
53
+ headers,
54
+ body: JSON.stringify({
55
+ command: 'displayPlaylist',
56
+ request: {
57
+ dp1_call: input.playlist,
58
+ intent: { action: 'now_display' },
59
+ },
60
+ }),
61
+ });
62
+ }
63
+ catch (error) {
64
+ return {
65
+ success: false,
66
+ error: 'Could not reach the FF1 relayer',
67
+ details: error.message,
68
+ };
69
+ }
70
+ const bodyText = (await response.text()).trim();
71
+ // HTTP status is authoritative for gateway failures. Parse only successful
72
+ // responses so an HTML/plain-text 401 cannot hide the actionable missing-key
73
+ // diagnosis behind a secondary JSON parsing error.
74
+ if (!response.ok) {
75
+ const needsCompatibilityKey = response.status === 401 && !input.apiKey;
76
+ return {
77
+ success: false,
78
+ error: `FF1 relayer returned HTTP ${response.status}`,
79
+ details: needsCompatibilityKey
80
+ ? 'This relayer deployment still requires its optional FF1_RELAYER_API_KEY compatibility gate.'
81
+ : bodyText || response.statusText,
82
+ };
83
+ }
84
+ let data = {};
85
+ if (bodyText) {
86
+ try {
87
+ data = JSON.parse(bodyText);
88
+ }
89
+ catch {
90
+ return {
91
+ success: false,
92
+ error: `FF1 relayer returned non-JSON HTTP ${response.status}`,
93
+ details: bodyText.slice(0, 200),
94
+ };
95
+ }
96
+ }
97
+ if (nestedOk(data) === false) {
98
+ return {
99
+ success: false,
100
+ error: 'FF1 rejected the relayer display command',
101
+ details: bodyText,
102
+ };
103
+ }
104
+ return { success: true, response: data };
105
+ }
@@ -259,7 +259,7 @@ async function verifyAddresses(params) {
259
259
  * @returns {Array<Object>} returns.devices - Array of device configurations
260
260
  * @returns {string} returns.devices[].name - Device name
261
261
  * @returns {string} returns.devices[].host - Device host URL
262
- * @returns {string} [returns.devices[].topicID] - Optional topic ID
262
+ * @returns {string} [returns.devices[].id] - Optional physical device ID
263
263
  * @returns {string} [returns.error] - Error message if no devices configured
264
264
  * @example
265
265
  * const result = await getConfiguredDevices();
@@ -291,7 +291,7 @@ async function getConfiguredDevices() {
291
291
  const devices = deviceConfig.devices.map((d) => ({
292
292
  name: d.name || d.host,
293
293
  host: d.host,
294
- topicID: d.topicID,
294
+ id: d.id,
295
295
  }));
296
296
  return {
297
297
  success: true,