@feralfile/cli 3.0.0 → 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 (45) hide show
  1. package/README.md +20 -17
  2. package/config.json.example +1 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +1 -1
  6. package/dist/src/commands/device.js +84 -39
  7. package/dist/src/commands/enrich.js +2 -2
  8. package/dist/src/commands/find.js +71 -37
  9. package/dist/src/commands/helpers/device-control.js +937 -0
  10. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  11. package/dist/src/commands/helpers/playlist-display.js +2 -15
  12. package/dist/src/commands/play.js +39 -26
  13. package/dist/src/commands/report.js +251 -0
  14. package/dist/src/commands/setup.js +1 -1
  15. package/dist/src/config.js +162 -6
  16. package/dist/src/utilities/control/codec.js +578 -0
  17. package/dist/src/utilities/control/control-errors.js +157 -0
  18. package/dist/src/utilities/control/controller-store.js +964 -0
  19. package/dist/src/utilities/control/jwk.js +141 -0
  20. package/dist/src/utilities/control/pairing-channel.js +52 -0
  21. package/dist/src/utilities/control/pairing.js +416 -0
  22. package/dist/src/utilities/control/registration.js +391 -0
  23. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  24. package/dist/src/utilities/control/secure-transport.js +850 -0
  25. package/dist/src/utilities/control/strict-json.js +376 -0
  26. package/dist/src/utilities/device-network.js +129 -0
  27. package/dist/src/utilities/device-upsert.js +1 -1
  28. package/dist/src/utilities/ff1-compatibility.js +87 -11
  29. package/dist/src/utilities/ff1-device.js +161 -78
  30. package/dist/src/utilities/ff1-relayer.js +34 -14
  31. package/dist/src/utilities/handoff-client.js +23 -9
  32. package/dist/src/utilities/neort-marketplace.js +3 -3
  33. package/dist/src/utilities/playlist-builder.js +3 -3
  34. package/dist/src/utilities/playlist-cast.js +6 -6
  35. package/dist/src/utilities/playlist-source.js +7 -7
  36. package/dist/src/utilities/raster-client.js +1 -1
  37. package/dist/src/utilities/relay-client.js +774 -0
  38. package/dist/src/utilities/ssh-access.js +192 -58
  39. package/dist/src/utilities/support-report.js +511 -0
  40. package/dist/src/utilities/temporary-channel.js +139 -0
  41. package/docs/CONFIGURATION.md +270 -28
  42. package/docs/EXAMPLES.md +31 -31
  43. package/docs/PROJECT_SPEC.md +32 -19
  44. package/docs/README.md +113 -36
  45. package/package.json +5 -2
