@feralfile/cli 2.8.0 → 2.9.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.
package/docs/EXAMPLES.md CHANGED
@@ -546,8 +546,9 @@ documents you sign by hand, where `playlist.role` (default `agent`) decides the
546
546
  ## Replace or Delete a Published Playlist
547
547
 
548
548
  A feed's `PUT` and `DELETE` are **owner-bound**, and neither accepts an API key. Both carry a short-lived
549
- signed **intent** — `ff-cli` builds it, signs it with the configured key in the `curator` role, and sends
550
- it alongside (replace) or as (delete) the request body. Only a key the **stored** playlist names in
549
+ signed **intent** — `ff-cli` builds it, signs it in the `curator` role with the configured
550
+ `playlist.privateKey` (or with `-k, --key` when you name one for that command), and sends it alongside
551
+ (replace) or as (delete) the request body. Only a key the **stored** playlist names in
551
552
  `curators[]` can authorize either. This is the whole reason `publish` refuses a document with no
552
553
  owner-role signature: once such a playlist is created it can never be replaced or deleted.
553
554
 
@@ -588,6 +589,14 @@ If you edited this playlist after it was signed, the existing signatures no long
588
589
  Signing fresh drops the feed's entry along with the curator's, which is correct: it covers the pre-edit
589
590
  content too, and the feed appends a new one of its own after it verifies the replacement.
590
591
 
592
+ **On a co-curated playlist this drops the other curators' signatures too.** The command verifies every
593
+ discarded entry against the document as it now stands and names each one, so you can see whose
594
+ signatures will not be on what you publish. Entries that still verify — all of them, if you have not
595
+ actually edited anything — are reported as removed; keep the previous file if you want them back.
596
+ Entries that do not verify are reported as exactly that, with no explanation attached: this command sees
597
+ only the document as it stands, so it cannot tell a changed document from a signature that was never
598
+ valid.
599
+
591
600
  ```
592
601
  $ ff-cli sign playlist.json -r curator --replace-signatures
593
602
 
@@ -596,10 +605,130 @@ Sign playlist
596
605
  ✓ Playlist signed and saved to: /path/to/playlist.json
597
606
 
598
607
  Playlist signed
599
- Replaced 2 existing signatures
608
+ Replaced 3 existing signatures:
609
+ - a signature claiming your key (curator, ...Dy8sokwC) — removed; could not be verified against this document
610
+ - another key's signature (curator, ...L5ufrpW3) — removed; could not be verified against this document
611
+ - another key's signature (feed, ...1Rq7Lzik) — removed; could not be verified against this document
612
+ 3 other signatures could not be verified against this document:
613
+ ...Dy8sokwC (curator) — claims your key, unverified
614
+ ...L5ufrpW3 (curator)
615
+ ...1Rq7Lzik (feed)
616
+ That is consistent with the content having changed since they were made, and
617
+ equally with their never having been valid — this command only has the document
618
+ as it stands, so it cannot tell which. If you want those signatures on what you
619
+ publish, their holders have to sign this document; signing appends, so they can
620
+ add to this file without disturbing yours.
621
+ A feed appends its own signature again after it verifies a replacement.
622
+ Signatures: 1
623
+ ```
624
+
625
+ **Your own earlier entry is in that list, and named as a claim rather than as yours.** It was made over
626
+ the content before the edit, so it no longer verifies — and an entry that does not verify has not
627
+ established whose it is. Reading a `kid` as proof of authorship is how a forged entry would hide behind
628
+ your identity, so the command says what it checked: this signature claims your key, and could not be
629
+ confirmed. On an unedited document the same entry verifies and is reported plainly as replaced.
630
+
631
+ Four things that report deliberately does **not** say.
632
+
633
+ It does not call an entry *void*. That would assert it verified against the previous content and no
634
+ longer does — and the previous content was edited in place, so it exists nowhere by the time `sign`
635
+ runs. A failed verification is equally consistent with an edit, with a tampered entry, and with one
636
+ that was never valid; the command reports what it checked and offers both explanations.
637
+
638
+ It does not treat `role: "feed"` as the feed's own. Any key can emit that role and the CLI holds no
639
+ feed identity to check a `kid` against. That a feed re-appends its signature after verifying a
640
+ replacement is a general fact, printed as one, not a claim about the entry above it.
641
+
642
+ It does not rewrite anyone's role. `agent`, `institution` and `licensor` are valid DP-1 roles, so each
643
+ entry is named with the role it actually carries.
644
+
645
+ It does not credit an unverified entry to the key it names — not even to yours. Attribution follows
646
+ verification, never the other way round.
647
+
648
+ A flat legacy `signature` gets its own line, because it carries no `kid` and no `role` and the
649
+ multi-signature verifier has nothing to check it with:
650
+
651
+ ```
652
+ - a legacy flat signature (no kid, no role) — removed; not checkable here
653
+ A legacy flat signature carries no kid or role, so nothing here can judge it.
654
+ ```
655
+
656
+ Running the flag on a document you have **not** edited is a different situation, because nothing was
657
+ invalidated — the signing payload excludes `signatures`, so the discarded entries still cover the
658
+ content. **If any of them belongs to another key, an in-place run refuses:**
659
+
660
+ ```
661
+ $ ff-cli sign playlist.json -r curator --replace-signatures
662
+
663
+ Sign playlist
664
+
665
+ Sign failed: This would discard 1 still-valid signature from other keys. Write the result elsewhere so the original stays:
666
+ ff-cli sign playlist.json -r curator --replace-signatures --output <new file>
667
+ ```
668
+
669
+ Only its holder could make that signature again, so overwriting the one file that carries it costs
670
+ something this command cannot restore. Writing elsewhere keeps the original exactly where it is:
671
+
672
+ ```
673
+ $ ff-cli sign playlist.json -r curator --replace-signatures -o out.json
674
+
675
+ Sign playlist
676
+
677
+ ✓ Playlist signed and saved to: /path/to/out.json
678
+
679
+ Playlist signed
680
+ Replaced 2 existing signatures:
681
+ - your own earlier signature (curator, ...XppfYVjB) — replaced by this signing
682
+ - another key's signature (curator, ...UNFmeU8D) — removed; still valid over this content
683
+ 1 other signature still verified over this content and was removed anyway.
684
+ Your input file is untouched, so it remains valid there.
685
+ A feed appends its own signature again after it verifies a replacement.
600
686
  Signatures: 1
601
687
  ```
