@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
@@ -0,0 +1,511 @@
1
+ "use strict";
2
+ /**
3
+ * Problem reports to Feral File support (`ff-cli report`).
4
+ *
5
+ * The report goes to support-logs' public `POST /v2/cli/reports`, which mints
6
+ * the support bundle id the app uses (`Support [<id>]`) and answers with a
7
+ * short-lived token valid only for that bundle's FF1 log submission. ff-cli
8
+ * holds no support credential: it is a public package, so any key it shipped
9
+ * would be public too. support-intake later moves the stored report into the
10
+ * support inbox, where it becomes a case like a report from the app.
11
+ *
12
+ * With an Art Computer, the report carries `device_id`, `version` and `branch`
13
+ * from its LAN `GET /api/status`, and once the report has posted the FF1 is
14
+ * sent its existing `uploadLogs` command with the token as `apiKey`, so its
15
+ * logs land under the same bundle id. FF OS sends that key as `x-api-key` to
16
+ * `/v2/ff1/log-submissions`; no firmware change is involved.
17
+ *
18
+ * What must not change:
19
+ * - The body is the report text, the reply address when given, this ff-cli's
20
+ * version, the OS and Node versions, and the Art Computer block. Nothing
21
+ * else leaves this machine: no config, keys, topic, or controller files.
22
+ * - The report is posted once and never retried: a request that timed out may
23
+ * have been stored, and a resend would open a second case.
24
+ * - The device upload is best effort and never changes the outcome: the
25
+ * report has already reached support by then.
26
+ */
27
+ var __importDefault = (this && this.__importDefault) || function (mod) {
28
+ return (mod && mod.__esModule) ? mod : { "default": mod };
29
+ };
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.MAX_REQUEST_BYTES = exports.MAX_REPORT_BYTES = void 0;
32
+ exports.buildReportPayload = buildReportPayload;
33
+ exports.isBareAddress = isBareAddress;
34
+ exports.isReservedAddress = isReservedAddress;
35
+ exports.containsSigningKey = containsSigningKey;
36
+ exports.readDeviceStatus = readDeviceStatus;
37
+ exports.postReport = postReport;
38
+ exports.requestLogUpload = requestLogUpload;
39
+ const node_os_1 = __importDefault(require("node:os"));
40
+ const http_1 = require("./http");
41
+ const pairing_1 = require("./control/pairing");
42
+ const user_agent_1 = require("./user-agent");
43
+ const ed25519_key_derive_1 = require("./ed25519-key-derive");
44
+ const ff1_device_1 = require("./ff1-device");
45
+ const control_errors_1 = require("./control/control-errors");
46
+ const secure_transport_1 = require("./control/secure-transport");
47
+ /** MAX_REPORT_BYTES is support-logs' cap on `report`, in UTF-8 bytes. */
48
+ exports.MAX_REPORT_BYTES = 32_768;
49
+ /**
50
+ * MAX_REQUEST_BYTES is support-logs' cap on the whole request body. A
51
+ * 32768-byte report of control characters escapes to 6 bytes each (`\u0001`),
52
+ * 196608 bytes, and every other field is capped at MAX_FIELD_BYTES, so a body
53
+ * stays near 202 KB; postReport still checks, so no future field can cross it.
54
+ */
55
+ exports.MAX_REQUEST_BYTES = 256 * 1024;
56
+ /** MAX_FIELD_BYTES is support-logs' cap on every metadata field (the device's included). */
57
+ const MAX_FIELD_BYTES = 256;
58
+ /*
59
+ * The reply-address grammar shared with support-logs and support-intake.
60
+ * All three must accept exactly the same set; the shared cases are in
61
+ * tests/fixtures/reporter-email-fixtures.json, copied unchanged from the
62
+ * cross-repo contract, whose `rule` field is the grammar:
63
+ * - at most 254 bytes, all printable ASCII, exactly one `@`;
64
+ * - local part: 1-64 characters of atext in dot-separated runs (no leading,
65
+ * trailing or doubled dot, no quotes);
66
+ * - domain: two or more dot-separated labels of 1-63 letters, digits and
67
+ * hyphens, not starting or ending with a hyphen (no IP literal, no
68
+ * underscore);
69
+ * - refused even when well formed (case-insensitive): support's own address,
70
+ * and a local part of mailer-daemon or postmaster.
71
+ */
72
+ const MAX_EMAIL_BYTES = 254;
73
+ const MAX_LOCAL_PART = 64;
74
+ const ATEXT = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+";
75
+ const LOCAL_PART = new RegExp(`^${ATEXT}(?:\\.${ATEXT})*$`);
76
+ const DOMAIN_LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
77
+ const RESERVED_LOCAL_PARTS = new Set(['mailer-daemon', 'postmaster']);
78
+ /** STATUS_TIMEOUT_MS bounds the LAN status read at an address. A `.local` host gets
79
+ * lanReadTimeoutMs's 12 s instead, since macOS takes about 5 s to look one up. */
80
+ const STATUS_TIMEOUT_MS = 5_000;
81
+ /** SUPPORT_BUNDLE_ID is the app's reference format: 13 base62 characters (ff-app support_bundle_id.dart). */
82
+ const SUPPORT_BUNDLE_ID = /^[0-9A-Za-z]{13}$/;
83
+ /** SUPPORT_EMAIL is the human route when the report cannot be posted. */
84
+ const SUPPORT_EMAIL = 'support@feralfile.com';
85
+ /**
86
+ * buildReportPayload assembles the report body from the text, the optional
87
+ * reply address and Art Computer block, and this ff-cli and runtime.
88
+ *
89
+ * @param input - Report text, ff-cli version (what `ff-cli --version` prints),
90
+ * reply address, and Art Computer block
91
+ * @returns The body to post
92
+ */
93
+ function buildReportPayload(input) {
94
+ return {
95
+ report: input.report,
96
+ ff_cli_version: input.ffCliVersion,
97
+ ...(input.email ? { reporter_email: input.email } : {}),
98
+ os: `${process.platform} ${node_os_1.default.release()}`,
99
+ node_version: process.version,
100
+ ...(input.device && Object.keys(input.device).length > 0 ? { device: input.device } : {}),
101
+ };
102
+ }
103
+ /**
104
+ * isBareAddress reports whether `value` matches the shared reply-address
105
+ * grammar's form (everything but the reserved addresses). Anything looser is
106
+ * refused here because support-logs answers it with a 400, or intake drops it
107
+ * and the reporter gets no reply.
108
+ *
109
+ * @param value - Candidate address, as it would be sent
110
+ * @returns Whether it has the accepted form
111
+ */
112
+ function isBareAddress(value) {
113
+ // Printable ASCII is checked first, so length is a byte count.
114
+ if (!/^[\x21-\x7e]+$/.test(value) || value.length > MAX_EMAIL_BYTES) {
115
+ return false;
116
+ }
117
+ const parts = value.split('@');
118
+ if (parts.length !== 2) {
119
+ return false;
120
+ }
121
+ const [local, domain] = parts;
122
+ const labels = domain.split('.');
123
+ return (local.length <= MAX_LOCAL_PART &&
124
+ LOCAL_PART.test(local) &&
125
+ labels.length >= 2 &&
126
+ labels.every((label) => DOMAIN_LABEL.test(label)));
127
+ }
128
+ /**
129
+ * isReservedAddress reports whether `value` is an address support never
130
+ * replies to: Feral File support's own (intake would read the report as its
131
+ * own mail), or a postmaster or mailer-daemon mailbox. Case-insensitive.
132
+ *
133
+ * @param value - Candidate address
134
+ * @returns Whether it is reserved
135
+ */
136
+ function isReservedAddress(value) {
137
+ const lower = value.toLowerCase();
138
+ return lower === SUPPORT_EMAIL || RESERVED_LOCAL_PARTS.has(lower.split('@')[0]);
139
+ }
140
+ /**
141
+ * containsSigningKey reports whether `report` carries the configured playlist
142
+ * signing key. When the key parses, the report is decoded rather than compared
143
+ * as text: every run of base64 (either alphabet, any padding, any unused bits)
144
+ * and every run of hex is decoded at each alignment, and the report matches
145
+ * when the decoded bytes hold the key's 32-byte seed. That covers PEM, PKCS#8
146
+ * DER and the seed in every spelling parsePlaylistPrivateKeyToKeyObject
147
+ * accepts, since all of them decode to bytes that hold the seed. A key that
148
+ * does not parse is matched as written.
149
+ *
150
+ * The report is read as written, with JSON string escapes (`\/`, `/`,
151
+ * `\n`) decoded up to four levels deep, and percent-decoded, since a pasted
152
+ * config.json may escape the key. Characters outside the encoding's alphabet
153
+ * are dropped first, so a wrapped PEM body or a key split by separators still
154
+ * matches.
155
+ *
156
+ * A cheap guard against pasting a config or key file into a report, not a
157
+ * secret scanner. A match needs 32 bytes of key material, so it is never a
158
+ * coincidence.
159
+ *
160
+ * @param report - Report text
161
+ * @param material - Configured key (`playlist.privateKey` or PLAYLIST_PRIVATE_KEY), if any
162
+ * @returns Whether the report contains it
163
+ */
164
+ function containsSigningKey(report, material) {
165
+ const configured = material?.trim();
166
+ if (!configured) {
167
+ return false;
168
+ }
169
+ const readings = reportReadings(report);
170
+ const seed = signingSeed(configured);
171
+ if (seed) {
172
+ return readings.some((text) => decodesToInclude(text, seed));
173
+ }
174
+ const asWritten = keyText(configured.replace(/-----[^-]+-----/g, ''));
175
+ return asWritten.length >= 32 && readings.some((text) => keyText(text).includes(asWritten));
176
+ }
177
+ /** keyText keeps only the characters of hex, base64 and base64url, without `=` padding, lowercased. */
178
+ function keyText(text) {
179
+ return text.replace(/[^A-Za-z0-9+/_-]/g, '').toLowerCase();
180
+ }
181
+ /**
182
+ * decodesToInclude decodes `text`'s base64 characters (both alphabets) at all
183
+ * four alignments and its hex digits at both, and reports whether any decoding
184
+ * holds `bytes`. Decoding at the alignment where the key starts yields the
185
+ * key's bytes exactly, whatever text runs into it on either side, and base64
186
+ * decoding ignores the unused bits of a final character.
187
+ */
188
+ function decodesToInclude(text, bytes) {
189
+ const base64 = text.replace(/[^A-Za-z0-9+/_-]/g, '');
190
+ for (let offset = 0; offset < 4; offset += 1) {
191
+ if (Buffer.from(base64.slice(offset), 'base64').includes(bytes)) {
192
+ return true;
193
+ }
194
+ }
195
+ const hex = text.replace(/[^0-9A-Fa-f]/g, '');
196
+ for (let offset = 0; offset < 2; offset += 1) {
197
+ if (Buffer.from(hex.slice(offset), 'hex').includes(bytes)) {
198
+ return true;
199
+ }
200
+ }
201
+ return false;
202
+ }
203
+ /**
204
+ * reportReadings lists the report as written, percent-decoded, and with JSON
205
+ * string escapes decoded up to four levels deep (a config quoted inside a JSON
206
+ * log line is escaped twice).
207
+ */
208
+ function reportReadings(report) {
209
+ const readings = new Set();
210
+ let text = report;
211
+ for (let depth = 0; depth < 4; depth += 1) {
212
+ readings.add(text);
213
+ readings.add(text.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))));
214
+ const next = text.replace(/\\(?:u([0-9A-Fa-f]{4})|(.))/g, (_, hex, ch) => hex ? String.fromCharCode(parseInt(hex, 16)) : (JSON_ESCAPES[ch] ?? ch));
215
+ if (next === text) {
216
+ break;
217
+ }
218
+ text = next;
219
+ }
220
+ return [...readings];
221
+ }
222
+ const JSON_ESCAPES = { b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
223
+ /** signingSeed returns the configured key's 32-byte Ed25519 seed, or null when the key does not parse. */
224
+ function signingSeed(material) {
225
+ try {
226
+ const key = (0, ed25519_key_derive_1.parsePlaylistPrivateKeyToKeyObject)(material);
227
+ return Buffer.from(String(key.export({ format: 'jwk' }).d), 'base64url');
228
+ }
229
+ catch {
230
+ return null;
231
+ }
232
+ }
233
+ /**
234
+ * readDeviceStatus reads the Art Computer's identity and build from its LAN
235
+ * `GET /api/status` (FF OS hub/status.go: `device_id`, `version` and `branch`
236
+ * among other fields). The route is open on every FF OS, secured or not, so no
237
+ * controller is involved. A failed read is returned in `error`, never thrown:
238
+ * the report then goes with the device ID configured for it, if any. A value
239
+ * over support-logs' 256-byte field cap is left out, since one would make it
240
+ * refuse the whole report.
241
+ *
242
+ * @param device - Configured Art Computer
243
+ * @param fetchFn - Fetch (tests); defaults to 5 s, or the longer `.local` deadline
244
+ * @returns The Art Computer block, and why the read failed when it did
245
+ */
246
+ async function readDeviceStatus(device, fetchFn = (input, init) => (0, http_1.fetchWithTimeout)(input, init ?? {}, (0, pairing_1.lanReadTimeoutMs)(device.host, STATUS_TIMEOUT_MS))) {
247
+ const configuredId = fieldValue(device.id);
248
+ const fallback = configuredId ? { device_id: configuredId } : {};
249
+ let body;
250
+ try {
251
+ const response = await fetchFn(`${device.host}/api/status`, { headers: deviceHeaders(device) });
252
+ if (!response.ok) {
253
+ return { device: fallback, error: `HTTP ${response.status}` };
254
+ }
255
+ body = await response.json();
256
+ }
257
+ catch (error) {
258
+ return { device: fallback, error: error.message };
259
+ }
260
+ const status = (body && typeof body === 'object' ? body : {});
261
+ const read = {};
262
+ for (const key of ['device_id', 'version', 'branch']) {
263
+ const value = fieldValue(status[key]);
264
+ if (value) {
265
+ read[key] = value;
266
+ }
267
+ }
268
+ if (Object.keys(read).length === 0) {
269
+ return { device: fallback, error: 'the answer had no device_id, version or branch' };
270
+ }
271
+ return { device: { ...fallback, ...read } };
272
+ }
273
+ /** fieldValue returns a trimmed, non-empty string within MAX_FIELD_BYTES, else undefined. */
274
+ function fieldValue(value) {
275
+ const trimmed = typeof value === 'string' ? value.trim() : '';
276
+ return trimmed && Buffer.byteLength(trimmed, 'utf8') <= MAX_FIELD_BYTES ? trimmed : undefined;
277
+ }
278
+ /**
279
+ * postReport sends the report body to `<baseUrl>/v2/cli/reports`, once.
280
+ *
281
+ * The endpoint is public by design; its bounds are the size caps and the
282
+ * service's per-IP and global rate limits. A body over MAX_REQUEST_BYTES is
283
+ * refused here, before anything is sent. Errors are the service's
284
+ * `{code, message}` JSON.
285
+ *
286
+ * @param baseUrl - support-logs origin
287
+ * @param body - Serialized ReportPayload
288
+ * @param fetchFn - Fetch (tests); defaults to the shared deadline
289
+ * @returns The receipt, or a failure that says what to do next
290
+ */
291
+ async function postReport(baseUrl, body, fetchFn = http_1.defaultDeadlineFetch) {
292
+ const bytes = Buffer.byteLength(body, 'utf8');
293
+ if (bytes > exports.MAX_REQUEST_BYTES) {
294
+ return {
295
+ ok: false,
296
+ error: `The report is ${bytes} bytes once encoded; Feral File support takes at most ${exports.MAX_REQUEST_BYTES}.`,
297
+ details: 'Shorten it. Control characters take six bytes each once encoded, so remove any the text carries.',
298
+ };
299
+ }
300
+ let response;
301
+ let text;
302
+ try {
303
+ response = await fetchFn(`${baseUrl}/v2/cli/reports`, {
304
+ method: 'POST',
305
+ headers: {
306
+ 'Content-Type': 'application/json',
307
+ Accept: 'application/json',
308
+ 'User-Agent': user_agent_1.USER_AGENT,
309
+ },
310
+ body,
311
+ });
312
+ text = await response.text();
313
+ }
314
+ catch (error) {
315
+ return {
316
+ ok: false,
317
+ error: `Could not reach Feral File support at ${baseUrl} (${error.message})`,
318
+ details: `Check this computer's internet connection and run the same command again. If it keeps failing, email the report to ${SUPPORT_EMAIL}.`,
319
+ };
320
+ }
321
+ if (!response.ok) {
322
+ return { ok: false, ...refusal(response.status, text) };
323
+ }
324
+ return parseReceipt(response.status, text);
325
+ }
326
+ /** parseReceipt reads `support_bundle_id` and `ff1_upload_token` from a 2xx answer. */
327
+ function parseReceipt(status, text) {
328
+ let parsed = {};
329
+ try {
330
+ const value = JSON.parse(text);
331
+ if (value && typeof value === 'object') {
332
+ parsed = value;
333
+ }
334
+ }
335
+ catch {
336
+ // Reported below as an answer without a reference.
337
+ }
338
+ const id = parsed.support_bundle_id;
339
+ if (typeof id !== 'string' || !SUPPORT_BUNDLE_ID.test(id)) {
340
+ return {
341
+ ok: false,
342
+ error: `Feral File support answered HTTP ${status} without a report reference`,
343
+ details: `The report may have arrived, but there is no reference to quote. Email it to ${SUPPORT_EMAIL} so it is not lost.`,
344
+ };
345
+ }
346
+ const token = parsed.ff1_upload_token;
347
+ return {
348
+ ok: true,
349
+ receipt: {
350
+ supportBundleId: id,
351
+ ...(typeof token === 'string' && token ? { uploadToken: token } : {}),
352
+ },
353
+ };
354
+ }
355
+ /** refusal maps a non-2xx answer to copy that says what to do next. */
356
+ function refusal(status, text) {
357
+ const reason = serviceMessage(text);
358
+ const said = reason ? `: ${reason}` : '';
359
+ if (status === 429) {
360
+ return {
361
+ error: `Feral File support is limiting reports right now (HTTP 429)${said}`,
362
+ details: 'Wait a few minutes, then run the same command again.',
363
+ };
364
+ }
365
+ if (status === 400) {
366
+ return {
367
+ error: `Feral File support refused the report (HTTP 400)${said}`,
368
+ details: 'Fix what the answer names, then run the command again.',
369
+ };
370
+ }
371
+ if (status < 500) {
372
+ return {
373
+ error: `Feral File support refused the report (HTTP ${status})${said}`,
374
+ details: `If you set supportLogs.baseUrl or FF_SUPPORT_LOGS_URL, check it. Otherwise email the report to ${SUPPORT_EMAIL}.`,
375
+ };
376
+ }
377
+ return {
378
+ error: `Feral File support could not take the report (HTTP ${status})${said}`,
379
+ details: `Try again later. If it keeps failing, email the report to ${SUPPORT_EMAIL}.`,
380
+ };
381
+ }
382
+ /** serviceMessage reads support-logs' `{code, message}` error, or a short excerpt of anything else. */
383
+ function serviceMessage(text) {
384
+ try {
385
+ const parsed = JSON.parse(text);
386
+ if (typeof parsed?.message === 'string' && parsed.message) {
387
+ return parsed.message;
388
+ }
389
+ if (typeof parsed?.code === 'string' && parsed.code) {
390
+ return parsed.code;
391
+ }
392
+ }
393
+ catch {
394
+ // Not JSON: quote it.
395
+ }
396
+ return text.trim().slice(0, 200);
397
+ }
398
+ /**
399
+ * requestLogUpload asks the Art Computer to upload its logs under the report's
400
+ * bundle, with FF OS's existing `uploadLogs` command and the bundle-scoped
401
+ * token as `apiKey`. FF OS requires `userId` and `title` and does not use them.
402
+ *
403
+ * The transport is the one ff-cli uses for the FF1's other owner-tier
404
+ * commands. With a controller file, a secure envelope (FF OS control/tiers.go
405
+ * puts `uploadLogs` at owner tier, so a controller-role grant gets
406
+ * `permission_denied`), over the LAN and through the relayer only when the LAN
407
+ * is unreachable, like `device controllers` and `device revoke`; the envelope
408
+ * is sealed to the FF1, so the relayer never sees the token. Without one, a
409
+ * plaintext LAN `/api/cast`, which FF OS takes from any LAN peer while the FF1
410
+ * is not secured (in phase 0 only `sshAccess` needs topic proof). No retry:
411
+ * FF OS runs one log upload at a time and ignores a duplicate.
412
+ *
413
+ * FF OS acks at once and uploads in the background (it needs internet), so
414
+ * success means the FF1 took the request, not that the logs arrived.
415
+ *
416
+ * @param device - Configured Art Computer
417
+ * @param receipt - The posted report's bundle id and upload token
418
+ * @param deps - Transport seams (tests)
419
+ * @returns Whether the FF1 took the request; never throws
420
+ */
421
+ async function requestLogUpload(device, receipt, deps = {}) {
422
+ const req = {
423
+ userId: 'ff-cli',
424
+ apiKey: receipt.uploadToken,
425
+ title: 'ff-cli report',
426
+ supportBundleID: receipt.supportBundleId,
427
+ };
428
+ const secure = (0, secure_transport_1.findSecureController)(device, deps.controllerStore);
429
+ if (secure.failure) {
430
+ return { success: false, error: secure.failure.error, details: secure.failure.details };
431
+ }
432
+ if (!secure.record) {
433
+ return castUploadLogs(device, req, deps.fetchFn ?? http_1.defaultDeadlineFetch);
434
+ }
435
+ // The secure transport can still throw on paths it does not map (a relayer
436
+ // answer whose body read fails). This step runs after the report is in, so a
437
+ // throw here must not reach the command and turn a delivered report into
438
+ // exit 1, which would invite a second report.
439
+ try {
440
+ const result = await (0, secure_transport_1.runSecureCommand)({
441
+ device: device,
442
+ record: secure.record,
443
+ cmd: 'uploadLogs',
444
+ req,
445
+ relayerFallback: true,
446
+ retry: false,
447
+ }, deps);
448
+ return { success: result.success, error: result.error, details: result.details };
449
+ }
450
+ catch (error) {
451
+ return { success: false, error: error.message };
452
+ }
453
+ }
454
+ /** castUploadLogs sends `uploadLogs` as a plaintext LAN cast and reads FF OS's `{ok}` answer. */
455
+ async function castUploadLogs(device, req, fetchFn) {
456
+ const label = device.name || device.host;
457
+ let response;
458
+ let text;
459
+ try {
460
+ response = await fetchFn(`${device.host}/api/cast`, {
461
+ method: 'POST',
462
+ headers: { 'Content-Type': 'application/json', ...deviceHeaders(device) },
463
+ body: JSON.stringify({ command: 'uploadLogs', request: req }),
464
+ });
465
+ text = await response.text();
466
+ }
467
+ catch (error) {
468
+ return {
469
+ success: false,
470
+ error: `Could not reach ${label} at ${device.host} (${error.message})`,
471
+ details: 'Check that it is on and reachable from this computer.',
472
+ };
473
+ }
474
+ if (!response.ok) {
475
+ const code = (0, ff1_device_1.publicControlCode)(response.status, text);
476
+ if (code) {
477
+ const failure = (0, control_errors_1.describeControlCode)(code, device.name || device.id || device.host);
478
+ return { success: false, error: failure.error, details: failure.details };
479
+ }
480
+ return {
481
+ success: false,
482
+ error: `${label} answered HTTP ${response.status}`,
483
+ details: text.trim().slice(0, 200) || undefined,
484
+ };
485
+ }
486
+ let answer = null;
487
+ try {
488
+ answer = JSON.parse(text);
489
+ }
490
+ catch {
491
+ // Reported below.
492
+ }
493
+ if (!answer || typeof answer !== 'object') {
494
+ // FF OS answers {"ok":true}. An empty or non-JSON 2xx (seen on the first
495
+ // cast after boot) does not say whether the command ran.
496
+ return {
497
+ success: false,
498
+ error: `${label} answered HTTP ${response.status} without a usable reply`,
499
+ details: 'It may or may not upload its logs.',
500
+ };
501
+ }
502
+ if (answer.ok === false) {
503
+ const why = typeof answer.message === 'string' ? answer.message : String(answer.error ?? '');
504
+ return { success: false, error: `${label} refused the request`, details: why || undefined };
505
+ }
506
+ return { success: true };
507
+ }
508
+ /** deviceHeaders carries the device's legacy API key when one is configured. */
509
+ function deviceHeaders(device) {
510
+ return device.apiKey ? { 'API-KEY': device.apiKey } : {};
511
+ }
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TemporaryChannel = void 0;
4
+ const relay_client_1 = require("./relay-client");
5
+ /** TemporaryChannel supplies only the generic carrier seam for the reviewed
6
+ * pairing workflow. QR generation, crypto, result validation and staging stay
7
+ * with that workflow. Its caller chooses the recipient from the endpoint exchange.
8
+ */
9
+ class TemporaryChannel {
10
+ channelId;
11
+ connectionId;
12
+ peers = new Set();
13
+ ready;
14
+ socket;
15
+ timer;
16
+ pinger;
17
+ closed = false;
18
+ resolveReady;
19
+ rejectReady;
20
+ receipts = new Map();
21
+ onPayload;
22
+ onClose;
23
+ /** Create an endpoint-timed attempt. Await ready before displaying its QR. */
24
+ constructor(endpoint, channelId = crypto.randomUUID(), timeoutMs = 120000, socketFactory = (url) => new WebSocket(url, 'ff-relay.v2')) {
25
+ this.channelId = channelId;
26
+ this.ready = new Promise((resolve, reject) => {
27
+ this.resolveReady = resolve;
28
+ this.rejectReady = reject;
29
+ });
30
+ void this.ready.catch(() => undefined);
31
+ this.socket = socketFactory((0, relay_client_1.relayURL)(endpoint, channelId));
32
+ this.socket.binaryType = 'arraybuffer';
33
+ this.timer = setTimeout(() => this.close(new Error('Pairing not completed: local timeout')), timeoutMs);
34
+ this.socket.addEventListener('message', (event) => {
35
+ try {
36
+ this.receive(event.data);
37
+ }
38
+ catch {
39
+ this.close(new Error('Malformed temporary-channel frame'));
40
+ }
41
+ });
42
+ this.socket.addEventListener('close', () => this.close(new Error('Pairing channel disconnected')));
43
+ this.socket.addEventListener('error', () => this.close(new Error('Pairing channel failed')));
44
+ }
45
+ /** send targets encrypted endpoint bytes; resolving means receipt, not approval. */
46
+ async send(to, ciphertext) {
47
+ await this.ready;
48
+ if (this.closed || !this.peers.has(to)) {
49
+ throw new Error('Pairing peer unavailable');
50
+ }
51
+ if (this.receipts.size >= 2) {
52
+ throw new Error('Pairing carrier backpressure');
53
+ }
54
+ const id = crypto.randomUUID();
55
+ const frame = (0, relay_client_1.encodeRelayFrame)({ v: 2, id, to }, ciphertext);
56
+ return new Promise((resolve, reject) => {
57
+ const key = `${to}:${id}`;
58
+ const timer = setTimeout(() => {
59
+ this.receipts.delete(key);
60
+ reject(new Error('Pairing receipt timeout; result delivery unknown'));
61
+ }, 5000);
62
+ this.receipts.set(key, { resolve, reject, timer });
63
+ try {
64
+ this.socket.send(frame);
65
+ }
66
+ catch (error) {
67
+ this.close(error);
68
+ }
69
+ });
70
+ }
71
+ /** close cancels this local attempt and never revokes an endpoint grant. */
72
+ close(reason = new Error('Pairing cancelled')) {
73
+ if (this.closed) {
74
+ return;
75
+ }
76
+ this.closed = true;
77
+ clearTimeout(this.timer);
78
+ clearInterval(this.pinger);
79
+ this.rejectReady(reason);
80
+ for (const pending of this.receipts.values()) {
81
+ clearTimeout(pending.timer);
82
+ pending.reject(reason);
83
+ }
84
+ this.receipts.clear();
85
+ this.peers.clear();
86
+ this.socket.close();
87
+ this.onClose?.(reason);
88
+ }
89
+ /** receive releases transport credit before handing ciphertext to validators. */
90
+ receive(data) {
91
+ if (typeof data !== 'string') {
92
+ if (!(data instanceof ArrayBuffer)) {
93
+ throw new Error('Invalid binary frame');
94
+ }
95
+ const { header, payload } = (0, relay_client_1.decodeRelayFrame)(data);
96
+ this.socket.send(JSON.stringify({ type: 'ack', from: header.from, id: header.id }));
97
+ this.onPayload?.(header.from, payload);
98
+ return;
99
+ }
100
+ if (data === 'pong') {
101
+ return;
102
+ }
103
+ const event = JSON.parse(data);
104
+ if (event.type === 'hello') {
105
+ if (typeof event.connectionId !== 'string' || !Array.isArray(event.peers)) {
106
+ throw new Error('Invalid hello');
107
+ }
108
+ this.connectionId = event.connectionId;
109
+ // §10 Liveness: a pairing wait can outlast the relay's 90 s idle close.
110
+ this.pinger ??= (0, relay_client_1.startPinging)(() => this.socket);
111
+ for (const peer of event.peers.slice(0, 35)) {
112
+ this.peers.add(peer);
113
+ }
114
+ this.resolveReady();
115
+ }
116
+ else if (event.type === 'peer_connected' && this.peers.size < 35) {
117
+ this.peers.add(event.connectionId);
118
+ }
119
+ else if (event.type === 'peer_disconnected') {
120
+ this.peers.delete(event.connectionId);
121
+ }
122
+ else if (event.type === 'ack' || event.type === 'error') {
123
+ const key = `${event.from ?? event.to}:${event.id}`;
124
+ const receipt = this.receipts.get(key);
125
+ if (!receipt) {
126
+ return;
127
+ }
128
+ clearTimeout(receipt.timer);
129
+ this.receipts.delete(key);
130
+ if (event.type === 'ack') {
131
+ receipt.resolve();
132
+ }
133
+ else {
134
+ receipt.reject(new Error(`Relay: ${event.code}`));
135
+ }
136
+ }
137
+ }
138
+ }
139
+ exports.TemporaryChannel = TemporaryChannel;