@@ -0,0 +1,376 @@
1
+ "use strict";
2
+ /**
3
+ * Strict JSON and base64url decoding for the FF1 control protocol v1
4
+ * (ffos-user docs/ff1-control-v1.md §1).
5
+ *
6
+ * Why this exists instead of `JSON.parse`: two implementations that disagree
7
+ * about what a byte string means (which of two duplicate `kid` members wins,
8
+ * whether `1.0` is the integer 1) are a signature-confusion bug. `JSON.parse`
9
+ * silently keeps the LAST duplicate and erases the difference between `1`,
10
+ * `1.0` and `1e0`, so every header, claims object and JWK that crosses the
11
+ * trust boundary is parsed here instead. The Go reference (control/json.go)
12
+ * applies the same rules; keep the two in step.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.ControlFormatError = void 0;
16
+ exports.b64uDecode = b64uDecode;
17
+ exports.b64uEncode = b64uEncode;
18
+ exports.isB64uOfLength = isB64uOfLength;
19
+ exports.utf8Decode = utf8Decode;
20
+ exports.parseStrictJson = parseStrictJson;
21
+ exports.isPlainObject = isPlainObject;
22
+ exports.strictObject = strictObject;
23
+ exports.stringMember = stringMember;
24
+ exports.uintMember = uintMember;
25
+ exports.boolMember = boolMember;
26
+ exports.toPlainJson = toPlainJson;
27
+ /** ControlFormatError is thrown for every malformed protocol object. */
28
+ class ControlFormatError extends Error {
29
+ constructor(message) {
30
+ super(`ff1-control: ${message}`);
31
+ this.name = 'ControlFormatError';
32
+ }
33
+ }
34
+ exports.ControlFormatError = ControlFormatError;
35
+ /** MAX_SAFE_CLAIM_INT is 2^53-1, the integer ceiling of §1. */
36
+ const MAX_SAFE_CLAIM_INT = Number.MAX_SAFE_INTEGER;
37
+ /** MAX_JSON_DEPTH bounds nesting, matching the Go reference's maxJSONDepth. */
38
+ const MAX_JSON_DEPTH = 128;
39
+ /**
40
+ * rawNumbers remembers the exact source text of every number member of every
41
+ * parsed object, so integer claims can be checked for canonical form after
42
+ * parsing (JSON.parse-style values cannot tell `1` from `1.0`).
43
+ */
44
+ const rawNumbers = new WeakMap();
45
+ const B64U_ALPHABET = /^[A-Za-z0-9_-]*$/;
46
+ /**
47
+ * b64uDecode decodes unpadded base64url strictly: `=` padding, whitespace,
48
+ * any non-alphabet byte and non-zero trailing bits are all rejected (§1). The
49
+ * round-trip comparison is what catches non-canonical trailing bits, which
50
+ * Node's lenient decoder would otherwise accept.
51
+ *
52
+ * @param value - Candidate base64url text
53
+ * @returns The decoded bytes
54
+ * @throws {ControlFormatError} When the text is not canonical base64url
55
+ */
56
+ function b64uDecode(value) {
57
+ if (typeof value !== 'string' || !B64U_ALPHABET.test(value) || value.length % 4 === 1) {
58
+ throw new ControlFormatError('invalid base64url');
59
+ }
60
+ const bytes = Buffer.from(value, 'base64url');
61
+ if (bytes.toString('base64url') !== value) {
62
+ throw new ControlFormatError('non-canonical base64url');
63
+ }
64
+ return new Uint8Array(bytes);
65
+ }
66
+ /**
67
+ * b64uEncode encodes bytes as unpadded base64url.
68
+ *
69
+ * @param bytes - Bytes or UTF-8 text to encode
70
+ * @returns Unpadded base64url text
71
+ */
72
+ function b64uEncode(bytes) {
73
+ return Buffer.from(bytes).toString('base64url');
74
+ }
75
+ /**
76
+ * isB64uOfLength reports whether value is canonical base64url of exactly n bytes.
77
+ *
78
+ * @param value - Candidate value
79
+ * @param n - Required decoded length
80
+ * @returns True when the value decodes to exactly n bytes
81
+ */
82
+ function isB64uOfLength(value, n) {
83
+ if (typeof value !== 'string') {
84
+ return false;
85
+ }
86
+ try {
87
+ return b64uDecode(value).length === n;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * utf8Decode decodes bytes as UTF-8, rejecting invalid sequences (§1). A
95
+ * lenient decoder would map them to U+FFFD and let two different byte strings
96
+ * read as the same JSON.
97
+ *
98
+ * @param bytes - Raw bytes
99
+ * @returns The decoded string
100
+ * @throws {ControlFormatError} On invalid UTF-8
101
+ */
102
+ function utf8Decode(bytes) {
103
+ try {
104
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
105
+ }
106
+ catch {
107
+ throw new ControlFormatError('invalid UTF-8');
108
+ }
109
+ }
110
+ /** StrictParser is a small recursive-descent JSON parser with duplicate-key detection. */
111
+ class StrictParser {
112
+ text;
113
+ pos = 0;
114
+ constructor(text) {
115
+ this.text = text;
116
+ }
117
+ parseDocument() {
118
+ const value = this.parseValue(0);
119
+ this.skipWhitespace();
120
+ if (this.pos !== this.text.length) {
121
+ throw new ControlFormatError('trailing data after JSON value');
122
+ }
123
+ return value;
124
+ }
125
+ fail(message) {
126
+ throw new ControlFormatError(`invalid JSON at ${this.pos}: ${message}`);
127
+ }
128
+ skipWhitespace() {
129
+ while (this.pos < this.text.length && ' \t\r\n'.includes(this.text[this.pos])) {
130
+ this.pos += 1;
131
+ }
132
+ }
133
+ parseValue(depth) {
134
+ if (depth > MAX_JSON_DEPTH) {
135
+ this.fail('nested too deeply');
136
+ }
137
+ this.skipWhitespace();
138
+ const ch = this.text[this.pos];
139
+ if (ch === '{') {
140
+ return this.parseObject(depth);
141
+ }
142
+ if (ch === '[') {
143
+ return this.parseArray(depth);
144
+ }
145
+ if (ch === '"') {
146
+ return this.parseString();
147
+ }
148
+ if (ch === '-' || (ch >= '0' && ch <= '9')) {
149
+ return Number(this.parseNumberText());
150
+ }
151
+ for (const [literal, value] of [
152
+ ['true', true],
153
+ ['false', false],
154
+ ['null', null],
155
+ ]) {
156
+ if (this.text.startsWith(literal, this.pos)) {
157
+ this.pos += literal.length;
158
+ return value;
159
+ }
160
+ }
161
+ return this.fail('unexpected token');
162
+ }
163
+ parseObject(depth) {
164
+ this.pos += 1;
165
+ // A null-prototype object keeps a member named "__proto__" an ordinary
166
+ // member instead of a prototype assignment.
167
+ const out = Object.create(null);
168
+ const numbers = new Map();
169
+ this.skipWhitespace();
170
+ if (this.text[this.pos] === '}') {
171
+ this.pos += 1;
172
+ rawNumbers.set(out, numbers);
173
+ return out;
174
+ }
175
+ for (;;) {
176
+ this.skipWhitespace();
177
+ if (this.text[this.pos] !== '"') {
178
+ this.fail('object key is not a string');
179
+ }
180
+ // Keys are compared after unescaping, so "typ" and "typ" collide.
181
+ const key = this.parseString();
182
+ if (Object.prototype.hasOwnProperty.call(out, key)) {
183
+ this.fail(`duplicate member ${JSON.stringify(key)}`);
184
+ }
185
+ this.skipWhitespace();
186
+ if (this.text[this.pos] !== ':') {
187
+ this.fail('expected ":"');
188
+ }
189
+ this.pos += 1;
190
+ this.skipWhitespace();
191
+ const start = this.pos;
192
+ out[key] = this.parseValue(depth + 1);
193
+ if (typeof out[key] === 'number') {
194
+ numbers.set(key, this.text.slice(start, this.pos));
195
+ }
196
+ this.skipWhitespace();
197
+ const sep = this.text[this.pos];
198
+ this.pos += 1;
199
+ if (sep === '}') {
200
+ rawNumbers.set(out, numbers);
201
+ return out;
202
+ }
203
+ if (sep !== ',') {
204
+ this.fail('expected "," or "}"');
205
+ }
206
+ }
207
+ }
208
+ parseArray(depth) {
209
+ this.pos += 1;
210
+ const out = [];
211
+ this.skipWhitespace();
212
+ if (this.text[this.pos] === ']') {
213
+ this.pos += 1;
214
+ return out;
215
+ }
216
+ for (;;) {
217
+ out.push(this.parseValue(depth + 1));
218
+ this.skipWhitespace();
219
+ const sep = this.text[this.pos];
220
+ this.pos += 1;
221
+ if (sep === ']') {
222
+ return out;
223
+ }
224
+ if (sep !== ',') {
225
+ this.fail('expected "," or "]"');
226
+ }
227
+ }
228
+ }
229
+ parseString() {
230
+ const start = this.pos;
231
+ this.pos += 1;
232
+ for (;;) {
233
+ const ch = this.text[this.pos];
234
+ if (ch === undefined) {
235
+ this.fail('unterminated string');
236
+ }
237
+ if (ch === '"') {
238
+ this.pos += 1;
239
+ break;
240
+ }
241
+ if (ch === '\\') {
242
+ this.pos += 2;
243
+ continue;
244
+ }
245
+ if (ch < ' ') {
246
+ this.fail('control character in string');
247
+ }
248
+ this.pos += 1;
249
+ }
250
+ // The span is grammar-checked by JSON.parse, which also unescapes it.
251
+ try {
252
+ return JSON.parse(this.text.slice(start, this.pos));
253
+ }
254
+ catch {
255
+ return this.fail('invalid string escape');
256
+ }
257
+ }
258
+ parseNumberText() {
259
+ const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(this.text.slice(this.pos));
260
+ if (!match) {
261
+ this.fail('invalid number');
262
+ }
263
+ this.pos += match[0].length;
264
+ return match[0];
265
+ }
266
+ }
267
+ /**
268
+ * parseStrictJson parses UTF-8 JSON text with the §1 rules: one value, no
269
+ * trailing data, no duplicate member names at any depth.
270
+ *
271
+ * @param input - JSON text or raw UTF-8 bytes
272
+ * @returns The parsed value; objects have a null prototype
273
+ * @throws {ControlFormatError} On any violation
274
+ */
275
+ function parseStrictJson(input) {
276
+ const text = typeof input === 'string' ? input : utf8Decode(input);
277
+ return new StrictParser(text).parseDocument();
278
+ }
279
+ /**
280
+ * isPlainObject reports whether value is a parsed JSON object (not an array or null).
281
+ *
282
+ * @param value - Candidate value
283
+ * @returns True for JSON objects
284
+ */
285
+ function isPlainObject(value) {
286
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
287
+ }
288
+ /**
289
+ * strictObject requires value to be an object whose member names are all in
290
+ * required ∪ optional, with every required member present (§1 closed sets).
291
+ *
292
+ * @param value - Parsed JSON value
293
+ * @param required - Members that must be present
294
+ * @param optional - Members that may be present
295
+ * @param what - Name used in error messages
296
+ * @returns The object, typed as a record
297
+ * @throws {ControlFormatError} On any extra or missing member
298
+ */
299
+ function strictObject(value, required, optional, what) {
300
+ if (!isPlainObject(value)) {
301
+ throw new ControlFormatError(`${what} is not a JSON object`);
302
+ }
303
+ for (const key of Object.keys(value)) {
304
+ if (!required.includes(key) && !optional.includes(key)) {
305
+ throw new ControlFormatError(`${what} has unexpected member ${JSON.stringify(key)}`);
306
+ }
307
+ }
308
+ for (const key of required) {
309
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
310
+ throw new ControlFormatError(`${what} is missing member ${JSON.stringify(key)}`);
311
+ }
312
+ }
313
+ return value;
314
+ }
315
+ /**
316
+ * stringMember returns a required string member.
317
+ *
318
+ * @param obj - Parsed object
319
+ * @param key - Member name
320
+ * @returns The string value
321
+ * @throws {ControlFormatError} When the member is absent or not a string
322
+ */
323
+ function stringMember(obj, key) {
324
+ const value = obj[key];
325
+ if (typeof value !== 'string') {
326
+ throw new ControlFormatError(`member ${JSON.stringify(key)} is not a string`);
327
+ }
328
+ return value;
329
+ }
330
+ /**
331
+ * uintMember returns a canonical non-negative integer member in [0, 2^53-1].
332
+ * `1.0`, `1e3`, `-0`, `01` and quoted numbers are rejected (§1): the Dart,
333
+ * JS and Go encoders never produce them, so seeing one means a hand-built or
334
+ * hostile object. Only objects produced by parseStrictJson can be checked.
335
+ *
336
+ * @param obj - Object returned by parseStrictJson
337
+ * @param key - Member name
338
+ * @returns The integer value
339
+ * @throws {ControlFormatError} When absent, non-canonical or out of range
340
+ */
341
+ function uintMember(obj, key) {
342
+ const raw = rawNumbers.get(obj)?.get(key);
343
+ if (raw === undefined || !/^(?:0|[1-9]\d*)$/.test(raw)) {
344
+ throw new ControlFormatError(`member ${JSON.stringify(key)} is not a canonical integer`);
345
+ }
346
+ const value = Number(raw);
347
+ if (!Number.isSafeInteger(value) || value > MAX_SAFE_CLAIM_INT) {
348
+ throw new ControlFormatError(`member ${JSON.stringify(key)} is out of range`);
349
+ }
350
+ return value;
351
+ }
352
+ /**
353
+ * boolMember returns a required boolean member.
354
+ *
355
+ * @param obj - Parsed object
356
+ * @param key - Member name
357
+ * @returns The boolean value
358
+ * @throws {ControlFormatError} When absent or not a boolean
359
+ */
360
+ function boolMember(obj, key) {
361
+ const value = obj[key];
362
+ if (typeof value !== 'boolean') {
363
+ throw new ControlFormatError(`member ${JSON.stringify(key)} is not a boolean`);
364
+ }
365
+ return value;
366
+ }
367
+ /**
368
+ * toPlainJson deep-copies a parsed value onto ordinary prototypes so callers
369
+ * outside the codec (printing, deepEqual in tests) see normal objects.
370
+ *
371
+ * @param value - Value from parseStrictJson
372
+ * @returns Equivalent value with ordinary object prototypes
373
+ */
374
+ function toPlainJson(value) {
375
+ return JSON.parse(JSON.stringify(value));
376
+ }
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ /**
3
+ * Network classification shared by the FF1 LAN delivery paths (legacy
4
+ * plaintext casts in ff1-device.ts and the secure envelope transport in
5
+ * control/secure-transport.ts), so both apply the same retry and relayer
6
+ * fallback rules.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
10
+ exports.isConnectPhaseNetworkError = isConnectPhaseNetworkError;
11
+ exports.parseRetryAfterMs = parseRetryAfterMs;
12
+ /**
13
+ * isTransientDeviceNetworkError returns true when a send failure is likely temporary.
14
+ *
15
+ * This classifier intentionally targets resolver and route-level failures that are
16
+ * common on local mDNS/Wi-Fi environments. Permanent command errors should surface
17
+ * immediately without retry loops.
18
+ *
19
+ * @param {unknown} error - Error thrown by fetch
20
+ * @returns {boolean} True when retrying is likely to recover
21
+ */
22
+ function isTransientDeviceNetworkError(error) {
23
+ if (!(error instanceof Error)) {
24
+ return false;
25
+ }
26
+ const networkCodes = new Set([
27
+ 'EHOSTUNREACH',
28
+ 'ENETUNREACH',
29
+ 'ENOTFOUND',
30
+ 'EAI_AGAIN',
31
+ 'ETIMEDOUT',
32
+ 'ECONNREFUSED',
33
+ 'ECONNRESET',
34
+ ]);
35
+ const message = error.message || '';
36
+ const messageLooksTransient = message.includes('fetch failed') ||
37
+ message.includes('getaddrinfo') ||
38
+ message.includes('EHOSTUNREACH') ||
39
+ message.includes('ENOTFOUND') ||
40
+ message.includes('ETIMEDOUT') ||
41
+ message.includes('network timeout') ||
42
+ message.includes('No route to host');
43
+ if (messageLooksTransient) {
44
+ return true;
45
+ }
46
+ const causeCode = error.cause &&
47
+ typeof error.cause === 'object' &&
48
+ 'code' in error.cause &&
49
+ typeof error.cause.code === 'string'
50
+ ? error.cause.code
51
+ : undefined;
52
+ return Boolean(causeCode && networkCodes.has(causeCode));
53
+ }
54
+ // Codes that can only come from opening the connection (resolving the name,
55
+ // routing, or a refused/timed-out TCP connect): the request bytes were never
56
+ // written, so the FF1 cannot have run it.
57
+ const CONNECT_PHASE_CODES = new Set([
58
+ 'ECONNREFUSED',
59
+ 'EHOSTUNREACH',
60
+ 'ENETUNREACH',
61
+ 'ENOTFOUND',
62
+ 'EAI_AGAIN',
63
+ 'UND_ERR_CONNECT_TIMEOUT',
64
+ ]);
65
+ // Codes that prove the same only when the failing call was the TCP connect.
66
+ // macOS answers a connect to an absent LAN host with EHOSTDOWN (no ARP reply),
67
+ // where Linux says EHOSTUNREACH.
68
+ const CONNECT_SYSCALL_CODES = new Set(['ETIMEDOUT', 'EHOSTDOWN', 'ENETDOWN', 'EADDRNOTAVAIL']);
69
+ /**
70
+ * isConnectPhaseNetworkError reports a send failure that proves the request
71
+ * never reached the FF1, so sending it again (even on another route, with a new
72
+ * counter) cannot run it twice. `fetch failed` on its own, ECONNRESET, a closed
73
+ * socket or a response timeout may follow a delivered request, and are not
74
+ * connect-phase (feral-file#3551 spec §14.4).
75
+ *
76
+ * @param {unknown} error - Error thrown by fetch
77
+ * @returns {boolean} True when the failure happened before any request byte was sent
78
+ */
79
+ function isConnectPhaseNetworkError(error) {
80
+ if (!(error instanceof Error)) {
81
+ return false;
82
+ }
83
+ const causes = [error, error.cause];
84
+ const cause = error.cause;
85
+ if (cause && Array.isArray(cause.errors)) {
86
+ // Happy-eyeballs connects report one error per address.
87
+ causes.push(...cause.errors);
88
+ }
89
+ return causes.some((candidate) => {
90
+ if (!candidate || typeof candidate !== 'object') {
91
+ return false;
92
+ }
93
+ const { code, syscall } = candidate;
94
+ if (typeof code !== 'string') {
95
+ return false;
96
+ }
97
+ return (CONNECT_PHASE_CODES.has(code) || (syscall === 'connect' && CONNECT_SYSCALL_CODES.has(code)));
98
+ });
99
+ }
100
+ /**
101
+ * parseRetryAfterMs converts an HTTP `Retry-After` header into milliseconds.
102
+ *
103
+ * Supports both forms from RFC 7231: a delta-seconds integer (e.g. "5") and an
104
+ * HTTP-date (e.g. "Wed, 21 Oct 2025 07:28:00 GMT"). Returns undefined when the
105
+ * header is absent or unparseable, so callers can fall back to their own
106
+ * backoff. A past date clamps to 0 (retry immediately).
107
+ *
108
+ * @param {string | null | undefined} headerValue - Raw Retry-After header value
109
+ * @returns {number | undefined} Delay in milliseconds, or undefined if unknown
110
+ */
111
+ function parseRetryAfterMs(headerValue) {
112
+ if (!headerValue) {
113
+ return undefined;
114
+ }
115
+ const trimmed = headerValue.trim();
116
+ if (/^\d+$/.test(trimmed)) {
117
+ return Number(trimmed) * 1000;
118
+ }
119
+ // A valid HTTP-date always contains letters (month/day names, or the ISO
120
+ // 'T'/'Z'). Requiring one rejects bare numeric junk (e.g. "-5", "5.5") that
121
+ // Date.parse would otherwise misinterpret as a date.
122
+ if (/[a-zA-Z]/.test(trimmed)) {
123
+ const dateMs = Date.parse(trimmed);
124
+ if (!Number.isNaN(dateMs)) {
125
+ return Math.max(0, dateMs - Date.now());
126
+ }
127
+ }
128
+ return undefined;
129
+ }
@@ -58,7 +58,7 @@ function applyPatch(existing, patch) {
58
58
  * 1. Same mDNS device ID → update in-place (preserves position, handles host change).
59
59
  * 2. Same host → update in-place (preserves position and metadata).
60
60
  * 3. Same name, different host → replace in-place (preserves position so that
61
- * devices[0] — the implicit default for play/send/ssh — does not silently change).
61
+ * devices[0] — the implicit default for display/send/ssh — does not silently change).
62
62
  * 4. Neither match → append.
63
63
  */
64
64
  function upsertDevice(existingDevices, newDevice,
@@ -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
  *