@feralfile/cli 2.3.0 → 2.5.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.
@@ -50,6 +50,19 @@ Used for signing DP‑1 playlists.
50
50
 
51
51
  ff-cli normalizes whichever form you supply to base64 PKCS#8 DER before handing it to **`dp1-js`** (`SignMultiEd25519`). A malformed key fails with an actionable message (e.g. _"Invalid Ed25519 signing key … run `ff-cli setup` to generate one"_) instead of dp1-js's cryptic OpenSSL ASN.1 error (`header too long` / `wrong tag`).
52
52
 
53
+ - `playlist.curatorName` (string): Display name recorded alongside your signing key in the `curators[]`
54
+ of playlists built by `find` and `build`. Defaults to `ff-cli`; you can also set it via
55
+ `PLAYLIST_CURATOR_NAME`. The feed authorizes a create by matching a signature's `kid` against a key
56
+ declared in the document's own `curators[]`, so those commands write that declaration **before**
57
+ signing — a name is required next to the key by DP-1, and the value is public and covered by the
58
+ signature. The sample `YOUR_CURATOR_NAME` in `config.json.example` is treated as unset, so an untouched
59
+ config falls through to `PLAYLIST_CURATOR_NAME` or the default rather than publishing under a
60
+ placeholder.
61
+
62
+ To read the `did:key` your signatures will carry, run `ff-cli status`. It reports the identity from
63
+ `PLAYLIST_PRIVATE_KEY` even when no config file exists yet, and `ff-cli status --key <privateKey>`
64
+ reports it for an explicit key — the one `sign --key` would use.
65
+
53
66
  - `playlist.role` (string): DP-1 signing role that travels with the private key. Defaults to `agent` if omitted. You can also set this via `PLAYLIST_ROLE` in `.env`. Guided `ff-cli setup`, `config validate`, and `sign --role` only accept the usual DP-1 signing roles (`agent`, `feed`, `curator`, `institution`, `licensor`).
54
67
 
55
68
  ### Generate an Ed25519 private key
package/docs/EXAMPLES.md CHANGED
@@ -232,6 +232,44 @@ JSON
232
232
  npm run dev -- build /tmp/mixed.json -o playlist-mixed.json -v
233
233
  ```
234
234
 
235
+ ## Enrich an existing playlist
236
+
237
+ A playlist whose items carry `provenance` but no `inlineManifest` labels
238
+ title-only on the FF1 tombstone, and shows empty tiles in the app for every
239
+ live HTML work. `enrich` repairs it in place.
240
+
241
+ ```bash
242
+ ff-cli enrich playlist.json
243
+ ```
244
+
245
+ ```text
246
+ Enrich playlist
247
+
248
+ 18/18 looked up...
249
+
250
+ 18 of 18 item(s) enriched
251
+ Output: playlist.json
252
+ ```
253
+
254
+ Items the indexer cannot resolve are listed rather than guessed at:
255
+
256
+ ```text
257
+ Nothing to enrich
258
+
259
+ Skipped 2:
260
+ Kim Asendorf — PXL NET — no provenance.contract chain/address/tokenId to look up
261
+ Untitled — the indexer returned nothing for it
262
+ ```
263
+
264
+ Enrichment changes the document, so a signed playlist loses its envelope and
265
+ has to be re-signed:
266
+
267
+ ```bash
268
+ ff-cli enrich playlist.json
269
+ ff-cli sign playlist.json
270
+ ff-cli play playlist.json -d "living room"
271
+ ```
272
+
235
273
  ## Validate / Sign / Play
236
274
 
237
275
  ```bash
@@ -279,12 +317,10 @@ Add feed servers to `config.json`:
279
317
  {
280
318
  "feedServers": [
281
319
  {
282
- "baseUrl": "http://localhost:8787/api/v1",
283
- "apiKey": "your-api-key"
320
+ "baseUrl": "http://localhost:8787/api/v1"
284
321
  },
285
322
  {
286
- "baseUrl": "https://feed.example.com/api/v1",
287
- "apiKey": "your-api-key"
323
+ "baseUrl": "https://feed.example.com/api/v1"
288
324
  }
289
325
  ]
290
326
  }
@@ -345,13 +381,61 @@ Select server (0-based index): 0
345
381
  Playlist file not found: /path/to/playlist.json
346
382
  ```
347
383
 
348
- **API error:**
384
+ Both of these are caught locally, before anything is uploaded.
385
+
386
+ **Not signed:**
349
387
 
350
388
  ```