602
688
 
689
+ The refusal is narrow: it is about what cannot be recovered, not about how many signatures go. An
690
+ in-place run still proceeds when the only entries dropped are **your own** — sign again to add any of
691
+ them back — or ones that **no longer verify**, which the input could not restore either.
692
+
693
+ **`sign` overwrites only a document it has read.** Before writing, it reads the destination back and
694
+ compares it with the document being signed. Equal means this is that document, wherever the name now
695
+ points, and the rule above applies to it. Different means the destination holds something never
696
+ inspected, and the run is refused:
697
+
698
+ ```
699
+ $ ff-cli sign playlist.json -r curator --replace-signatures -o notes.json
700
+
701
+ Sign failed: notes.json already exists and is not the playlist being signed; choose a new name, or pass --force to overwrite it.
702
+ ```
703
+
704
+ `--force` allows it, and the report names what it replaced. This is a content rule, not an identity
705
+ one: no comparison of paths or inodes can promise that the file about to be truncated is the file that
706
+ was read, because a name can be re-pointed between any two system calls — but comparing the bytes
707
+ through the descriptor being written can. A destination that already exists needs read permission for
708
+ that reason.
709
+
710
+ **It narrows the race; it does not close it.** The read and the overwrite are two operations, and an
711
+ editor writing in the same instant can still lose its change — every tool that edits a file in place
712
+ carries this, and nothing in userspace removes it portably. A fresh `--output` name is the write that
713
+ cannot collide, because nothing is there to lose.
714
+
715
+ If the destination turns out to hold the **same** document as the input — a copy taken with `cp`, say —
716
+ it is treated exactly as an in-place run, because without file identity a copy and a second name for the
717
+ input are the same thing. So the same narrow rule decides it: the run is refused **only when it would
718
+ discard a still-valid signature from another key**. That is the whole of what the refusal protects, and
719
+ a copy carrying only your own signatures, or only ones that no longer verify, is simply written — the
720
+ input is a different file and is not touched either way.
721
+
722
+ When it does refuse, `--force` deliberately does **not** override it: force is for replacing a different
723
+ document, never for destroying a signature only its holder could make again. Delete the copy and re-run,
724
+ or pick another `--output` name; a name that does not exist is written with no check at all.
725
+
726
+ The rule holds on every platform and assumes nothing about the filesystem. An earlier version kept a
727
+ copy of the input instead; that requires reproducing the source's access, which is not portable — POSIX
728
+ ACLs grant what mode bits do not describe, macOS extended ACLs are not constrained by the mask, and
729
+ Windows mode bits constrain nothing — so every implementation of it was a way for the copy to disclose
730
+ the document. Refusing needs none of that.
731
+
603
732
  Appending stays the default, because it is right whenever the content has not changed — a second curator
