@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
@@ -27,6 +27,12 @@ npm run dev -- config show
27
27
  - **generativeDuration** (number, seconds)
28
28
  - Display duration stamped on generative/interactive (HTML) works, which have no intrinsic runtime and no end-of-stream event. Defaults to **60s** (override via `DEFAULT_GENERATIVE_DURATION`).
29
29
  - Set to **0** to omit the duration entirely, so a conformant player parks on the work open-ended instead of rotating. Note: some players (including FF1) require every item to carry a `duration` and will reject a playlist whose items omit it — keep this non-zero unless you know your player tolerates an absent duration.
30
+ - **tokenSource** (`"raster"` | `"indexer"`)
31
+ - Which service `find`, `build`, and `enrich` read token data from. Defaults to `indexer`. See [tokenSource](#tokensource).
32
+ - **ipfsGateways** (object)
33
+ - Gateways used to turn bare `ipfs://` sources into HTTPS URLs. See [ipfsGateways](#ipfsgateways).
34
+ - **supportLogs** (object)
35
+ - Where `ff-cli report` sends a problem report. See [supportLogs](#supportlogs).
30
36
 
31
37
  ## browser
32
38
 
@@ -39,10 +45,9 @@ Optional settings used where headless/browser‑like behavior is needed.
39
45
 
40
46
  Used for signing DP‑1 playlists.
41
47
 
42
- - `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures, and as the default signing key for the owner-bound feed mutations (`publish --replace`, `unpublish`), which sign an authorization intent at request time. Those commands and `sign` all take `-k, --key` or `--key-file <path>` to override it for a single run — see [Where the signing key can come from](#where-the-signing-key-can-come-from). The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `play` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `play` and `publish` verify before delivery or upload and reject unsigned or broken playlists.
48
+ - `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures, and as the default signing key for the owner-bound feed mutations (`publish --replace`, `unpublish`), which sign an authorization intent at request time. Those commands and `sign` all take `-k, --key` or `--key-file <path>` to override it for a single run — see [Where the signing key can come from](#where-the-signing-key-can-come-from). The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `display` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `display` and `publish` verify before delivery or upload and reject unsigned or broken playlists.
43
49
 
44
50
  **Signing and key encoding:** Signing paths (`sign`, deterministic `build` when configured, and `-k/--key` overrides) accept the private key in any of these encodings:
45
-
46
51
  - **base64 PKCS#8 DER** — recommended; what `ff-cli setup` generates.
47
52
  - **32-byte raw Ed25519 seed** as **hex** (optional `0x`) or **base64**.
48
53
  - **PEM** (`-----BEGIN PRIVATE KEY-----`).
@@ -86,7 +91,7 @@ delivery mechanism rather than a fourth key format.
86
91
 
87
92
  **Prefer `--key-file` to `--key`.** `--key` writes your private key into your shell history file and
88
93
  exposes it in the process list, where any local user can read it for as long as the command runs.
89
- Neither disclosure can be undone: an Ed25519 signing key *is* the identity it asserts, and a playlist's
94
+ Neither disclosure can be undone: an Ed25519 signing key _is_ the identity it asserts, and a playlist's
90
95
  `curators[]` owner set is immutable, so a leaked key cannot be rotated out of a document that already
91
96
  names it. `--key-file` keeps the material in a file you control:
92
97
 
@@ -112,7 +117,7 @@ The rules on both flags:
112
117
  something a publish or a delete can be taken back from.
113
118
  - **A `--key-file` argument is always opened, whatever it looks like.** A directory really can be
114
119
  called `BEGIN`, and a key file really can be named after a content hash. What is gated is not the
115
- open but the *message*: a failure names the path only when it is short and no part of it is a
120
+ open but the _message_: a failure names the path only when it is short and no part of it is a
116
121
  complete key encoding, and otherwise says it could not be read without repeating it. Typing the key
117
122
  where the path goes — one keystroke from `--key` — is reported as that mistake, by name, with the
118
123
  value withheld.
@@ -145,8 +150,8 @@ If you need a different role, set `playlist.role` to one of the DP-1 signing rol
145
150
  ### Signing role and ownership
146
151
 
147
152
  A feed decides who owns a playlist from the document itself: the keys in `curators[]` are the owner set.
148
- Under **role-aware ownership**, a key counts as an owner only when that same key *also signed in the
149
- `curator` role* — being named is a claim, signing as `curator` is the proof.
153
+ Under **role-aware ownership**, a key counts as an owner only when that same key _also signed in the
154
+ `curator` role_ — being named is a claim, signing as `curator` is the proof.
150
155
 
151
156
  `ff-cli publish` requires that role. **This is ff-cli's check, not every feed's answer today**: a feed that
152
157
  does not yet enforce roles accepts an `agent`-signed document, so a manual upload of the same file can
@@ -181,7 +186,8 @@ DP‑1 Feed API configuration.
181
186
 
182
187
  - `feed.baseURLs` (string[]): Array of DP‑1 Feed Operator API v1 base URLs. The CLI queries all feeds in parallel.
183
188
  - Legacy support: `feed.baseURL` (string) is still accepted and normalized to an array.
184
- - Default: `https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1` if not set.
189
+ - Default: `https://feed.feralfile.com/api/v1` if not set.
190
+ - A configured URL on `dp1-feed-operator-api-prod.autonomy-system.workers.dev` (the deleted server ff-cli 3.0.0's `setup` wrote into `config.json`) is read as `https://feed.feralfile.com` with the same path, with a one-line note on stderr until you update it.
185
191
  - Compatibility: API v1 of the DP‑1 Feed Operator server. See the repository for endpoints and behavior: [dp1-feed](https://github.com/display-protocol/dp1-feed).
186
192
 
187
193
  Endpoints used by the CLI:
@@ -212,12 +218,80 @@ defaulting to index 0 would silently write to whichever feed happens to be first
212
218
  Environment variable alternative:
213
219
 
214
220
  ```env
215
- FEED_BASE_URLS=https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1,https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1
221
+ FEED_BASE_URLS=https://feed.feralfile.com/api/v1,https://feed.example.com/api/v1
216
222
  ```
217
223
 
218
- ## find (Raster API)
224
+ ## tokenSource
225
+
226
+ Token data for `find`, `build`, and `enrich` (title, artist, description, the source to display, on-chain provenance, and a still image where the service supplies one) comes from one service per run:
227
+
228
+ - `indexer` (default): the Feral File indexer (ff-indexer-v2). A token it has not seen yet triggers an indexing job, so a first build of unseen tokens can take minutes.
229
+ - `raster` (opt-in, under evaluation): Raster's public GraphQL API. Tokens are looked up 40 per request, so a build does not wait for indexing jobs. Raster's data still has known bugs, tracked on [feral-file#3546](https://github.com/feral-file/feral-file/issues/3546): some Feral File works point at a retired image host and display black, and evolving works display an older snapshot. No still image is supplied: Raster's preview renditions refuse non-browser clients, so items and `enrich` get no thumbnail from Raster.
230
+
231
+ ```json
232
+ { "tokenSource": "raster" }
233
+ ```
234
+
235
+ Omit the key, or set `"indexer"`, to use the default.
236
+
237
+ Environment variable alternative:
238
+
239
+ ```env
240
+ FF_TOKEN_SOURCE=raster
241
+ ```
242
+
243
+ There is no fallback between the two. A token Raster does not cover, or one that was burned, is reported as unresolved rather than looked up elsewhere.
244
+
245
+ Raster has no way to list a contract's tokens. With `tokenSource: "raster"`, a `build_playlist` requirement without `tokenIds` fails with a message saying so, and a `query_address` requirement for an address that holds nothing is not retried as a contract. Give token ids, an owner address, or run `find` on the series URL instead.
246
+
247
+ Only Ethereum mainnet and Tezos mainnet tokens are built. Tokens on other chains in a wallet or series are skipped and counted.
248
+
249
+ ## ipfsGateways
250
+
251
+ With `tokenSource: "raster"`, many sources come back as bare `ipfs://` CIDs, which the FF1 cannot load. ff-cli rewrites them through a gateway:
219
252
 
220
- `ff-cli find` resolves tokens, series, and artist addresses through Raster's public GraphQL API ([docs.api.raster.art](https://docs.api.raster.art/)). No configuration is required; two environment variables adjust the integration:
253
+ - `ipfsGateways.feralFile`: used for Feral File works. Default `https://ipfs.feralfile.com/ipfs/`.
254
+ - `ipfsGateways.default`: used for everything else. Default `https://ipfs.filebase.io/ipfs/`.
255
+
256
+ A bare origin such as `https://gateway.example` is accepted and normalized to `https://gateway.example/ipfs/`. Sources on the retired `cdn.feralfileassets.com` host are rewritten to `cdn.artworks.feralfile.io`. Sources on the legacy `ipfs.bitmark.com` gateway go through `ipfsGateways.feralFile` instead.
257
+
258
+ ```json
259
+ {
260
+ "ipfsGateways": {
261
+ "feralFile": "https://ipfs.feralfile.com/ipfs/",
262
+ "default": "https://ipfs.filebase.io/ipfs/"
263
+ }
264
+ }
265
+ ```
266
+
267
+ Environment variable alternative:
268
+
269
+ ```env
270
+ IPFS_GATEWAY_FERAL_FILE=https://ipfs.feralfile.com/ipfs/
271
+ IPFS_GATEWAY_DEFAULT=https://ipfs.filebase.io/ipfs/
272
+ ```
273
+
274
+ ## supportLogs
275
+
276
+ `ff-cli report` posts to Feral File's support-logs service, `https://support-logs.feralfile.com` by default. No configuration or key is needed; set `supportLogs.baseUrl` only to point at a staging or test service. It does not move an Art Computer's log upload, which goes to the support-logs its FF OS names.
277
+
278
+ ```json
279
+ {
280
+ "supportLogs": {
281
+ "baseUrl": "https://support-logs.feralfile.com"
282
+ }
283
+ }
284
+ ```
285
+
286
+ Environment variable alternative:
287
+
288
+ ```env
289
+ FF_SUPPORT_LOGS_URL=https://support-logs.feralfile.com
290
+ ```
291
+
292
+ ## Raster API
293
+
294
+ `find` resolves tokens, series, and artist addresses through Raster's public GraphQL API ([docs.api.raster.art](https://docs.api.raster.art/)), and with the default `tokenSource` so do `build` and `enrich`. No configuration is required; two environment variables adjust the integration:
221
295
 
222
296
  ```env
223
297
  # API key sent as the x-api-key header. The API currently answers without
@@ -230,13 +304,13 @@ RASTER_API_URL=https://api.raster.art/graphql
230
304
 
231
305
  ## ff1Devices
232
306
 
233
- Configure devices you want to play content on.
307
+ Configure devices you want to display art on.
234
308
 
235
309
  - `ff1Devices.devices` (array of objects):
236
310
  - `name` (string): Friendly device label. Free‑form; pick anything memorable.
237
311
  - `id` (string): Stable physical ID discovered from FF1 (for example
238
- `FF1-SKYZ2E3A`). Relayer pairing stores the topic under this ID in the OS
239
- credential vault.
312
+ `FF1-ABCD1234`). Relayer pairing stores the topic under this ID in the OS
313
+ credential vault, and controller pairing names the controller key file after it.
240
314
  - `host` (string): Device base URL. For LAN devices, use `http://<ip>:1111`. The device typically listens on port `1111`.
241
315
 
242
316
  During `ff-cli setup`, the CLI will attempt local discovery via mDNS (`_ff1._tcp`). If devices are found, you can pick one and the host will be filled in automatically. If discovery returns nothing, setup falls back to manual entry.
@@ -256,12 +330,49 @@ You can also manage devices independently with:
256
330
  - `ff-cli device rename <name> <new-name>` – Rename a device without touching its host, API key, or default status. Works offline; also accepts the host URL as the identifier, so unnamed legacy entries can be given a name.
257
331
  - `ff-cli device remove <name>` – Remove a device by name.
258
332
  - `ff-cli device default <name>` – Promote a device to the top of the list so it is used when `-d` is omitted.
259
- - `ff-cli device pair [name]` – Display a one-time handoff code and key-bound
260
- security check, then securely receive that device's relayer topic from Feral
261
- File mobile. Confirm both values match before approving in mobile.
333
+ - `ff-cli device pair [name]` – Pair ff-cli with an FF1. ff-cli reads the
334
+ FF1's status on the LAN to pick the path:
335
+ - FF OS with secure control and an owner: show a QR code (and the same text
336
+ to paste) that an owner scans with **Add controller** in the Feral File
337
+ app; ff-cli pins the FF1's control profile from the owner's sealed answer
338
+ and enrolls as a **controller**. Needs an internet connection; see
339
+ [Secure control pairing](#secure-control-pairing).
340
+ - FF OS without secure control (or with it switched on but no owner yet):
341
+ display a one-time code and key-bound security check; in Feral File
342
+ mobile choose **Pair with ff-cli**, enter the code, and confirm both values
343
+ match before sharing. The app sends the relayer topic, which ff-cli stores
344
+ in the operating system credential vault.
345
+ - Status unreadable (FF1 off the LAN): the QR path, with a hint. Pass
346
+ `--topic-handoff` to force the one-time-code path.
347
+
348
+ The status is not authenticated, so ff-cli says which path it chose from
349
+ it, and never chooses the one-time code when it already holds controller
350
+ keys for the FF1 (a controller file, a pending replacement or the keys of
351
+ an unfinished pairing); only `--topic-handoff` does then.
352
+
353
+ - `ff-cli device controllers [name]` – List the controllers paired with an FF1
354
+ and any pending requests (owner only). On FF OS with display grants the list
355
+ also shows Play sites that an owner approved through mint pairing, with role
356
+ `display`, platform `web` and the site's origin in the LABEL column
357
+ (`(unverified)` when the relay did not attest it). A display row may only
358
+ cast to the FF1; `ff-cli device revoke` removes it like any controller.
359
+ - `ff-cli device revoke <controller-id> [name]` – Revoke a controller, or deny
360
+ its pending request (owner only).
361
+ - `ff-cli device unpair [name]` – Revoke ff-cli's own access on the FF1 (best
362
+ effort) and delete its controller keys. It always asks first (owner keys get
363
+ a stronger warning); `-y, --yes` skips the question.
364
+ Owner keys need confirmation (or `--yes`): if no other owner can reach the
365
+ FF1, deleting them leaves it without a reachable owner until a factory reset.
262
366
 
263
367
  Setup and `device add` both preserve existing devices. Adding a device with the same host as an existing one updates it in place.
264
368
 
369
+ For an explicitly enrolled owner Tailscale connection, use the FF1's Tailscale IPv4
370
+ address with the **same physical `--id` and existing `--name`**. Save its old
371
+ host first. This updates that device in place, preserving its list position,
372
+ API key and ID; relayer credentials remain in the vault under the same ID.
373
+ The configured host is replaced, with no automatic LAN/Tailscale failover.
374
+ See the [owner-access procedure](https://github.com/feral-file/ffos/blob/36e25cec84505271a7232d6936bb46a19fab5cfd/docs/OWNER_TAILSCALE.md).
375
+
265
376
  Selection rules when sending:
266
377
 
267
378
  - If you omit `-d`, the first configured device is used.
@@ -269,7 +380,7 @@ Selection rules when sending:
269
380
 
270
381
  Device cast contract:
271
382
 
272
- The device exposes a single HTTP endpoint, `POST http://<device>:1111/api/cast`. `ff-cli play` casts a DP-1 playlist with:
383
+ The device exposes a single HTTP endpoint, `POST http://<device>:1111/api/cast`. `ff-cli display` casts a DP-1 playlist with:
273
384
 
274
385
  ```jsonc
275
386
  POST http://<device>:1111/api/cast
@@ -287,18 +398,23 @@ Content-Type: application/json
287
398
 
288
399
  The `intent` field is **required** — without it the device returns `{"message":{"ok":false}}` with no further explanation. `dp1_call` may be the playlist inline (as above) or a hosted playlist URL string; ff-cli sends the resolved playlist object.
289
400
 
290
- When LAN delivery is unreachable, `play` prints an explicit notice and retries
401
+ When LAN delivery is unreachable, `display` prints an explicit notice and retries
291
402
  the same command through `ff1Relayer.baseUrl`. The per-device topic is read from
292
403
  the operating system credential vault, never `config.json`. Pair it from the
293
404
  mobile app first:
294
405
 
295
406
  ```bash
296
- ff-cli device pair office
407
+ ff-cli device pair office # FF OS without secure control: one-time code
408
+ ff-cli device pair office --topic-handoff # force the one-time code (FF1 not on this LAN)
297
409
  ```
298
410
 
299
- The CLI and mobile confirmation both show the same 12-hex-digit security check.
300
- Comparing it authenticates the CLI public key out of band, so an active broker
301
- cannot silently substitute its own encryption key.
411
+ On FF OS without secure control, the CLI and mobile confirmation both show the
412
+ same 12-hex-digit security check. Comparing it authenticates the CLI public key
413
+ out of band, so an active broker cannot silently substitute its own encryption
414
+ key. On FF OS with secure control, the topic arrives inside the owner's sealed
415
+ answer when the FF1 still uses the topic relayer; ff-cli stores it only after
416
+ the FF1's enroll reply verified, and the enroll itself uses the relayer only
417
+ with a topic stored earlier. The topic is never printed.
302
418
 
303
419
  The default relayer is
304
420
  `https://tv-cast-coordination.autonomy-system.workers.dev`. Override it with
@@ -307,18 +423,205 @@ need no additional key. Deployments that retain the cloud-edge compatibility
307
423
  gate may receive `ff1Relayer.apiKey` or `FF1_RELAYER_API_KEY`; ff-cli never
308
424
  bundles that shared key.
309
425
 
426
+ #### Relay v2
427
+
428
+ Relay v2 routes by FF1 device ID instead of a topic, on a public channel that
429
+ carries only sealed envelopes: a reply counts only when it verifies under the
430
+ FF1 profile pinned at pairing. `device pair` uses it for its pairing channel.
431
+
432
+ With the default relayer, the v2 endpoint is the production relay v2 Worker,
433
+ `wss://relayer.feralfile.io`, which keeps working after the legacy relayer is
434
+ retired. A custom `ff1Relayer.baseUrl` that is a bare HTTPS origin is used as
435
+ its own v2 endpoint. Either way pairing needs no setting. Override it with
436
+ `ff1Relayer.v2Endpoint` or `FF1_RELAY_V2_ENDPOINT`: an HTTPS or WSS origin
437
+ (plain HTTP only on loopback), with no credentials, path or query. An empty
438
+ value turns v2 off; `device pair` then reports that pairing is unavailable.
439
+
440
+ When ff-cli is paired as a controller, a command that cannot reach the FF1 on
441
+ the LAN goes over relay v2 first when the v2 endpoint is set explicitly, or
442
+ when the FF1's last verified status listed `relayV2` in its control
443
+ transports (ff-cli remembers this in the controller file). Otherwise it uses
444
+ the topic relayer.
445
+
446
+ ```json
447
+ {
448
+ "ff1Relayer": {
449
+ "v2Endpoint": "https://relay.example"
450
+ }
451
+ }
452
+ ```
453
+
454
+ - The stored topic is used only when relay v2 proves the command was not
455
+ delivered: it could not connect, or the relay refused it for every peer and
456
+ no part of it was acknowledged. The topic route then carries a newly sealed
457
+ request.
458
+ - When the command may have reached the FF1 (it was acknowledged, or the
459
+ connection dropped after sending, or no verified reply came in time), ff-cli
460
+ reports "Outcome unknown" and does not send it again. Check the FF1 before
461
+ running the command again.
462
+ - Relay refusals while connecting, and relay backpressure, are retried with
463
+ backoff.
464
+ - `device pair` finishes its enroll over a relayer topic this machine held
465
+ from before the pairing, when it has one: that topic reaches the real FF1,
466
+ which cannot answer an enroll sealed to a forged profile. Only when the
467
+ relayer answers that the topic has no device (1003) or is retired (410),
468
+ or the legacy host answers 404 (it was shut down), or no topic is held,
469
+ does it go to the LAN and then relay v2; any other relayer failure
470
+ (offline, a timeout, a 5xx, a DNS failure) stops there. When the
471
+ credential vault cannot be read, whether a topic is held is unknown, so the
472
+ enroll uses the LAN only, never relay v2. Relay v2 lets a
473
+ controller be added remotely: the FF1 profile comes from the owner's sealed
474
+ pairing result, so a reply on the public device channel has to verify under
475
+ it. A first-owner enroll with a screen invitation never uses relay v2.
476
+ - A command that reaches the relayer topic only to hear that the topic leads
477
+ nowhere (the same 1003, 410 or 404) also tries relay v2, even before a
478
+ verified status has said the FF1 is on v2, and a verified reply there
479
+ records that it is. So does a command when ff-cli holds no topic for the
480
+ FF1, or cannot read its credential vault: with no topic route there is
481
+ nothing a relay v2 attempt could cost. If the legacy host's name stops resolving instead,
482
+ ff-cli cannot tell that from an offline network; update ff-cli to a
483
+ release without the legacy relayer.
484
+ - The owner's sealed pairing result must fit one relay frame (32 KiB; results
485
+ are about 1 KiB). If the relay connection drops while `device pair` waits,
486
+ pairing stops; run it again to show a new code.
487
+ - On relay v2 a `replay` answer is reported as an unknown outcome, never
488
+ retried: the FF1 may have received two copies of the same request.
489
+
310
490
  On the first cast after boot the device may return an empty body. ff-cli retries this automatically and, if it persists, reports a clear "accepted the request but returned no usable response" error rather than crashing on a JSON parse.
311
491
 
312
492
  Compatibility checks:
313
493
 
314
- - `play` and `ssh` perform a compatibility preflight before sending commands to FF1. The CLI gets the device version by calling `POST /api/cast` with `{ "command": "getDeviceStatus", "request": {} }` and reads `message.installedVersion` from the response.
494
+ - `display` and `ssh` perform a compatibility preflight before sending commands to FF1. The CLI gets the device version by calling `POST /api/cast` with `{ "command": "getDeviceStatus", "request": {} }` and reads `message.installedVersion` from the response.
315
495
 
316
496
  - Minimum supported FF1 OS versions:
317
- - `play` (`displayPlaylist`): `1.0.0` or newer
497
+ - `display` (`displayPlaylist`): `1.0.0` or newer
318
498
  - `ssh` (`sshAccess`): `1.0.9` or newer
319
499
 
320
500
  - If the CLI cannot get a version from the device (e.g. network or malformed response), it continues and sends the command.
321
501
  - If the detected version is below the minimum, the command fails early with an error that includes the detected version.
502
+ - `ssh` sends the relayer topic stored by `ff-cli device pair` (OS keyring) in the `X-FF1-Topic` header. A claimed FF1 on newer FF OS rejects plaintext `sshAccess` without it (HTTP 401 `topic_required`). With no stored topic the request is sent without the header, which unclaimed devices and older FF OS accept.
503
+
504
+ - When ff-cli is paired as a controller (a controller file exists for the
505
+ device), the preflight, `display` and `ssh` are all sent as secure envelopes
506
+ instead; see below.
507
+
508
+ ### Secure control pairing
509
+
510
+ Current FF OS authorizes every command per controller (FF1 control protocol
511
+ v1). The FF1 itself decides who may do what; the LAN and the relayer only carry
512
+ encrypted, signed requests. ff-cli takes part as a controller:
513
+
514
+ ```bash
515
+ ff-cli device pair office # show the QR, wait for an owner, enroll
516
+ ff-cli device pair office --label studio # name the owner sees (default: hostname); 1-64 characters,
517
+ # no control or invisible formatting (bidi, zero-width) characters
518
+ ff-cli device pair office --timeout 600 # wait longer for an owner (seconds, 10-600; default 300)
519
+ ff-cli device pair office --resume # finish a pairing an owner already approved
520
+ ff-cli device pair office --yes # move a corrupt controller file aside without asking
521
+ ```
522
+
523
+ Pairing is owner-initiated, like adding an SSH key: nothing is requested from
524
+ the FF1 and no code is compared.
525
+
526
+ 1. ff-cli shows a QR code and the same text to paste (`ff1-ctl:1|…`). It
527
+ carries this computer's public controller keys, a one-time pairing secret
528
+ and a relay channel ID. The QR uses this computer's existing keys for the
529
+ FF1 when it has them, so a re-pair (after a revoke, a role change or a
530
+ factory reset) keeps one controller ID on the FF1. Otherwise the keys are
531
+ saved to `controllers/pairing/<device ID>.keys.json` once the relay
532
+ channel is open, before the QR is shown, so a retry after a timeout or Ctrl-C shows the same keys (with a
533
+ new secret and channel) and completes a registration an owner already
534
+ made.
535
+ 2. On an owner's phone, open the FF1 in the Feral File app, choose **Add
536
+ controller**, scan the code (or paste the text), and pick the role.
537
+ 3. The app registers ff-cli on the FF1, then sends ff-cli a sealed answer over
538
+ the relay channel. ff-cli accepts only an answer it can open with this
539
+ run's keys that carries its pairing secret, its own controller ID, and the
540
+ configured FF1's device ID; anything else on the channel is ignored. It pins
541
+ the FF1's control profile from that answer: every reply must then be signed
542
+ by its key.
543
+ 4. ff-cli enrolls with the FF1 (LAN first, relayer if the LAN is unreachable)
544
+ and prints the role it was granted.
545
+
546
+ Pairing needs an internet connection: the relay carries the owner's answer and
547
+ keeps nothing. It also needs an FF1 that already has an owner (choose **Secure
548
+ this FF1** in the app first); ff-cli cannot become an FF1's first owner. If no
549
+ owner answers within the timeout, the pairing is "not completed", not
550
+ rejected: running `device pair` again shows a new code for the same keys.
551
+ If an owner declines, the command fails. If the owner's
552
+ answer arrived but enrolling did not finish (the FF1 was unreachable, Ctrl-C),
553
+ `ff-cli device pair <name> --resume` finishes it without a new code.
554
+
555
+ If ff-cli is already paired with the FF1 under a different identity (the FF1
556
+ was factory reset), it says that the new pairing will replace it, and keeps
557
+ the current keys in place until the new pairing is enrolled: a failed or
558
+ abandoned pairing never loses an existing grant. The pending replacement is
559
+ kept in `controllers/pairing/` until then.
560
+
561
+ `ff-cli device unpair <name>` first asks the FF1 to revoke ff-cli's own access
562
+ (a sealed self-revoke, best effort with a short timeout), then deletes the keys
563
+ whatever the answer. Older FF OS refuses a controller's self-revoke, and an FF1
564
+ never lets its last owner revoke itself; in those cases the FF1 keeps listing
565
+ the controller until an owner revokes it. Unpair also deletes the keys of an
566
+ unfinished pairing; if an owner already added them, it prints their
567
+ controller ID for an owner to revoke.
568
+
569
+ Roles:
570
+
571
+ - **owner** – every command, including `ssh` and controller management.
572
+ - **controller** – displaying art (`display`) and device status. `ssh`,
573
+ `device controllers` and `device revoke` need an owner grant.
574
+
575
+ Controller keys live in one file per FF1:
576
+
577
+ ```
578
+ $XDG_CONFIG_HOME/ff-cli/controllers/<device ID>.json # default ~/.config/ff-cli/controllers/
579
+ ```
580
+
581
+ The file holds this computer's private controller keys, the pinned FF1 profile
582
+ (generation and key pins), facts from the owner's answer (channel, FF1 name,
583
+ role, expiry), the granted role, expiry and notification key, and the request
584
+ counter. ff-cli creates it with mode `0600` inside a `0700` directory
585
+ and replaces it atomically on every write. It is never printed or logged.
586
+ **Windows:** mode bits do not apply; the file relies on the ACL of your user
587
+ profile directory, so keep `%USERPROFILE%\.config` private to your account.
588
+
589
+ Treat the file like an SSH private key: anyone who copies it can act as this
590
+ controller until an owner revokes it. Do not restore it from an old backup —
591
+ the counter inside must only grow, and an FF1 rejects reused counters
592
+ (`replay`). When an FF1 answers `replay` in a reply signed by its key, ff-cli
593
+ moves the counter ahead and sends the command once more as a new request. If
594
+ it keeps happening, check that no other computer uses a copy of the file, then
595
+ run `ff-cli device pair <name>` again: the current keys stay in place until the
596
+ new pairing is finished. A controller file that
597
+ cannot be read (permissions, a program holding it) is never overwritten; a
598
+ corrupt one is moved aside to `<device ID>.json.corrupt-<time>` (after a
599
+ question, or `--yes`) before a new pairing.
600
+ `device remove` keeps the file, so re-adding the same FF1 keeps its pairing.
601
+
602
+ Requests go to `POST /api/cast` as
603
+ `{"command":"ff1Envelope","request":{"v":1,"jwe":"…"}}` on the LAN. When the
604
+ LAN is unreachable, `display`, enrolling (`device pair`), `device controllers`,
605
+ `device revoke` and unpair's self-revoke go through the relayer instead (they are encrypted and signed
606
+ either way); the version preflight and `ssh` stay LAN-only. Each attempt,
607
+ including every retry, is a new request with a new counter. FF1s on older FF OS
608
+ (no controller file for them) keep the plaintext path described above.
609
+
610
+ Errors and what to do:
611
+
612
+ | Message | Cause | Fix |
613
+ | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
614
+ | "This FF1 is secured and only accepts requests from paired controllers." | The FF1 refuses plaintext and ff-cli is not paired | `ff-cli device pair <name>`; an owner scans the code with **Add controller** |
615
+ | "…does not know this ff-cli controller", "…was revoked or has expired", "…has not been approved", "…still waiting for approval" | No active grant for these keys | `ff-cli device pair <name>` (or `--resume`); an owner scans the code with **Add controller**. If the owner gave the FF1 up (it shows a setup code, or no one has set it up since), `ff-cli device unpair <name>` to use it without pairing |
616
+ | "Pairing was not completed: no owner answered…" | No verified answer reached ff-cli before `--timeout` | Run `ff-cli device pair <name>` again (needs internet) |
617
+ | "Pairing is not available: … relay v2 is turned off" | Relay v2 is off: `ff1Relayer.v2Endpoint`/`FF1_RELAY_V2_ENDPOINT` is empty, the relayer settings are invalid, or `ff1Relayer.baseUrl` is not a bare HTTPS origin and no v2 endpoint is set | Remove the empty override, or set an HTTPS/WSS origin (see [Relay v2](#relay-v2)) |
618
+ | "This command needs owner access…" | `ssh` or controller management with a controller grant | An owner grants the owner role in the app |
619
+ | "The FF1 could not open the request." / "…rejected the request signature." | The FF1's control identity changed (for example a factory reset), or the device address now reaches another FF1. These answers are not authenticated: anyone on the path can send one | Check the device address first. If the FF1 was factory reset: `ff-cli device pair <name>` (the current keys stay until the new pairing is finished). Do not unpair |
620
+ | "The reply did not verify against the paired FF1 identity…" | A reply not signed by the pinned FF1 (wrong device, or a forged relayer reply) | Check the device host; if the FF1 was reset, `ff-cli device pair <name>`. Do not unpair |
621
+ | "The FF1 is busy; try again." | The FF1 was checking too many requests at once and did not run this one (`display` retries it automatically with a new request) | Run the command again |
622
+ | "The FF1 has no grant for ff-cli, although an owner's Feral File app reported adding it." | An owner removed ff-cli again before it enrolled | `ff-cli device pair <name>` |
623
+ | "The FF1 rejected the request counter as already used." | The counter is still behind after ff-cli moved it ahead once (another computer uses a copy of the file) | Stop the other copy; run the command again. Do not unpair |
624
+ | "The FF1 rejected the request as malformed." | Usually a clock more than 5 minutes ahead of the FF1's | Fix the clock on this computer (or let the FF1 finish setting its time), then retry |
322
625
 
323
626
  Troubleshooting note:
324
627
 
@@ -328,10 +631,10 @@ Examples:
328
631
 
329
632
  ```bash
330
633
  # Send to first device
331
- npm run dev -- play playlist.json
634
+ npm run dev -- display playlist.json
332
635
 
333
- # Play on a specific device by exact name
334
- npm run dev -- play playlist.json -d "Living Room Display"
636
+ # Display on a specific device by exact name
637
+ npm run dev -- display playlist.json -d "Living Room Display"
335
638
  ```
336
639
 
337
640
  Minimal `config.json` example (selected fields):
@@ -344,7 +647,7 @@ Minimal `config.json` example (selected fields):
344
647
  "role": "curator"
345
648
  },
346
649
  "feed": {
347
- "baseURLs": ["https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"]
650
+ "baseURLs": ["https://feed.feralfile.com/api/v1"]
348
651
  },
349
652
  "ff1Devices": {
350
653
  "devices": [