@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
@@ -31,6 +31,8 @@ npm run dev -- config show
31
31
  - Which service `find`, `build`, and `enrich` read token data from. Defaults to `indexer`. See [tokenSource](#tokensource).
32
32
  - **ipfsGateways** (object)
33
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).
34
36
 
35
37
  ## browser
36
38
 
@@ -43,10 +45,9 @@ Optional settings used where headless/browser‑like behavior is needed.
43
45
 
44
46
  Used for signing DP‑1 playlists.
45
47
 
46
- - `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.
47
49
 
48
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:
49
-
50
51
  - **base64 PKCS#8 DER** — recommended; what `ff-cli setup` generates.
51
52
  - **32-byte raw Ed25519 seed** as **hex** (optional `0x`) or **base64**.
52
53
  - **PEM** (`-----BEGIN PRIVATE KEY-----`).
@@ -90,7 +91,7 @@ delivery mechanism rather than a fourth key format.
90
91
 
91
92
  **Prefer `--key-file` to `--key`.** `--key` writes your private key into your shell history file and
92
93
  exposes it in the process list, where any local user can read it for as long as the command runs.
93
- 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
94
95
  `curators[]` owner set is immutable, so a leaked key cannot be rotated out of a document that already
95
96
  names it. `--key-file` keeps the material in a file you control:
96
97
 
@@ -116,7 +117,7 @@ The rules on both flags:
116
117
  something a publish or a delete can be taken back from.
117
118
  - **A `--key-file` argument is always opened, whatever it looks like.** A directory really can be
118
119
  called `BEGIN`, and a key file really can be named after a content hash. What is gated is not the
119
- 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
120
121
  complete key encoding, and otherwise says it could not be read without repeating it. Typing the key
121
122
  where the path goes — one keystroke from `--key` — is reported as that mistake, by name, with the
122
123
  value withheld.
@@ -149,8 +150,8 @@ If you need a different role, set `playlist.role` to one of the DP-1 signing rol
149
150
  ### Signing role and ownership
150
151
 
151
152
  A feed decides who owns a playlist from the document itself: the keys in `curators[]` are the owner set.
152
- Under **role-aware ownership**, a key counts as an owner only when that same key *also signed in the
153
- `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.
154
155
 
155
156
  `ff-cli publish` requires that role. **This is ff-cli's check, not every feed's answer today**: a feed that
156
157
  does not yet enforce roles accepts an `agent`-signed document, so a manual upload of the same file can
@@ -185,7 +186,8 @@ DP‑1 Feed API configuration.
185
186
 
186
187
  - `feed.baseURLs` (string[]): Array of DP‑1 Feed Operator API v1 base URLs. The CLI queries all feeds in parallel.
187
188
  - Legacy support: `feed.baseURL` (string) is still accepted and normalized to an array.
188
- - 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.
189
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).
190
192
 
191
193
  Endpoints used by the CLI:
@@ -216,15 +218,15 @@ defaulting to index 0 would silently write to whichever feed happens to be first
216
218
  Environment variable alternative:
217
219
 
218
220
  ```env
219
- 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
220
222
  ```
221
223
 
222
224
  ## tokenSource
223
225
 
224
- Token data for `find`, `build`, and `enrich` (title, artist, description, playable source, on-chain provenance, and a still image where the service supplies one) comes from one service per run:
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:
225
227
 
226
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.
227
- - `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 play black, and evolving works play 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.
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.
228
230
 
229
231
  ```json
230
232
  { "tokenSource": "raster" }
@@ -269,6 +271,24 @@ IPFS_GATEWAY_FERAL_FILE=https://ipfs.feralfile.com/ipfs/
269
271
  IPFS_GATEWAY_DEFAULT=https://ipfs.filebase.io/ipfs/
270
272
  ```
271
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
+
272
292
  ## Raster API
273
293
 
274
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:
@@ -284,13 +304,13 @@ RASTER_API_URL=https://api.raster.art/graphql
284
304
 
285
305
  ## ff1Devices
286
306
 
287
- Configure devices you want to play content on.
307
+ Configure devices you want to display art on.
288
308
 
289
309
  - `ff1Devices.devices` (array of objects):
290
310
  - `name` (string): Friendly device label. Free‑form; pick anything memorable.
291
311
  - `id` (string): Stable physical ID discovered from FF1 (for example
292
- `FF1-SKYZ2E3A`). Relayer pairing stores the topic under this ID in the OS
293
- 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.
294
314
  - `host` (string): Device base URL. For LAN devices, use `http://<ip>:1111`. The device typically listens on port `1111`.