351
- ❌ Failed to publish playlist
352
- Failed to publish: {"error":"unauthorized","message":"Invalid API key"}
389
+ Publish failed
390
+ Playlist verification failed: Playlist signature verification failed
353
391
  ```
354
392
 
393
+ Run `ff-cli sign <file>` before publishing. (Posting the same document straight to the feed answers
394
+ `{"error":"unauthorized","message":"missing authentication: request body must carry signatures"}`.)
395
+
396
+ **Signed, but the signer is not declared as a curator:**
397
+
398
+ ```
399
+ Publish failed
400
+ Playlist is signed, but the signing key is not declared as a curator.
401
+
402
+ The feed accepts a publish when a signature's kid appears in the playlist's own curators[].
403
+ Add this to the playlist before signing:
404
+ "curators": [{ "name": "Your name", "key": "did:key:z6Mkv7qJ..." }]
405
+ then sign again from the unsigned file — signing appends, so re-signing an already-signed
406
+ playlist leaves the earlier signature covering a document that no longer exists.
407
+ ```
408
+
409
+ This is the most common publish failure, and it is not about credentials. The feed accepts a create when
410
+ the document carries a signature whose `kid` matches a key declared in its own `curators[]`; posting such
411
+ a document directly returns
412
+ `{"error":"signature_verification_failed","message":"curator signature verification: no valid curator signature found"}`,
413
+ which reads as a signing fault, so `ff-cli` diagnoses it locally instead.
414
+
415
+ **Order matters.** A signature covers every field except `signature`/`signatures`, so `curators[]` has to
416
+ be declared *before* signing. Adding it afterwards invalidates the signature, and signing again does not
417
+ repair it — signing **appends**, leaving the earlier entry covering a document that no longer exists, so
418
+ verification fails before upload.
419
+
420
+ ```bash
421
+ # 1. Read your signing identity — this is the kid your signatures will carry.
422
+ ff-cli status # → Signing identity (did:key) did:key:z6Mk...
423
+
424
+ # 2. Declare it in the UNSIGNED playlist.
425
+ # "curators": [{ "name": "Your Name", "key": "did:key:z6Mk..." }]
426
+ # DP-1 requires `name` alongside `key`.
427
+
428
+ # 3. Sign once, then publish.
429
+ ff-cli sign playlist.json
430
+ ff-cli publish playlist.json
431
+ ```
432
+
433
+ If a playlist is already signed and you need to add `curators[]`, start again from the unsigned file
434
+ rather than re-signing: remove `signatures`, declare the curator, then sign.
435
+
436
+ `ff-cli publish` checks this before uploading, so a missing declaration fails immediately with the key to
437
+ add rather than as a server error. No API key is involved: the feed does not accept one.
438
+
355
439
  ## Complete Flow (build → validate → sign → play → publish)
356
440
 
357
441
  ```bash
@@ -361,7 +445,9 @@ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 -o playlist.json
361
445
  # 2. Validate it
362
446
  npm run dev -- validate playlist.json
363
447
 
364
- # 3. Sign it
448
+ # 3. Sign it. Declare curators[] BEFORE this step: the signature covers it, and the feed only
449
+ # accepts a publish when a signature's kid matches a declared curator key.
450
+ # Run `ff-cli status` for the kid (add -k <key> if you sign with `sign --key`).
365
451
  npm run dev -- sign playlist.json -o signed.json
366
452
 
367
453
  # 4. Play it on a device
@@ -371,6 +457,18 @@ npm run dev -- play signed.json -d "Living Room Display"
371
457
  npm run dev -- publish signed.json -s 0
372
458
  ```
373
459
 
460
+ With a signing key configured, `find` and `build` already produce a signed `playlist.json` with your
461
+ key declared in `curators[]`. Skip step 3 and use `playlist.json` in steps 4 and 5 — there is no
462
+ `signed.json` on that path:
463
+
464
+ ```bash
465
+ npm run dev -- play playlist.json -d "Living Room Display"
466
+ npm run dev -- publish playlist.json -s 0
467
+ ```
468
+
469
+ Set `playlist.curatorName` in the config to control the name recorded in `curators[]`; it defaults
470
+ to `ff-cli`.
471
+
374
472
  `ff-cli find` can collapse build + play + publish into one command:
375
473
 
376
474
  ```bash
@@ -197,6 +197,128 @@ nowhere to host a manifest document. Behavior worth knowing downstream:
197
197
  - An inline manifest has no `refHash`; its integrity comes from the playlist
198
198
  signature.
199
199
 
