@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.
- package/README.md +31 -23
- package/config.json.example +6 -1
- package/dist/index.js +10 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/build.js +40 -0
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/device.js +85 -40
- package/dist/src/commands/enrich.js +18 -23
- package/dist/src/commands/find.js +144 -106
- package/dist/src/commands/helpers/device-control.js +937 -0
- package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
- package/dist/src/commands/helpers/playlist-display.js +2 -15
- package/dist/src/commands/helpers/prompt.js +85 -10
- package/dist/src/commands/play.js +162 -84
- package/dist/src/commands/report.js +251 -0
- package/dist/src/commands/setup.js +82 -7
- package/dist/src/commands/status.js +4 -1
- package/dist/src/config.js +256 -6
- package/dist/src/main.js +14 -5
- package/dist/src/utilities/control/codec.js +578 -0
- package/dist/src/utilities/control/control-errors.js +157 -0
- package/dist/src/utilities/control/controller-store.js +964 -0
- package/dist/src/utilities/control/jwk.js +141 -0
- package/dist/src/utilities/control/pairing-channel.js +52 -0
- package/dist/src/utilities/control/pairing.js +416 -0
- package/dist/src/utilities/control/registration.js +391 -0
- package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
- package/dist/src/utilities/control/secure-transport.js +850 -0
- package/dist/src/utilities/control/strict-json.js +376 -0
- package/dist/src/utilities/device-lookup.js +36 -11
- package/dist/src/utilities/device-network.js +129 -0
- package/dist/src/utilities/device-upsert.js +1 -1
- package/dist/src/utilities/feral-file-artwork.js +2 -2
- package/dist/src/utilities/ff1-compatibility.js +87 -11
- package/dist/src/utilities/ff1-device.js +161 -78
- package/dist/src/utilities/ff1-relayer.js +34 -14
- package/dist/src/utilities/handoff-client.js +23 -9
- package/dist/src/utilities/index.js +270 -259
- package/dist/src/utilities/media-url.js +117 -0
- package/dist/src/utilities/neort-marketplace.js +3 -3
- package/dist/src/utilities/nft-indexer.js +12 -376
- package/dist/src/utilities/playlist-builder.js +11 -2
- package/dist/src/utilities/playlist-cast.js +6 -6
- package/dist/src/utilities/playlist-source.js +223 -34
- package/dist/src/utilities/raster-client.js +101 -14
- package/dist/src/utilities/relay-client.js +774 -0
- package/dist/src/utilities/ssh-access.js +192 -58
- package/dist/src/utilities/support-report.js +511 -0
- package/dist/src/utilities/temporary-channel.js +139 -0
- package/dist/src/utilities/token-item.js +428 -0
- package/dist/src/utilities/token-source-contract.js +55 -0
- package/dist/src/utilities/token-source-indexer.js +99 -0
- package/dist/src/utilities/token-source-raster.js +378 -0
- package/dist/src/utilities/token-source.js +48 -0
- package/docs/CONFIGURATION.md +331 -28
- package/docs/EXAMPLES.md +33 -33
- package/docs/PROJECT_SPEC.md +55 -36
- package/docs/README.md +145 -43
- 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 =
|
|
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 {
|
|
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(
|
|
150
|
+
async function detectFF1VersionSafely(device, headers, fetchFn) {
|
|
144
151
|
try {
|
|
145
|
-
return await detectFF1Version(
|
|
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 {
|
|
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(
|
|
202
|
+
* const probe = await detectFF1Version(device, {}, fetch);
|
|
196
203
|
*/
|
|
197
|
-
async function detectFF1Version(
|
|
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
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
|
52
|
-
class
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
151
|
+
exports.HandoffReceiver = HandoffReceiver;
|