295
315
 
296
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.
@@ -310,9 +330,39 @@ You can also manage devices independently with:
310
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.
311
331
  - `ff-cli device remove <name>` – Remove a device by name.
312
332
  - `ff-cli device default <name>` – Promote a device to the top of the list so it is used when `-d` is omitted.
313
- - `ff-cli device pair [name]` – Display a one-time handoff code and key-bound
314
- security check, then securely receive that device's relayer topic from Feral
315
- 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.
316
366
 
317
367
  Setup and `device add` both preserve existing devices. Adding a device with the same host as an existing one updates it in place.
318
368
 
@@ -330,7 +380,7 @@ Selection rules when sending:
330
380
 
331
381
  Device cast contract:
332
382
 
333
- 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:
334
384
 
335
385
  ```jsonc
336
386
  POST http://<device>:1111/api/cast
@@ -348,18 +398,23 @@ Content-Type: application/json
348
398
 
349
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.
350
400
 
351
- 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
352
402
  the same command through `ff1Relayer.baseUrl`. The per-device topic is read from
353
403
  the operating system credential vault, never `config.json`. Pair it from the
354
404
  mobile app first:
355
405
 
356
406
  ```bash
357
- 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)
358
409
  ```
359
410
 
360
- The CLI and mobile confirmation both show the same 12-hex-digit security check.
361
- Comparing it authenticates the CLI public key out of band, so an active broker
362
- 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.
363
418
 
364
419
  The default relayer is
365
420
  `https://tv-cast-coordination.autonomy-system.workers.dev`. Override it with
@@ -368,18 +423,205 @@ need no additional key. Deployments that retain the cloud-edge compatibility
368
423
  gate may receive `ff1Relayer.apiKey` or `FF1_RELAYER_API_KEY`; ff-cli never
369
424
  bundles that shared key.
370
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
+
371
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.
372
491
 
373
492
  Compatibility checks:
374
493
 
375
- - `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.
376
495
 
377
496
  - Minimum supported FF1 OS versions:
378
- - `play` (`displayPlaylist`): `1.0.0` or newer
497
+ - `display` (`displayPlaylist`): `1.0.0` or newer
379
498
  - `ssh` (`sshAccess`): `1.0.9` or newer
380
499
 
381
500
  - If the CLI cannot get a version from the device (e.g. network or malformed response), it continues and sends the command.
382
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 |
383
625
 
384
626
  Troubleshooting note:
385
627
 
@@ -389,10 +631,10 @@ Examples:
389
631
 
390
632
  ```bash
391
633
  # Send to first device
392
- npm run dev -- play playlist.json
634
+ npm run dev -- display playlist.json
393
635
 
394
- # Play on a specific device by exact name
395
- 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"
396
638
  ```
397
639
 
398
640
  Minimal `config.json` example (selected fields):
@@ -405,7 +647,7 @@ Minimal `config.json` example (selected fields):
405
647
  "role": "curator"
406
648
  },
407
649
  "feed": {
408
- "baseURLs": ["https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"]
650
+ "baseURLs": ["https://feed.feralfile.com/api/v1"]
409
651
  },
410
652
  "ff1Devices": {
411
653
  "devices": [
package/docs/EXAMPLES.md CHANGED
@@ -18,7 +18,7 @@ npm run dev -- config validate
18
18
 
19
19
  ## Use with a coding agent
20
20
 
21
- ff-cli ships a [Claude Code](https://docs.claude.com/en/docs/claude-code) / Codex skill at `skills/ff-control/SKILL.md`. Once installed, ask your agent in plain language ("Get 3 works from reas.eth and play on Living Room", "Build a playlist from this Objkt URL and publish to my feed") and it translates the request into `ff-cli find` or `ff-cli build` and runs the validate → play/publish flow for you.
21
+ ff-cli ships a [Claude Code](https://docs.claude.com/en/docs/claude-code) / Codex skill at `skills/ff-control/SKILL.md`. Once installed, ask your agent in plain language ("Get 3 works from reas.eth and display on Living Room", "Build a playlist from this Objkt URL and publish to my feed") and it translates the request into `ff-cli find` or `ff-cli build` and runs the validate → display/publish flow for you.
22
22
 
23
23
  Recommended local install path:
24
24
 
@@ -32,19 +32,19 @@ The rest of this doc shows the deterministic commands the skill (or you) drives
32
32
 
33
33
  ## Find an artwork
34
34
 
35
- `ff-cli find` resolves a marketplace URL, raw `chain:contract:tokenId`, or wallet address into a playable DP-1 playlist. Sources: Art Blocks, Objkt, fxhash, OpenSea, SuperRare, Feral File, Neort, Verse, raw on-chain coordinates, and wallet addresses.
35
+ `ff-cli find` resolves a marketplace URL, raw `chain:contract:tokenId`, or wallet address into a DP-1 playlist ready to display. Sources: Art Blocks, Objkt, fxhash, OpenSea, SuperRare, Feral File, Neort, Verse, raw on-chain coordinates, and wallet addresses.
36
36
 
37
37
  ```bash