200
+ ### Enriching a playlist the CLI did not build
201
+
202
+ `find` and `build` attach a manifest as they create each item. A playlist that
203
+ arrives another way — assembled by hand, copied out of another document, or
204
+ written before §3.6 existed — has items with a `source` and a `provenance` block
205
+ and nothing else. `enrich` is the repair path for those.
206
+
207
+ - Enrichment is keyed on `provenance.contract` (chain, address, token id), not
208
+ on `source`. A source URL is one rendition of a work and several renditions
209
+ share one; the coordinate is what identifies the artwork.
210
+ - It writes `inlineManifest` and nothing else. `source`, `duration`, `title`,
211
+ `id`, and `display` are curator decisions and survive untouched even when the
212
+ indexer disagrees with them.
213
+ - An item the indexer cannot resolve is reported, never synthesized. The same
214
+ rule the manifest mapping follows applies here: the CLI does not invent fields
215
+ it cannot substantiate, and an invented artist line inside a signed document
216
+ is worse than an absent one.
217
+ - DP-1 names the EVM family `evm`; the FF indexer names it `ethereum`. Enrich
218
+ translates between the two. Unlisted chain names pass through so the indexer
219
+ decides what it supports.
220
+ - Enrichment changes the document, so both the `signatures[]` envelope and the
221
+ legacy flat `signature` are removed and the playlist must be re-signed.
222
+ Leaving a stale envelope in place would produce a file that fails
223
+ verification at the device with no explanation.
224
+ - An item carrying a `ref` is already labelled by a manifest the CLI cannot
225
+ see, and `ref` outranks `inlineManifest` in the resolution order, so
226
+ enrichment skips it. `--force` does not override that: making the inline
227
+ manifest win would mean deleting the curator's `ref` and `refHash`, which
228
+ discards the integrity hash the remote manifest is checked against. That
229
+ conversion, if ever wanted, belongs behind its own flag.
230
+ - Enrichment looks each distinct coordinate up once, however many items share
231
+ it. A token the indexer has not seen triggers an indexing job and a poll that
232
+ can take minutes, and a playlist that repeats a work would otherwise submit
233
+ that job once per appearance.
234
+ - Overriding an item's title changes the manifest's content, so it also changes
235
+ the manifest's `id`. The indexer derives that id from the token coordinate,
236
+ so two items of one artwork receive the same one; leaving it alone while
237
+ changing the title would produce two documents claiming to be the same cached
238
+ manifest. The replacement is derived from the original id and the title, so
239
+ it is deterministic across runs.
240
+ - The indexer resolving a token and having something worth attaching are
241
+ different outcomes: `buildInlineManifestForToken` returns nothing when it has
242
+ only a title, since a title-only manifest adds payload without adding
243
+ information. Enrichment reports those separately from tokens it could not
244
+ resolve at all.
245
+ - A still the manifest builder suppressed is recovered against the item's own
246
+ source. `resolveStillUri` blanks a thumbnail equal to the source it was
247
+ handed, which is the source the *indexer* chose; for a static work that is
248
+ often the still itself. When the curator kept a live HTML source instead, the
249
+ item would otherwise end up with no thumbnail — the empty grid tile this
250
+ command exists to remove. When the indexer's source differs from the item's
251
+ and is http(s) **and is media the app can rasterize**, it is that suppressed
252
+ still
253
+ and becomes the thumbnail. The type check matters: `getBestMediaUrl` prefers
254
+ `display.animation_url` and media assets over `display.image_url`, so the
255
+ indexer's source is frequently a live HTML rendition, and putting one in the
256
+ thumbnail slot is worse than leaving it empty — the grid still cannot
257
+ rasterize it and the item now claims a still it does not have. The predicate
258
+ matches the media types `playlist-builder.js` already recognizes, images plus
259
+ SVG and video, and rejects HTML. A
260
+ thumbnail-only manifest is emitted where none was, unlike a title-only one:
261
+ it is the difference between a tile and an empty square.
262
+ - `evm` names a family, and enrichment refuses to guess which member. DP-1 §6
263
+ carries no network identity, and the indexer maps Ethereum, Polygon,
264
+ Arbitrum, Optimism, Base, and Zora all back to `evm`, so neither the request
265
+ nor the response can say which network is meant. Ethereum is chosen because
266
+ it is the only EVM network this client can reach — `buildTokenCID` maps
267
+ ethereum to `eip155:1` and tezos to `tezos:mainnet` and throws for anything
268
+ else. The residual risk is narrow: an L2 work whose address and token id also
269
+ exist on Ethereum would take the Ethereum work's metadata. The command
270
+ is the operator's to make: `--assume-ethereum` asserts it, and without that
271
+ flag `evm` items are skipped with a reason that names the flag. The warning
272
+ prints before anything is written, so an operator who did not mean to assert
273
+ it still has the file they started with. Reaching an actual L2 belongs in the
274
+ indexer client, which maps only `ethereum` and `tezos` today.
275
+ - In-place writes refuse to run over an input that changed while the lookup
276
+ was in flight. The comparison is a hash of the bytes actually parsed, against
277
+ the resolved write target, so `-o` naming the input by another spelling and a
278
+ symlink pointing at it are both recognized as the in-place case. It runs
279
+ immediately before the rename, so the exposed interval is a few syscalls
280
+ rather than the minutes a lookup takes. It narrows the race and does not
281
+ close it: compare-then-replace is not atomic against an editor participating
282
+ in no protocol, and no userspace sequence makes it so. Writing to a distinct
283
+ `--output` opts out of in-place replacement entirely, which is the right
284
+ choice when a file is being actively edited.
285
+ - An attached manifest's id is derived from its whole finished metadata block,
286
+ every time rather than only when this command changed something. The indexer
287
+ keys its id on the token coordinate, so every rendition of one artwork
288
+ carries the same id whatever its content, and under `--force` the indexer may
289
+ itself return a different artist or description under that unchanged id.
290
+ Deriving from the payload is the only rule that holds in every case:
291
+ identical content keeps one identity, differing content never shares one. The
292
+ original id is folded in so the result stays anchored to the token and stays
293
+ deterministic across runs.
294
+ - In-place writes refuse to run over an input that changed while the lookup
295
+ was in flight. The lookup can take minutes and the default destination is the
296
+ input, so a curator's edit or re-sign in that window would otherwise be
297
+ overwritten by a result computed from older bytes.
298
+ - In-place writes are atomic and crash-durable: the contents are synced before
299
+ the rename and the containing directory after it, so a power loss cannot
300
+ leave the destination name pointing at incomplete data. The directory sync is
301
+ best-effort, since it is not portable.
302
+ - In-place writes are atomic: the candidate goes to a temporary file beside the
303
+ destination and is renamed over it, so an interruption cannot leave a
304
+ truncated playlist. A symlinked destination is followed to its target rather
305
+ than replaced, and the existing file's permission mode is read before the
306
+ temporary file is created and applied to it before any bytes are written, so
307
+ a curator-restricted playlist is never briefly world-readable mid-write. The
308
+ destination's owner and group are carried across too, since a replacement is
309
+ a new inode and would otherwise take this process's ownership — silently
310
+ reassigning a shared playlist. Where that cannot be done, the in-place
311
+ replacement is refused and `--output` is offered instead. Extended ACLs are
312
+ not preserved, because Node exposes no portable way to read them; a playlist
313
+ carrying them should be enriched through `--output`.
314
+ - `--output` names a file the caller expects to exist afterwards, so it is
315
+ written even when nothing was enriched. A no-op without `--output` writes
316
+ nothing rather than rewriting the input for no gain.
317
+ - The playlist is validated before lookup and the enriched candidate is
318
+ validated before writing. On either failure the diagnostics are printed and
319
+ the file is left untouched, rather than replaced with a document that sign,
320
+ publish, and play would each reject later.
321
+
200
322
  ## Deterministic-first behavior
