@feralfile/cli 2.10.1 → 4.0.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.
Files changed (59) hide show
  1. package/README.md +31 -23
  2. package/config.json.example +6 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +40 -0
  6. package/dist/src/commands/config.js +1 -0
  7. package/dist/src/commands/device.js +85 -40
  8. package/dist/src/commands/enrich.js +18 -23
  9. package/dist/src/commands/find.js +144 -106
  10. package/dist/src/commands/helpers/device-control.js +937 -0
  11. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  12. package/dist/src/commands/helpers/playlist-display.js +2 -15
  13. package/dist/src/commands/helpers/prompt.js +85 -10
  14. package/dist/src/commands/play.js +162 -84
  15. package/dist/src/commands/report.js +251 -0
  16. package/dist/src/commands/setup.js +82 -7
  17. package/dist/src/commands/status.js +4 -1
  18. package/dist/src/config.js +256 -6
  19. package/dist/src/main.js +14 -5
  20. package/dist/src/utilities/control/codec.js +578 -0
  21. package/dist/src/utilities/control/control-errors.js +157 -0
  22. package/dist/src/utilities/control/controller-store.js +964 -0
  23. package/dist/src/utilities/control/jwk.js +141 -0
  24. package/dist/src/utilities/control/pairing-channel.js +52 -0
  25. package/dist/src/utilities/control/pairing.js +416 -0
  26. package/dist/src/utilities/control/registration.js +391 -0
  27. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  28. package/dist/src/utilities/control/secure-transport.js +850 -0
  29. package/dist/src/utilities/control/strict-json.js +376 -0
  30. package/dist/src/utilities/device-lookup.js +36 -11
  31. package/dist/src/utilities/device-network.js +129 -0
  32. package/dist/src/utilities/device-upsert.js +1 -1
  33. package/dist/src/utilities/feral-file-artwork.js +2 -2
  34. package/dist/src/utilities/ff1-compatibility.js +87 -11
  35. package/dist/src/utilities/ff1-device.js +161 -78
  36. package/dist/src/utilities/ff1-relayer.js +34 -14
  37. package/dist/src/utilities/handoff-client.js +23 -9
  38. package/dist/src/utilities/index.js +270 -259
  39. package/dist/src/utilities/media-url.js +117 -0
  40. package/dist/src/utilities/neort-marketplace.js +3 -3
  41. package/dist/src/utilities/nft-indexer.js +12 -376
  42. package/dist/src/utilities/playlist-builder.js +11 -2
  43. package/dist/src/utilities/playlist-cast.js +6 -6
  44. package/dist/src/utilities/playlist-source.js +223 -34
  45. package/dist/src/utilities/raster-client.js +101 -14
  46. package/dist/src/utilities/relay-client.js +774 -0
  47. package/dist/src/utilities/ssh-access.js +192 -58
  48. package/dist/src/utilities/support-report.js +511 -0
  49. package/dist/src/utilities/temporary-channel.js +139 -0
  50. package/dist/src/utilities/token-item.js +428 -0
  51. package/dist/src/utilities/token-source-contract.js +55 -0
  52. package/dist/src/utilities/token-source-indexer.js +99 -0
  53. package/dist/src/utilities/token-source-raster.js +378 -0
  54. package/dist/src/utilities/token-source.js +48 -0
  55. package/docs/CONFIGURATION.md +331 -28
  56. package/docs/EXAMPLES.md +33 -33
  57. package/docs/PROJECT_SPEC.md +55 -36
  58. package/docs/README.md +145 -43
  59. package/package.json +5 -2
@@ -42,6 +42,8 @@ const http_1 = require("./http");
42
42
  const config_1 = require("../config");
43
43
  const device_lookup_1 = require("./device-lookup");
44
44
  const logger = __importStar(require("../logger"));