38
- # Paste a URL and play it on your FF1
39
- npm run dev -- find https://www.artblocks.io/collection/ringers-by-dmitri-cherniak --play
38
+ # Paste a URL and display it on your FF1
39
+ npm run dev -- find https://www.artblocks.io/collection/ringers-by-dmitri-cherniak --display
40
40
 
41
41
  # Tezos / hic et nunc via Objkt (the alias resolves to a KT1 contract)
42
- npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play
42
+ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --display
43
43
 
44
44
  # Feral File artwork (public id may be hex or numeric)
45
- npm run dev -- find https://feralfile.com/exhibitions/artwork/f0240e04d64717e319584957f6a83954b029254ad1260b6320472ea8c0c5b1cf --play
45
+ npm run dev -- find https://feralfile.com/exhibitions/artwork/f0240e04d64717e319584957f6a83954b029254ad1260b6320472ea8c0c5b1cf --display
46
46
 
47
- # Save without playing
47
+ # Save without displaying
48
48
  npm run dev -- find ethereum:0xababababab20053426ad1c782de9ea8444358070:5001410 -o send-receive.json
49
49
 
50
50
  # Single on-chain token by coordinates
@@ -62,11 +62,11 @@ npm run dev -- find https://www.artblocks.io/collection/ringers-by-dmitri-cherni
62
62
  # Build and publish to a configured feed server
63
63
  npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --publish
64
64
 
65
- # Build, play on a named device, and skip interactive prompts
66
- npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play -d "Living Room Display" -y
65
+ # Build, display on a named device, and skip interactive prompts
66
+ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --display -d "Living Room Display" -y
67
67
 
68
- # -y alone also plays (the scripted default); add --no-play to build and save without casting
69
- npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 -y --no-play
68
+ # -y alone also displays (the scripted default); add --no-display to build and save without casting
69
+ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 -y --no-display
70
70
  ```
71
71
 
72
72
  OpenSea and Verse accept both item and collection/series URLs:
@@ -278,14 +278,14 @@ has to be re-signed:
278
278
  ```bash
279
279
  ff-cli enrich playlist.json --assume-ethereum
280
280
  ff-cli sign playlist.json
281
- ff-cli play playlist.json -d "living room"
281
+ ff-cli display playlist.json -d "living room"
282
282
  ```
283
283
 
284
284
  Casting does not care about the signing role. Add `-r curator` to that `sign` if the playlist is also
285
285
  going to a feed: `ff-cli publish` requires the owner role, for the reasons in
286
286
  [Publish to Feed Server](#publish-to-feed-server).
287
287
 
288
- ## Validate / Sign / Play
288
+ ## Validate / Sign / Display
289
289
 
290
290
  ```bash
291
291
  # Validate playlist structure
@@ -298,27 +298,27 @@ npm run dev -- verify playlist.json
298
298
  # Sign playlist (uses key/role from config, or override via --key / --role)
299
299
  npm run dev -- sign playlist.json -o signed.json
300
300
 
301
- # Play on the configured default device
302
- npm run dev -- play playlist.json
301
+ # Display on the configured default device
302
+ npm run dev -- display playlist.json
303
303
 