201
323
 
202
324
  The CLI is deterministic end to end. There is no natural-language interface; any natural-language layer lives in the user's coding agent, which drives these commands:
package/docs/README.md CHANGED
@@ -36,7 +36,8 @@ ff-cli config validate
36
36
  {
37
37
  "defaultDuration": 10,
38
38
  "playlist": {
39
- "privateKey": "your_ed25519_private_key_hex_or_base64_here"
39
+ "privateKey": "your_ed25519_private_key_hex_or_base64_here",
40
+ "curatorName": "YOUR_CURATOR_NAME"
40
41
  },
41
42
  "feed": { "baseURLs": ["https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"] },
42
43
  "ff1Devices": {
@@ -104,6 +105,9 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1
104
105
  - OpenSea forms: `/assets/ethereum/{contract}/{tokenId}`, `/item/ethereum/{contract}/{tokenId}`, and `/collection/{slug}` (Ethereum collections; the slug is resolved from the public collection page, no API key needed)
105
106
  - Verse forms: `/items/ethereum/{contract}/{tokenId}` and `/series/{slug}`
106
107
  - Options: `-o, --output <path>`, `-l, --limit <n>`, `-p, --play`, `-d, --device <name>`, `--publish`, `-s, --server <index>`, `-y, --yes`, `--skip-verify`
108
+ - `enrich <file>` – Add missing artist, title, and thumbnail metadata to a playlist that already exists. Resolves each item by its `provenance.contract` and writes an inline Ref Manifest; `source`, `duration`, `title`, and `id` are never touched. Drops the signature when it changes anything, so re-sign afterwards
109
+ - DP-1 `evm` names a chain family rather than a network, so those items are skipped unless `--assume-ethereum` asserts which one they are. Getting that wrong attaches another artwork's metadata
110
+ - Options: `-o, --output <file>`, `--force` (replace existing manifests), `--assume-ethereum`, `-v, --verbose`
107
111
  - `publish <file>` – Publish a playlist to a feed server (runs `verify` before upload and rejects unsigned or broken playlists)
108
112
  - Options: `-s, --server <index>` (server index if multiple configured)
109
113
  - `ssh <enable|disable>` – Manage SSH access on an FF1 device
@@ -176,6 +180,41 @@ cat params.json | ff-cli build -o playlist.json
176
180
  }
177
181
  ```
178
182
 
183
+ ### Enrich a playlist that came from somewhere else
184
+
185
+ `find` and `build` label every item they create. A playlist assembled by hand,
186
+ copied from another document, or written before inline manifests existed has no
187
+ `inlineManifest`, and on a device that shows as a tombstone with no artist line
188
+ and an empty tile in the app's grid — the app can draw a thumbnail from an
189
+ image, video, or SVG source, but not from a live HTML work.
190
+
191
+ `ff-cli enrich` fills that in from the indexer, keyed on each item's
192
+ `provenance.contract`:
193
+
194
+ ```bash
195
+ ff-cli enrich playlist.json --assume-ethereum
196
+ ff-cli enrich playlist.json -o labelled.json
197
+ ff-cli enrich playlist.json --force # replace existing manifests
198
+ ```
199
+
200
+ `--assume-ethereum` is needed for most playlists. DP-1 records an EVM
201
+ coordinate as `chain: "evm"`, which names Ethereum, Polygon, Arbitrum,
202
+ Optimism, Base and Zora alike, and the indexer's answer collapses back to the
203
+ same word. Enrichment will not pick one for you, because the wrong member
204
+ returns a different artwork's artist and still — into a document you then sign.
205
+
206
+ It writes `inlineManifest` and nothing else. Items with no
207
+ `provenance.contract` chain, address, and token id cannot be looked up and are
208
+ reported rather than guessed at, and items carrying an external `ref` are left
209
+ alone because `ref` outranks an inline manifest at the device. The playlist is
210
+ validated before and after; on failure the file is left untouched. Enriching
211
+ changes the document, so the signature is removed and the playlist must be
212
+ re-signed before playing:
213
+
214
+ ```bash
215
+ ff-cli enrich playlist.json && ff-cli sign playlist.json
216
+ ```
217
+
179
218
  ### Validate, sign, and play
180
219
 
181
220
  ```bash
@@ -240,16 +279,24 @@ The `publish` command:
240
279
 
241
280
  Configure feed servers in `config.json`:
242
281
 
282
+ Publishing needs no credentials. The feed authorizes a create from the signatures inside the playlist:
283
+ it requires a signature whose `kid` matches a key the document declares in `curators[]`.
284
+
285
+ Declare it before signing, not after: a signature covers `curators[]`, so adding the entry to an
286
+ already-signed playlist invalidates it, and signing again appends rather than replaces. Run
287
+ `ff-cli status` to read the `did:key` your signatures will carry, put it in `curators[]`, then sign
288
+ once. If you sign with `sign --key <privateKey>`, read that key's identity with
289
+ `ff-cli status --key <privateKey>` — the configured identity would be the wrong one to declare. `find` and `build` do this for you when a signing key is configured. An `apiKey` left over in
290
+ an existing config is ignored.
291
+
243
292
  ```json
244
293
  {
245
294
  "feedServers": [
246
295
  {
247
- "baseUrl": "http://localhost:8787/api/v1",
248
- "apiKey": "your-api-key-optional"
296
+ "baseUrl": "http://localhost:8787/api/v1"
249
297
  },
250
298
  {
251
- "baseUrl": "https://feed.example.com/api/v1",
252
- "apiKey": "your-api-key-optional"
299
+ "baseUrl": "https://feed.example.com/api/v1"
253
300
  }
254
301
  ]
255
302
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",