604
733
  co-signing an unedited playlist keeps the first endorsement, and the payload hash excludes `signatures`
605
734
  so the earlier entry stays valid.
@@ -657,6 +786,42 @@ Without `-y`, `unpublish` shows the title and the server and asks, defaulting to
657
786
  tombstones the id: the playlist cannot be restored, and a later publish naming that id is refused. Build
658
787
  a new playlist instead of trying to recreate it.
659
788
 
789
+ ### Signing with a key other than the configured one
790
+
791
+ The configured `playlist.privateKey` is the default, not the only option. `unpublish` and
792
+ `publish --replace` take `-k, --key`, like `ff-cli sign` and `ff-cli status`, and it overrides that
793
+ default both for the intent signature and for the local ownership check — so holding a second owner key
794
+ no longer means editing `config.json`:
795
+
796
+ ```bash
797
+ ff-cli unpublish <id> -s 0 --key <private key for a stored owner>
798
+ ff-cli publish playlist.json --replace -s 0 --key <private key for a stored owner>
799
+ ```
800
+
801
+ `ff-cli status --key <private key>` reports which identity a key carries, which is how you check it
802
+ against the `Keys that have proved ownership:` list in a refusal. `unpublish` also prints `Signing as:`, both in its
803
+ confirmation and under `-y`, so the identity that performs an irreversible delete is always in the
804
+ record. It resolves that key once, before the lookup and before the prompt, and signs with the same
805
+ material — editing `config.json` while a confirmation is open cannot change who the delete runs as.
806
+
807
+ A refusal names the key you actually used. If it came from `--key`, the retry says `--key`; it will not
808
+ send you to `playlist.privateKey` for a run that never read it.
809
+
810
+ An empty `--key` is **rejected**, not ignored:
811
+
812
+ ```
813
+ $ ff-cli unpublish <id> -y --key "$SIGNING_KEY" # variable unset
814
+
815
+ Cannot sign the delete
816
+ The --key value is empty. This usually means a shell variable did not expand
817
+ (for example --key "$SIGNING_KEY" with SIGNING_KEY unset). Refusing rather than falling
818
+ back to the configured key: a signature made by the wrong identity is not something a
819
+ delete can be taken back from.
820
+ ```
821
+
822
+ A plain `publish` **refuses** `--key` rather than ignoring it: it signs nothing at request time, so the
823
+ flag would do nothing. `ff-cli fetch` has no `--key` for the same reason — it is a read.
824
+
660
825
  ### When ownership cannot be proved
661
826
 
662
827
  A key counts as an owner only when the **stored** playlist names it in `curators[]` **and** carries its
@@ -700,17 +865,23 @@ Unpublish playlist
700
865
  Unpublish failed
701
866
  The configured signing key is not an owner of this playlist, so it cannot delete it.
702
867
 
703
- Only a key the stored playlist names in curators[] can authorize a delete; the feed derives
704
- ownership from the stored document, not from a local copy.
868
+ Only a key the stored playlist names in curators[] AND that signed it as "curator" can
869
+ authorize a delete; the feed derives ownership from the stored document, not from a local copy.
705
870
  Your configured identity:
706
871
  did:key:z6MkoX8i2dynyvLh4hUHZt8b42q9uAwwCWxM4NSX4YDfMtaC
707
- Stored owners:
872
+ Keys that have proved ownership:
708
873
  did:key:z6MkoDkq5YXsFGXPiD6HDUVfze5mvhU5QF4hTy59pVVPYg82