304
- # Play on a specific named device
305
- npm run dev -- play signed.json -d "Living Room Display"
304
+ # Display on a specific named device
305
+ npm run dev -- display signed.json -d "Living Room Display"
306
306
 
307
- # Play a hosted DP-1 playlist
308
- npm run dev -- play "https://cdn.example.com/playlist.json"
307
+ # Display a hosted DP-1 playlist
308
+ npm run dev -- display "https://cdn.example.com/playlist.json"
309
309
 
310
- # Play a playlist straight from a DP-1 feed server (the URL the feed hands out)
310
+ # Display a playlist straight from a DP-1 feed server (the URL the feed hands out)
311
311
  # Wallet-signed (eip191) documents need --skip-verify until #103 lands
312
- npm run dev -- play "https://feed.feralfile.com/api/v1/playlists/<slug>" --skip-verify
312
+ npm run dev -- display "https://feed.feralfile.com/api/v1/playlists/<slug>" --skip-verify
313
313
 
314
- # Play a media URL directly
315
- npm run dev -- play "https://example.com/video.mp4"
314
+ # Display a media URL directly
315
+ npm run dev -- display "https://example.com/video.mp4"
316
316
 
317
317
  # Skip verification only if you must send a non-conformant payload (not recommended)
318
- npm run dev -- play playlist.json --skip-verify
318
+ npm run dev -- display playlist.json --skip-verify
319
319
  ```
320
320
 
321
- > **No feed server is required to cast.** A device can play any static, signed DP-1 playlist hosted at a public URL — sign it (`sign`), upload the JSON anywhere that serves it over HTTPS (S3, Supabase Storage, a static host, etc.), then `ff-cli play "<url>"`. The DP-1 Feed server ([Publish to Feed Server](#publish-to-feed-server) below) adds discovery and curation, but is optional for simply playing your own playlists.
321
+ > **No feed server is required to cast.** A device can display any static, signed DP-1 playlist hosted at a public URL — sign it (`sign`), upload the JSON anywhere that serves it over HTTPS (S3, Supabase Storage, a static host, etc.), then `ff-cli display "<url>"`. The DP-1 Feed server ([Publish to Feed Server](#publish-to-feed-server) below) adds discovery and curation, but is optional for simply displaying your own playlists.
322
322
 
323
323
  ## Publish to Feed Server
324
324
 
@@ -921,7 +921,7 @@ says so rather than sending you after a key that does not exist. That is the sta
921
921
  published without an owner-role signature is frozen in, and it is what the `publish` owner-role gate
922
922
  above exists to prevent.
923
923
 
924
- ## Complete Flow (build → validate → sign → play → publish)
924
+ ## Complete Flow (build → validate → sign → display → publish)
925
925
 
926
926
  ```bash
927
927
  # 1. Build a playlist (via find or build)
@@ -937,8 +937,8 @@ npm run dev -- validate playlist.json
937
937
  # what feeds enforce today.
938
938
  npm run dev -- sign playlist.json -r curator -o signed.json
939
939
 
940
- # 4. Play it on a device
941
- npm run dev -- play signed.json -d "Living Room Display"
940
+ # 4. Display it on a device
941
+ npm run dev -- display signed.json -d "Living Room Display"
942
942
 
943
943
  # 5. Publish to a feed server
944
944
  npm run dev -- publish signed.json -s 0
@@ -949,17 +949,17 @@ key declared in `curators[]`. Skip step 3 and use `playlist.json` in steps 4 and
949
949
  `signed.json` on that path:
950
950
 
951
951
  ```bash
952
- npm run dev -- play playlist.json -d "Living Room Display"
952
+ npm run dev -- display playlist.json -d "Living Room Display"
953
953
  npm run dev -- publish playlist.json -s 0
954
954
  ```
955
955
 
956
956
  Set `playlist.curatorName` in the config to control the name recorded in `curators[]`; it defaults
957
957
  to `ff-cli`.
958
958
 
959
- `ff-cli find` can collapse build + play + publish into one command:
959
+ `ff-cli find` can collapse build + display + publish into one command:
960
960
 
961
961
  ```bash
962
- npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play -d "Living Room Display" --publish
962
+ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --display -d "Living Room Display" --publish
963
963
  ```
964
964
 
965
965
  ## FF1 device management