45
+ const secure_transport_1 = require("./control/secure-transport");
46
+ const control_errors_1 = require("./control/control-errors");
45
47
  const FF1_COMMAND_POLICIES = {
46
48
  displayPlaylist: {
47
49
  minimumVersion: '1.0.0',
@@ -118,7 +120,12 @@ async function assertFF1CommandCompatibility(device, command, options = {}) {
118
120
  // the TCP connection but never answers must not hang `ff-cli ssh`.
119
121
  const fetchFn = options.fetchFn || ((input, init) => (0, http_1.fetchWithTimeout)(input, init ?? {}));
120
122
  const policy = getCommandPolicy(command);
121
- const versionResult = await detectFF1VersionSafely(device.host, buildVersionHeaders(device), fetchFn);
123
+ const versionResult = options.controller && device.id
124
+ ? await detectFF1VersionSecurely(device, options.controller)
125
+ : await detectFF1VersionSafely(device, buildVersionHeaders(device), fetchFn);
126
+ if (versionResult?.refusal) {
127
+ return { compatible: false, ...versionResult.refusal };
128
+ }
122
129
  return resolveCompatibility(device, command, policy, versionResult);
123
130
  }
124
131
  /**
@@ -135,14 +142,14 @@ function getCommandPolicy(command) {
135
142
  /**
136
143
  * Detect FF1 version and recover compatibility when detection fails.
137
144
  *
138
- * @param {string} host - Device host URL
145
+ * @param {FF1Device} device - Target device
139
146
  * @param {Object} headers - Request headers
140
147
  * @param {Function} fetchFn - Fetch implementation
141
148
  * @returns {Promise<FF1VersionProbe | null>} Detected version metadata
142
149
  */
143
- async function detectFF1VersionSafely(host, headers, fetchFn) {
150
+ async function detectFF1VersionSafely(device, headers, fetchFn) {
144
151
  try {
145
- return await detectFF1Version(host, headers, fetchFn);
152
+ return await detectFF1Version(device, headers, fetchFn);
146
153
  }
147
154
  catch (error) {
148
155
  logger.debug('FF1 version detection failed; continuing with command', error.message);
@@ -159,7 +166,7 @@ async function detectFF1VersionSafely(host, headers, fetchFn) {
159
166
  * @returns {FF1CompatibilityResult} Compatibility decision
160
167
  */
161
168
  function resolveCompatibility(device, command, policy, versionResult) {
162
- if (!versionResult) {
169
+ if (!versionResult?.version) {
163
170
  logger.warn(`Could not verify FF1 OS version for ${device.name || device.host}`);
164
171
  return { compatible: true };
165
172
  }
@@ -187,22 +194,22 @@ function resolveCompatibility(device, command, policy, versionResult) {
187
194
  *
188
195
  * Reads `message.installedVersion` from the device status response.
189
196
  *
190
- * @param {string} host - Device host URL
197
+ * @param {FF1Device} device - Target device
191
198
  * @param {Record<string, string>} headers - Request headers (e.g. API-KEY)
192
199
  * @param {FetchFunction} fetchFn - Fetch implementation
193
- * @returns {Promise<FF1VersionProbe | null>} Version probe or null if unavailable
200
+ * @returns {Promise<FF1VersionProbe | null>} Version probe, a refusal, or null if unavailable
194
201
  * @example
195
- * const probe = await detectFF1Version('http://ff1.local', {}, fetch);
202
+ * const probe = await detectFF1Version(device, {}, fetch);
196
203
  */
197
- async function detectFF1Version(host, headers, fetchFn) {
204
+ async function detectFF1Version(device, headers, fetchFn) {
198
205
  try {
199
- const response = await fetchFn(`${host}/api/cast`, {
206
+ const response = await fetchFn(`${device.host}/api/cast`, {
200
207
  method: 'POST',
201
208
  headers: { ...headers, 'Content-Type': 'application/json' },
202
209
  body: JSON.stringify({ command: 'getDeviceStatus', request: {} }),
203
210
  });
204
211
  if (!response.ok) {
205
- return null;
212
+ return refusalFromPublicError(response.status, await response.text(), device.name || device.id || device.host);
206
213
  }
207
214
  const data = (await response.json());
208
215
  const version = data?.message?.installedVersion;
@@ -215,6 +222,75 @@ async function detectFF1Version(host, headers, fetchFn) {
215
222
  return null;
216
223
  }
217
224
  }
225
+ /**
226
+ * refusalFromPublicError recognizes a secured FF1 refusing a plaintext LAN
227
+ * cast (ff1-control-v1 §4.4, HTTP 403 `enrollment_required`). That answer
228
+ * means every plaintext command will be refused, so the preflight stops with
229
+ * the pairing guidance instead of sending the real command into the same 403.
230
+ * Any other failure keeps the historical "could not verify" behavior.
231
+ *
232
+ * @param status - HTTP status
233
+ * @param body - Response body text
234
+ * @param label - Device name for the pair command
235
+ * @returns A refusal probe, or null
236
+ */
237
+ function refusalFromPublicError(status, body, label) {
238
+ if (status !== 401 && status !== 403) {
239
+ return null;
240
+ }
241
+ try {
242
+ const code = JSON.parse(body)?.error?.code;
243
+ if (code === 'enrollment_required' || code === 'locked') {
244
+ return { refusal: (0, control_errors_1.describeControlCode)(code, label) };
245
+ }
246
+ }
247
+ catch {
248
+ // Not a control-protocol answer.
249
+ }
250
+ return null;
251
+ }
252
+ /**
253
+ * TRANSIENT_PROBE_CODES are secure-probe answers that do not mean "this
254
+ * controller may not proceed": the FF1 shed load (busy, rate_limited, or the
255
+ * router's storm protection surfacing as command_failed) or could not record
256
+ * the replay window (internal_error, §4.3: not executed, retry with a new ctr).
257
+ * The plaintext probe sees the same conditions as HTTP 429 and carries on, so
258
+ * the secure probe does too; the real command then applies its own retries.
259
+ */
260
+ const TRANSIENT_PROBE_CODES = new Set(['busy', 'rate_limited', 'internal_error', 'command_failed']);
261
+ /**
262
+ * detectFF1VersionSecurely runs the getDeviceStatus probe as a secure
263
+ * envelope over the LAN only (no retry, no relayer: the probe never had
264
+ * either). A protocol refusal (revoked, unknown controller, …) stops the
265
+ * command with actionable guidance; an unreachable device degrades to
266
+ * "could not verify", exactly like the plaintext probe.
267
+ *
268
+ * @param device - Device with a stable ID
269
+ * @param controller - Enrolled controller record and transport seams
270
+ * @returns Version probe, refusal, or null
271
+ */
272
+ async function detectFF1VersionSecurely(device, controller) {
273
+ const result = await (0, secure_transport_1.runSecureCommand)({
274
+ device,
275
+ record: controller.record,
276
+ cmd: 'getDeviceStatus',
277
+ req: {},
278
+ relayerFallback: false,
279
+ retry: false,
280
+ }, controller.deps);
281
+ if (!result.success) {
282
+ if (result.code && !TRANSIENT_PROBE_CODES.has(result.code)) {
283
+ return { refusal: { error: result.error, details: result.details } };
284
+ }
285
+ logger.debug('Secure FF1 version probe failed; continuing with command', result.error);
286
+ return null;
287
+ }
288
+ // res is the handler's existing body; accept both the bare status object
289
+ // and the `{message:{…}}` wrapper the plaintext LAN reply uses.
290
+ const res = result.response;
291
+ const version = res?.installedVersion ?? res?.message?.installedVersion;
292
+ return typeof version === 'string' && version ? { version } : null;
293
+ }
218
294
  /**
219
295
  * Build headers shared by cast requests.
220
296
  *
@@ -37,21 +37,36 @@ var __importStar = (this && this.__importStar) || (function () {
37
37
  };
38
38
  })();
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
- exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
41
- exports.parseRetryAfterMs = parseRetryAfterMs;
40
+ exports.parseRetryAfterMs = exports.isTransientDeviceNetworkError = void 0;
42
41
  exports.sendPlaylistToDevice = sendPlaylistToDevice;
42
+ exports.publicControlCode = publicControlCode;
43
43
  const http_1 = require("./http");
44
44
  const logger = __importStar(require("../logger"));
45
45
  const config_1 = require("../config");
46
46
  const ff1_compatibility_1 = require("./ff1-compatibility");
47
47
  const ff1_relayer_1 = require("./ff1-relayer");
48
48
  const topic_store_1 = require("./topic-store");
49
+ const secure_transport_1 = require("./control/secure-transport");
50
+ const control_errors_1 = require("./control/control-errors");
51
+ const device_network_1 = require("./device-network");
52
+ Object.defineProperty(exports, "isTransientDeviceNetworkError", { enumerable: true, get: function () { return device_network_1.isTransientDeviceNetworkError; } });
53
+ Object.defineProperty(exports, "parseRetryAfterMs", { enumerable: true, get: function () { return device_network_1.parseRetryAfterMs; } });
49
54
  const SEND_RETRY_ATTEMPTS = 3;
50
55
  const SEND_RETRY_BASE_DELAY_MS = 750;
51
56
  const RATE_LIMIT_STATUS = 429;
52
57
  // Cap how long a device-supplied Retry-After can stall the CLI, so a buggy or
53
58
  // hostile device cannot pin a command for minutes/hours.
54
59
  const MAX_RETRY_DELAY_MS = 30_000;
60
+ // PUBLIC_CONTROL_CODES are the ff1-control-v1 §4.4 public errors a secured
61
+ // FF1 can answer a plaintext cast with (LAN 401/403, relayer RPC message).
62
+ const PUBLIC_CONTROL_CODES = new Set([
63
+ 'bad_envelope',
64
+ 'unknown_controller',
65
+ 'bad_signature',
66
+ 'enrollment_required',
67
+ 'envelope_required',
68
+ 'locked',
69
+ ]);
55
70
  /** Find an FF1 `ok` flag through the nested cast response envelopes. */
56
71
  function nestedCastOk(value) {
57
72
  if (!value || typeof value !== 'object') {
@@ -74,78 +89,6 @@ function waitForRetry(delayMs) {
74
89
  setTimeout(resolve, delayMs);
75
90
  });
76
91
  }
77
- /**
78
- * isTransientDeviceNetworkError returns true when a send failure is likely temporary.
79
- *
80
- * This classifier intentionally targets resolver and route-level failures that are
81
- * common on local mDNS/Wi-Fi environments. Permanent command errors should surface
82
- * immediately without retry loops.
83
- *
84
- * @param {unknown} error - Error thrown by fetch
85
- * @returns {boolean} True when retrying is likely to recover
86
- */
87
- function isTransientDeviceNetworkError(error) {
88
- if (!(error instanceof Error)) {
89
- return false;
90
- }
91
- const networkCodes = new Set([
92
- 'EHOSTUNREACH',
93
- 'ENETUNREACH',
94
- 'ENOTFOUND',
95
- 'EAI_AGAIN',
96
- 'ETIMEDOUT',
97
- 'ECONNREFUSED',
98
- 'ECONNRESET',
99
- ]);
100
- const message = error.message || '';
101
- const messageLooksTransient = message.includes('fetch failed') ||
102
- message.includes('getaddrinfo') ||
103
- message.includes('EHOSTUNREACH') ||
104
- message.includes('ENOTFOUND') ||
105
- message.includes('ETIMEDOUT') ||
106
- message.includes('network timeout') ||
107
- message.includes('No route to host');
108
- if (messageLooksTransient) {
109
- return true;
110
- }
111
- const causeCode = error.cause &&
112
- typeof error.cause === 'object' &&
113
- 'code' in error.cause &&
114
- typeof error.cause.code === 'string'
115
- ? error.cause.code
116
- : undefined;
117
- return Boolean(causeCode && networkCodes.has(causeCode));
118
- }
119
- /**
120
- * parseRetryAfterMs converts an HTTP `Retry-After` header into milliseconds.
121
- *
122
- * Supports both forms from RFC 7231: a delta-seconds integer (e.g. "5") and an
123
- * HTTP-date (e.g. "Wed, 21 Oct 2025 07:28:00 GMT"). Returns undefined when the
124
- * header is absent or unparseable, so callers can fall back to their own
125
- * backoff. A past date clamps to 0 (retry immediately).
126
- *
127
- * @param {string | null | undefined} headerValue - Raw Retry-After header value
128
- * @returns {number | undefined} Delay in milliseconds, or undefined if unknown
129
- */
130
- function parseRetryAfterMs(headerValue) {
131
- if (!headerValue) {
132
- return undefined;
133
- }
134
- const trimmed = headerValue.trim();
135
- if (/^\d+$/.test(trimmed)) {
136
- return Number(trimmed) * 1000;
137
- }
138
- // A valid HTTP-date always contains letters (month/day names, or the ISO
139
- // 'T'/'Z'). Requiring one rejects bare numeric junk (e.g. "-5", "5.5") that
140
- // Date.parse would otherwise misinterpret as a date.
141
- if (/[a-zA-Z]/.test(trimmed)) {
142
- const dateMs = Date.parse(trimmed);
143
- if (!Number.isNaN(dateMs)) {
144
- return Math.max(0, dateMs - Date.now());
145
- }
146
- }
147
- return undefined;
148
- }
149
92
  /**
150
93
  * Send a DP1 playlist to an FF1 device using the cast API
151
94
  *
@@ -186,6 +129,8 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
186
129
  getRelayerConfigFn: overrides.getRelayerConfigFn ?? config_1.getFF1RelayerConfig,
187
130
  waitFn: overrides.waitFn ?? waitForRetry,
188
131
  noticeFn: overrides.noticeFn ?? logger.always,
132
+ controllerStore: overrides.controllerStore,
133
+ nowFn: overrides.nowFn,
189
134
  };
190
135
  let device;
191
136
  try {
@@ -204,16 +149,30 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
204
149
  };
205
150
  }
206
151
  device = resolved.device;
152
+ // A stored controller file means this FF1 was paired under the control
153
+ // protocol (ff1-control-v1): every command goes as a secure envelope and
154
+ // the legacy plaintext path below is never used for it.
155
+ const secure = (0, secure_transport_1.findSecureController)(device, dependencies.controllerStore);
156
+ if (secure.failure) {
157
+ return { success: false, error: secure.failure.error, details: secure.failure.details };
158
+ }
207
159
  const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'displayPlaylist', {
208
160
  fetchFn: dependencies.fetchFn,
161
+ controller: secure.record
162
+ ? { record: secure.record, deps: secureDeps(dependencies) }
163
+ : undefined,
209
164
  });
210
165
  if (!compatibility.compatible) {
211
166
  return {
212
167
  success: false,
213
168
  error: compatibility.error || 'FF1 OS does not support playlist casting',
214
- details: compatibility.version ? `Detected version ${compatibility.version}` : undefined,
169
+ details: compatibility.details ??
170
+ (compatibility.version ? `Detected version ${compatibility.version}` : undefined),
215
171
  };
216
172
  }
173
+ if (secure.record) {
174
+ return sendPlaylistSecurely(playlist, device, secure.record, dependencies);
175
+ }
217
176
  logger.info(`Sending playlist to FF1 device: ${device.host}`);
218
177
  const apiUrl = `${device.host}/api/cast`;
219
178
  // Wrap playlist in required structure
@@ -251,7 +210,7 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
251
210
  // Treat it as transient: honor Retry-After when present, otherwise back
252
211
  // off, and retry within the bounded attempt budget.
253
212
  if (response.status === RATE_LIMIT_STATUS && attempt < SEND_RETRY_ATTEMPTS) {
254
- const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'));
213
+ const retryAfterMs = (0, device_network_1.parseRetryAfterMs)(response.headers.get('retry-after'));
255
214
  const retryDelay = Math.min(retryAfterMs ?? SEND_RETRY_BASE_DELAY_MS * attempt, MAX_RETRY_DELAY_MS);
256
215
  logger.warn(`Device rate-limited the command (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}); retrying in ${retryDelay}ms`);
257
216
  response = null;
@@ -291,7 +250,7 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
291
250
  break;
292
251
  }
293
252
  catch (error) {
294
- const shouldRetry = attempt < SEND_RETRY_ATTEMPTS && isTransientDeviceNetworkError(error);
253
+ const shouldRetry = attempt < SEND_RETRY_ATTEMPTS && (0, device_network_1.isTransientDeviceNetworkError)(error);
295
254
  if (!shouldRetry) {
296
255
  throw error;
297
256
  }
@@ -320,6 +279,11 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
320
279
  const errorText = await response.text();
321
280
  logger.error(`Failed to cast to device: ${response.status} ${response.statusText}`);
322
281
  logger.debug(`Error details: ${errorText}`);
282
+ const publicCode = publicControlCode(response.status, errorText);
283
+ if (publicCode) {
284
+ const failure = (0, control_errors_1.describeControlCode)(publicCode, device.name || device.id || device.host);
285
+ return { success: false, error: failure.error, details: failure.details };
286
+ }
323
287
  if (response.status === RATE_LIMIT_STATUS) {
324
288
  const deviceLabel = device.name || device.host;
325
289
  return {
@@ -368,7 +332,7 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
368
332
  catch (error) {
369
333
  const errorMessage = error.message;
370
334
  logger.error(`Error sending playlist to device: ${errorMessage}`);
371
- if (device && isTransientDeviceNetworkError(error)) {
335
+ if (device && (0, device_network_1.isTransientDeviceNetworkError)(error)) {
372
336
  const deviceLabel = device.name || device.host;
373
337
  return fallbackToRelayer({
374
338
  playlist,
@@ -388,6 +352,117 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
388
352
  };
389
353
  }
390
354
  }
355
+ /**
356
+ * secureDeps forwards the delivery seams to the secure transport.
357
+ *
358
+ * @param dependencies - Resolved delivery dependencies
359
+ * @returns Secure transport dependencies
360
+ */
361
+ function secureDeps(dependencies) {
362
+ return {
363
+ fetchFn: dependencies.fetchFn,
364
+ getTopicIdFn: dependencies.getTopicIdFn,
365
+ getRelayerConfigFn: dependencies.getRelayerConfigFn,
366
+ waitFn: dependencies.waitFn,
367
+ noticeFn: dependencies.noticeFn,
368
+ controllerStore: dependencies.controllerStore,
369
+ nowFn: dependencies.nowFn,
370
+ };
371
+ }
372
+ /**
373
+ * sendPlaylistSecurely delivers displayPlaylist as a secure envelope with the
374
+ * same retry budget and LAN-then-relayer fallback as the plaintext path. The
375
+ * `req` is the unchanged plaintext request object (§3.3).
376
+ *
377
+ * @param playlist - DP-1 playlist
378
+ * @param device - Device with a stored controller record
379
+ * @param record - Enrolled controller record
380
+ * @param dependencies - Delivery seams
381
+ * @returns Send result
382
+ */
383
+ async function sendPlaylistSecurely(playlist, device, record, dependencies) {
384
+ logger.info(`Sending playlist to FF1 device (secure): ${device.host}`);
385
+ const result = await (0, secure_transport_1.runSecureCommand)({
386
+ device: device,
387
+ record,
388
+ cmd: 'displayPlaylist',
389
+ req: { dp1_call: playlist, intent: { action: 'now_display' } },
390
+ relayerFallback: true,
391
+ retry: true,
392
+ }, secureDeps(dependencies));
393
+ const deviceName = device.name || device.host;
394
+ if (!result.success) {
395
+ return {
396
+ success: false,
397
+ error: result.error,
398
+ details: result.details,
399
+ transport: result.transport,
400
+ };
401
+ }
402
+ if (nestedCastOk(result.response) === false) {
403
+ return {
404
+ success: false,
405
+ error: `Device "${deviceName}" rejected the display command`,
406
+ details: JSON.stringify(result.response),
407
+ transport: result.transport,
408
+ };
409
+ }
410
+ const viaRelayer = result.transport === 'relayer';
411
+ return {
412
+ success: true,
413
+ device: device.host,
414
+ deviceName,
415
+ response: result.response,
416
+ message: viaRelayer
417
+ ? 'Playlist successfully sent through the FF1 relayer'
418
+ : 'Playlist successfully sent to FF1 device',
419
+ transport: result.transport,
420
+ };
421
+ }
422
+ /**
423
+ * publicControlCode extracts a control-protocol public error (§4.4) from a
424
+ * LAN 401/403, e.g. a secured FF1 refusing plaintext with
425
+ * `enrollment_required`. Other bodies return undefined.
426
+ *
427
+ * @param status - HTTP status
428
+ * @param body - Response text
429
+ * @returns The code, or undefined
430
+ */
431
+ function publicControlCode(status, body) {
432
+ if (status !== 401 && status !== 403) {
433
+ return undefined;
434
+ }
435
+ try {
436
+ const code = JSON.parse(body)?.error?.code;
437
+ return typeof code === 'string' && PUBLIC_CONTROL_CODES.has(code) ? code : undefined;
438
+ }
439
+ catch {
440
+ return undefined;
441
+ }
442
+ }
443
+ /**
444
+ * plaintextRefusalCode finds a control-protocol refusal in a relayer reply:
445
+ * the flat plaintext denial `{ok:false,error:"<code>",command,message}` or the
446
+ * public-error shape `{error:{code}}`, at any of the relayer's nesting levels.
447
+ *
448
+ * @param response - Parsed relayer response
449
+ * @returns A §4.4 code, or undefined
450
+ */
451
+ function plaintextRefusalCode(response) {
452
+ let current = response;
453
+ for (let depth = 0; depth < 3 && current && typeof current === 'object'; depth += 1) {
454
+ const record = current;
455
+ const error = record.error;
456
+ const code = record.ok === false && typeof error === 'string'
457
+ ? error
458
+ : error?.code;
459
+ if (typeof code === 'string' && PUBLIC_CONTROL_CODES.has(code)) {
460
+ return code;
461
+ }
462
+ current = record.message;
463
+ }
464
+ return undefined;
465
+ }
391
466
  /**
392
467
  * Fall back to the relayer only after LAN reachability is exhausted.
393
468
  *
@@ -437,6 +512,14 @@ async function fallbackToRelayer(input) {
437
512
  playlist: input.playlist,
438
513
  fetchFn: input.dependencies.fetchFn,
439
514
  });
515
+ // A secured FF1 refuses a plaintext relayer cast it will not run
516
+ // (ff1-control-v1 §4.4/§7.2) with a flat `{ok:false,error:"<code>"}` RPC
517
+ // message; map it to pairing guidance instead of a bare rejection.
518
+ const refusal = plaintextRefusalCode(result.response);
519
+ if (refusal) {
520
+ const failure = (0, control_errors_1.describeControlCode)(refusal, input.device.name ?? input.device.id);
521
+ return { success: false, error: failure.error, details: failure.details, transport: 'relayer' };
522
+ }
440
523
  if (!result.success) {
441
524
  return {
442
525
  success: false,
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.sendPlaylistViaRelayer = sendPlaylistViaRelayer;
4
+ exports.sendCastViaRelayer = sendCastViaRelayer;
4
5
  const http_1 = require("./http");
5
6
  function nestedOk(value) {
6
7
  if (!value || typeof value !== 'object') {
@@ -27,6 +28,37 @@ function nestedOk(value) {
27
28
  * and JSON parsing errors.
28
29
  */
29
30
  async function sendPlaylistViaRelayer(input) {
31
+ const result = await sendCastViaRelayer({
32
+ ...input,
33
+ body: {
34
+ command: 'displayPlaylist',
35
+ request: {
36
+ dp1_call: input.playlist,
37
+ intent: { action: 'now_display' },
38
+ },
39
+ },
40
+ });
41
+ if (result.success && nestedOk(result.response) === false) {
42
+ return {
43
+ success: false,
44
+ error: 'FF1 rejected the relayer display command',
45
+ details: JSON.stringify(result.response),
46
+ response: result.response,
47
+ };
48
+ }
49
+ return result;
50
+ }
51
+ /**
52
+ * sendCastViaRelayer posts one cast body to the relayer topic endpoint and
53
+ * returns the parsed JSON response without interpreting the device's answer.
54
+ * It is shared by the legacy plaintext Play path and the secure `ff1Envelope`
55
+ * path (ff1-control-v1 §10: the envelope rides the same topic cast).
56
+ *
57
+ * @param input - Relayer endpoint, optional compatibility key, paired topic,
58
+ * cast body, and optional fetch implementation.
59
+ * @returns The parsed relayer response, or a structured failure.
60
+ */
61
+ async function sendCastViaRelayer(input) {
30
62
  // Production default rides the shared deadline (#101 review).
31
63
  const fetchFn = input.fetchFn ?? http_1.defaultDeadlineFetch;
32
64
  let url;
@@ -53,13 +85,7 @@ async function sendPlaylistViaRelayer(input) {
53
85
  // the mobile request shape and topic query parameter.
54
86
  method: 'POST',
55
87
  headers,
56
- body: JSON.stringify({
57
- command: 'displayPlaylist',
58
- request: {
59
- dp1_call: input.playlist,
60
- intent: { action: 'now_display' },
61
- },
62
- }),
88
+ body: JSON.stringify(input.body),
63
89
  });
64
90
  }
65
91
  catch (error) {
@@ -78,6 +104,7 @@ async function sendPlaylistViaRelayer(input) {
78
104
  return {
79
105
  success: false,
80
106
  error: `FF1 relayer returned HTTP ${response.status}`,
107
+ status: response.status,
81
108
  details: needsCompatibilityKey
82
109
  ? 'This relayer deployment still requires its optional FF1_RELAYER_API_KEY compatibility gate.'
83
110
  : bodyText || response.statusText,
@@ -96,12 +123,5 @@ async function sendPlaylistViaRelayer(input) {
96
123
  };
97
124
  }
98
125
  }
99
- if (nestedOk(data) === false) {
100
- return {
101
- success: false,
102
- error: 'FF1 rejected the relayer display command',
103
- details: bodyText,
104
- };
105
- }
106
126
  return { success: true, response: data };
107
127
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TopicHandoffReceiver = void 0;
3
+ exports.HandoffReceiver = void 0;
4
+ exports.parseHandoffPayload = parseHandoffPayload;
4
5
  const http_1 = require("./http");
5
6
  const handoff_crypto_1 = require("./handoff-crypto");
6
7
  const DEFAULT_HANDOFF_URL = 'https://handoff.feralfile.com';
@@ -33,6 +34,19 @@ function parsePoll(value, channelId) {
33
34
  }
34
35
  return record;
35
36
  }
37
+ /**
38
+ * parseHandoffPayload accepts a v1 topic handoff for the selected device and
39
+ * rejects everything else, including a retired v2 controller handoff.
40
+ *
41
+ * @param value - Decrypted handoff plaintext
42
+ * @param channelId - This receiver's channel
43
+ * @param expectedDeviceId - The configured device's stable ID
44
+ * @returns The validated payload
45
+ * @throws {Error} When the payload is for another channel or device, or malformed
46
+ */
47
+ function parseHandoffPayload(value, channelId, expectedDeviceId) {
48
+ return parseTopicPayload(value, channelId, expectedDeviceId);
49
+ }
36
50
  function parseTopicPayload(value, channelId, expectedDeviceId) {
37
51
  const payload = value;
38
52
  if (payload?.v !== 1 ||
@@ -48,8 +62,8 @@ function parseTopicPayload(value, channelId, expectedDeviceId) {
48
62
  }
49
63
  return payload;
50
64
  }
51
- /** One short-lived CLI-side channel waiting for a mobile topic transfer. */
52
- class TopicHandoffReceiver {
65
+ /** One short-lived CLI-side channel waiting for a mobile topic handoff. */
66
+ class HandoffReceiver {
53
67
  baseUrl;
54
68
  channelId;
55
69
  shortCode;
@@ -90,10 +104,10 @@ class TopicHandoffReceiver {
90
104
  channelId: created.channelId,
91
105
  minterPublicKeyJwk: publicKey,
92
106
  });
93
- return new TopicHandoffReceiver(baseUrl, created.channelId, created.shortCode, verificationCode, created.expiresAt, created.minterToken, keyPair.privateKey, fetchFn);
107
+ return new HandoffReceiver(baseUrl, created.channelId, created.shortCode, verificationCode, created.expiresAt, created.minterToken, keyPair.privateKey, fetchFn);
94
108
  }
95
- /** Poll until mobile sends a valid encrypted topic for the selected device. */
96
- async receiveTopic(options) {
109
+ /** Poll until mobile sends a valid encrypted topic handoff for the selected device. */
110
+ async receiveHandoff(options) {
97
111
  const deadline = Date.now() + (options.maxWaitMs ?? 300_000);
98
112
  let afterSeq = 0;
99
113
  while (Date.now() <= deadline) {
@@ -117,11 +131,11 @@ class TopicHandoffReceiver {
117
131
  seq: message.seq,
118
132
  message,
119
133
  });
120
- return parseTopicPayload(plaintext, this.channelId, options.expectedDeviceId);
134
+ return parseHandoffPayload(plaintext, this.channelId, options.expectedDeviceId);
121
135
  }
122
136
  await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 1000));
123
137
  }
124
- throw new Error('Timed out waiting for the mobile app topic transfer');
138
+ throw new Error('Timed out waiting for the mobile app handoff');
125
139
  }
126
140
  /** Close the broker channel and invalidate its participant credentials. */
127
141
  async close() {
@@ -134,4 +148,4 @@ class TopicHandoffReceiver {
134
148
  }
135
149
  }
136
150
  }
137
- exports.TopicHandoffReceiver = TopicHandoffReceiver;
151
+ exports.HandoffReceiver = HandoffReceiver;