709
- Point playlist.privateKey at a key listed above (confirm any key's identity with
710
- "ff-cli status --key <private key>"). Ownership cannot be granted after the fact: the owner set
711
- is immutable, so a playlist signed by the wrong key stays that way.
874
+ Point playlist.privateKey at the key listed above, or pass one for this run with --key (confirm
875
+ any key's identity with "ff-cli status --key <private key>").
876
+ Ownership cannot be granted after the fact: the owner set is immutable, so a playlist signed
877
+ by the wrong key stays that way.
712
878
  ```
713
879
 
880
+ That run used the configured key, so the refusal names it as configured. Had it been given
881
+ `--key`, the same refusal would name that key instead and the retry line would read
882
+ `Run it again with an owner key: --key <private key for the key listed above>` — a refusal always
883
+ points at the key the command actually used, never at a config file the run never read.
884
+
714
885
  Ownership cannot be granted after the fact — the owner set is immutable, and only an owner could change
715
886
  it — so the only fix is to hold a declared key. A playlist whose stored `curators[]` is **empty** is a
716
887
  harder case with the same shape: nobody owns it, no signature can ever authorize a write, and `ff-cli`
@@ -191,8 +191,9 @@ accepts an API key. Both therefore carry a signed intent — `{ action, target:
191
191
  created, signatures }`, with a `payloadHash` for replace — whose `created` must fall inside the feed's
192
192
  freshness window; the intent exists because a document's own signatures are public via `GET` and could
193
193
  otherwise be replayed to roll a resource back. The CLI proves ownership before it signs anything: it
194
- `GET`s the stored playlist and requires the configured key to be both named in the stored `curators[]`
195
- and the signer of a cryptographically valid `curator`-role signature over that stored document. Being
194
+ `GET`s the stored playlist and requires the signing key — the configured `playlist.privateKey` unless
195
+ `-k, --key` overrides it for that command — to be both named in the stored `curators[]` and the signer of
196
+ a cryptographically valid `curator`-role signature over that stored document. Being
196
197
  named is a claim; the owner-role signature is the proof, and checking only the claim is what would let a
197
198
  legacy `agent`-signed document pass preflight and come back as a `403` reported as a missing
198
199
  declaration — the one thing not wrong with it. The three local refusals are therefore distinct: no
@@ -203,6 +204,35 @@ create a document whose declared curator did not sign in the `curator` role: tha
203
204
  recoverable and the resulting stored playlist would not be. A delete tombstones the id, so the operation
204
205
  is final and the id is not reusable.
205
206
 
207
+ A `--replace-signatures` run writing over its own input refuses when it would discard a still-valid
208
+ signature from another key. Only that key's holder could produce it again, so overwriting the one file
209
+ that carries it destroys something the command cannot restore; `--output` writes the result elsewhere
210
+ and leaves the original in place. The refusal is scoped to what is unrecoverable — an in-place run still
211
+ proceeds when the entries dropped are the signer's own, or no longer verify.
212
+
213
+ Refusal rather than a preserved copy is deliberate. A copy has to reproduce the source's access, and no
214
+ portable means of doing so exists: POSIX ACLs grant what the mode bits do not describe, macOS extended
215
+ ACLs are not constrained by the mask, and Windows mode bits constrain nothing at all. Every version of
216
+ that copy was a way to disclose the document. The refusal assumes nothing about the filesystem, so the
217
+ invariant — a still-valid endorsement is never overwritten in place — holds identically everywhere.
218
+
219
+ Which document a destination holds is decided by content, not by file identity. The destination is read
220
+ back through the descriptor that will write it and compared with the document being signed; a
221
+ destination holding anything else is refused unless `--force` says otherwise. Identity cannot answer
222
+ this — a name can be re-pointed between any two system calls, so no comparison of paths or inodes
223
+ describes what a later write will land on — while the bytes behind a descriptor can. It narrows the
224
+ race and does not close it: the read and the overwrite are two operations, and an editor writing in the
225
+ same instant can still lose its change, as with any tool that edits in place. A `--output` name that
226
+ does not yet exist is the write that cannot collide.
227
+
228
+ A destination holding the *same* document as the input is treated as an in-place run, since without
229
+ identity a copy and a second name for the input are indistinguishable — and so the same narrow rule
230
+ applies: it is refused only when the run would discard a still-valid signature from another key. A copy
231
+ carrying only the signer's own entries, or only unverifiable ones, is written like any other
232
+ destination; the input is a separate file and is untouched. Where the refusal does fire, `--force` does
233
+ not reach it by design: force authorizes replacing a different document, never destroying a signature
234
+ only its holder could reproduce.
235
+
206
236
  Editing a published playlist therefore has a required shape: `fetch` the stored document, change it,
207
237
  re-sign with `sign --replace-signatures`, then `publish --replace`. `fetch` exists because every other
208
238
  way of producing that starting file is wrong: `find` and `build` mint a new identity, and `verify` only
package/docs/README.md CHANGED
@@ -97,8 +97,11 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1
97
97
  - `validate <file-or-url>` – Validate playlist structure only
98
98
  - `verify <file-or-url>` – Validate structure and verify signatures. On failure, the CLI labels structure issues separately from signature verification. dp1-js uses `--public-key` (or a key derived from `playlist.privateKey` / `PLAYLIST_PRIVATE_KEY` when omitted) **only** for legacy flat `signature` verification; DP-1 v1.1.0 `signatures[]` envelopes are verified without relying on that argument. If deriving or normalizing key material fails, the CLI prints a warning on stderr and continues without it (legacy verification still requires a usable key when the playlist uses a flat `signature`). The derived key is emitted as PEM. Supported key forms: hex with optional `0x`, PEM, or 32-byte raw public key as hex or base64
99
99
  - `sign <file>` – Sign playlist with a DP-1 v1.1.0 multi-signature envelope (private key string is forwarded to **`dp1-js`**; same hex or base64 PKCS#8 DER forms as `playlist.privateKey` in `./CONFIGURATION.md`). The command verifies the final envelope before writing output and refuses to persist tampered or otherwise unverifiable `signatures[]`.
100
- - Options: `-k, --key <privateKey>`, `-r, --role <role>`, `-o, --output <file>`, `--replace-signatures`
100
+ - Options: `-k, --key <privateKey>`, `-r, --role <role>`, `-o, --output <file>`, `--replace-signatures`, `--force`
101
+ - **It overwrites only a document it has read.** Before writing, the destination is read back and compared with the document being signed; if it holds something else, the run is refused rather than destroying a file the command never inspected. `--force` allows that overwrite, and the report names the file it replaced. A destination holding the *same* document — a copy of the input — is treated as an in-place run, so it is refused only when it would discard a still-valid signature from another key, and `--force` does not override that one. The read and the overwrite are not atomic, so an editor writing in the same instant can still lose its change, as with any in-place tool — a fresh `--output` name is the write that cannot collide. A destination that already exists needs read permission
101
102
  - Signing **appends** by default, so repeated runs accumulate endorsements. That is only correct while the signed content is unchanged: editing a signed playlist moves bytes the earlier signatures cover, and the envelope check then refuses to write the result. `--replace-signatures` discards every existing entry — including a feed's own co-signature — and signs the document as it stands. It is the required step before `publish --replace`
103
+ - **An in-place run refuses when it would discard a still-valid signature from another key**, since only its holder could make that signature again: write the result elsewhere with `-o, --output`, which leaves the original where it is. The refusal is about what cannot be recovered — an in-place run still proceeds when the only entries dropped are your own, or ones that no longer verify
104
+ - `--replace-signatures` names each discarded entry by role and the last 8 characters of its `kid`, and verifies it against the document as it now stands. Your own earlier signature is replaced by the same run. Another key's entry that **still verifies** — which is every entry when the content was not edited, since the signing payload excludes `signatures` — is reported as removed; keep the previous file if you want it back. An entry that does **not** verify is reported as exactly that and no more: the command has only the document as it stands, so it cannot tell an edit from an entry that was never valid, and it does not guess. Roles are carried through as found (`agent`, `institution` and `licensor` are as valid as `curator`), and a flat legacy `signature` is reported as not checkable, since it carries no key or role at all
102
105
  - `play <source>` – Play a playlist file, playlist URL, or media URL on an FF1 device (runs `verify` before sending; only the CLI-synthesized media URL fallback is auto-signed when a signing key is configured; use `--skip-verify` to bypass the gate)
103
106
  - Options: `-d, --device <name>`, `--skip-verify` (skip signature verification; not recommended)
104
107
  - `find <input>` – Resolve a marketplace URL, raw `chain:contract:tokenId`, or wallet address into a playable DP-1 playlist
@@ -110,11 +113,12 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1
110
113
  - 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
111
114
  - Options: `-o, --output <file>`, `--force` (replace existing manifests), `--assume-ethereum`, `-v, --verbose`
112
115
  - `publish <file>` – Publish a playlist to a feed server (runs `verify` before upload and rejects unsigned or broken playlists)
113
- - Options: `-s, --server <index>` (server index if multiple configured), `--replace` (replace the playlist already stored under this document id instead of creating a new one)
116
+ - Options: `-s, --server <index>` (server index if multiple configured), `--replace` (replace the playlist already stored under this document id instead of creating a new one), `-k, --key <privateKey>` (signs the replace authorization; `--replace` only)
117
+ - `--key` is refused on a plain `publish`, which signs nothing at request time — it uploads the `signatures[]` envelope the document already carries. Sign the file with `ff-cli sign --key` instead
114
118
  - `fetch <id-or-url>` – Save a published playlist from a feed server. Accepts a playlist id, a slug, or a feed URL. The document goes to `-o` when given and to stdout otherwise (status lines always go to stderr, so `ff-cli fetch <id> > playlist.json` works). This is the starting point for `publish --replace`, which requires the stored `id`, `slug`, and `created`
115
- - Options: `-s, --server <index>`, `-o, --output <file>`
116
- - `unpublish <id-or-url>` – Delete a playlist from a feed server. Accepts a playlist id, a slug, or a feed URL. Requires the configured key to be an owner of the stored playlist, and confirms before deleting
117
- - Options: `-s, --server <index>`, `-y, --yes` (skip the confirmation)
119
+ - Options: `-s, --server <index>`, `-o, --output <file>`. There is no `--key`: a fetch is a plain read and signs nothing
120
+ - `unpublish <id-or-url>` – Delete a playlist from a feed server. Accepts a playlist id, a slug, or a feed URL. Requires the signing key to be an owner of the stored playlist, and confirms before deleting
121
+ - Options: `-s, --server <index>`, `-y, --yes` (skip the confirmation), `-k, --key <privateKey>` (signs the delete authorization)
118
122
  - `ssh <enable|disable>` – Manage SSH access on an FF1 device
119
123
  - Options: `-d, --device <name>`, `--pubkey <path>`, `--ttl <duration>`
120
124
  - `device list` – List all configured FF1 devices
@@ -331,8 +335,8 @@ is configured. An `apiKey` left over in an existing config is ignored.
331
335
  ### Change or remove a published playlist
332
336
 
333
337
  A feed's `PUT` and `DELETE` are owner-bound. Neither takes an API key, and neither is authorized by the
334
- document alone: both carry a short-lived **intent** that ff-cli signs with the configured key in the
335
- `curator` role. Only a key the *stored* playlist names in `curators[]` can authorize either one, which is
338
+ document alone: both carry a short-lived **intent** that ff-cli signs in the `curator` role — with the
339
+ configured `playlist.privateKey` by default, or with whatever `-k, --key` names for that one command. Only a key the *stored* playlist names in `curators[]` can authorize either one, which is
336
340
  why `publish` refuses to create a playlist that carries no owner-role signature — such a document can be
337
341
  neither replaced nor deleted, ever.
338
342
 
@@ -359,6 +363,17 @@ you published — re-running `find` or `build` mints a fresh id, slug, and `crea
359
363
  playlist rather than a replacement. A `publish` without `--replace` is never silently upgraded to a
360
364
  replace; an id the feed already holds fails with a conflict, as before.
361
365
 
366
+ The configured `playlist.privateKey` is only the default. `-k, --key` overrides it on both commands —
367
+ for the intent signature and for the local ownership check alike — the way `ff-cli sign --key` and
368
+ `ff-cli status --key` already do. A `--key` that is
369
+ present but empty (what `--key "$SIGNING_KEY"` becomes when the variable is unset) is **rejected**, never
370
+ treated as absent: falling back to the configured key would authorize the write under an identity you did
371
+ not choose, and a delete cannot be taken back. `unpublish` derives the identity before it looks the
372
+ playlist up, so a bad credential fails before you are asked to confirm anything, and it prints
373
+ `Signing as:` so the identity is visible in the record either way. The configured key is one identity;
374
+ the key that owns a given playlist may be another, and editing `config.json` to delete something you own
375
+ is not a workflow.
376
+
362
377
  Both verbs prove ownership locally before signing anything. A key counts as an owner only when the
363
378
  **stored** playlist names it in `curators[]` **and** carries its valid `curator`-role signature — being
364
379
  named is a claim, signing in the owner role is the proof. A playlist that declares a curator but was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.8.0",
3
+ "version": "2.9.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",