@feralfile/cli 2.7.0 → 2.8.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.
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ /**
3
+ * Deletes a playlist from a DP-1 feed.
4
+ *
5
+ * `curl -X DELETE` alone answers `401`: the feed has no API key, and a delete is authorized by a signed
6
+ * **delete-intent** in the request body (dp1-feed-v2 `docs/api_design.md`, "DELETE — owner-bound, signed
7
+ * delete-intent"). The intent must name the stored `id` and `slug` exactly, carry a `created` inside the
8
+ * server's freshness window, and be signed by a key the stored playlist names as an owner.
9
+ *
10
+ * The delete is permanent in a stronger sense than most: it tombstones the id, and a later create naming
11
+ * that id is refused. There is no undo, which is why the command layer confirms before calling this.
12
+ */
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.storedOwnerKeys = void 0;
18
+ exports.fetchPlaylistForUnpublish = fetchPlaylistForUnpublish;
19
+ exports.unpublishPlaylist = unpublishPlaylist;
20
+ const axios_1 = __importDefault(require("axios"));
21
+ const feed_mutation_1 = require("./feed-mutation");
22
+ Object.defineProperty(exports, "storedOwnerKeys", { enumerable: true, get: function () { return feed_mutation_1.storedOwnerKeys; } });
23
+ const signing_identity_1 = require("./signing-identity");
24
+ const config_1 = require("../config");
25
+ /**
26
+ * Look up a playlist on a feed without deleting it.
27
+ *
28
+ * Exposed so the command layer can show the operator what they are about to destroy — and so the
29
+ * ownership refusal happens before any confirmation prompt, rather than after they have already said yes.
30
+ */
31
+ async function fetchPlaylistForUnpublish(idOrUrl, feedServerUrl) {
32
+ return (0, feed_mutation_1.fetchStoredPlaylist)(feedServerUrl, (0, feed_mutation_1.resolvePlaylistIdentifier)(idOrUrl));
33
+ }
34
+ /**
35
+ * Delete a playlist from a feed server.
36
+ *
37
+ * Flow:
38
+ * 1. Resolve the id or URL the user supplied to a feed path segment.
39
+ * 2. `GET` the stored playlist — the intent must carry the stored id and slug, and only the stored
40
+ * document says who owns it.
41
+ * 3. Refuse locally when the configured key is not a stored owner.
42
+ * 4. Build and sign the delete-intent in the `curator` role.
43
+ * 5. `DELETE` with the intent as the body and report the outcome.
44
+ *
45
+ * @param idOrUrl - Playlist id, slug, or a feed URL ending in either
46
+ * @param feedServerUrl - Feed server base URL, including `/api/v1`
47
+ * @param options - Optional signing-key override
48
+ * @returns Result carrying the deleted playlist's identity, or a diagnosis and a remedy
49
+ * @example
50
+ * const result = await unpublishPlaylist('9f...c1', 'https://feed.example.com/api/v1');
51
+ * if (!result.success) console.error(result.error);
52
+ */
53
+ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
54
+ const identifier = (0, feed_mutation_1.resolvePlaylistIdentifier)(idOrUrl);
55
+ if (!identifier) {
56
+ return { success: false, error: 'No playlist id or URL was given' };
57
+ }
58
+ let privateKey;
59
+ try {
60
+ privateKey = resolveSigningKey(options.privateKey);
61
+ }
62
+ catch (error) {
63
+ return { success: false, error: error.message };
64
+ }
65
+ let signerDidKey;
66
+ try {
67
+ signerDidKey = (0, signing_identity_1.playlistSigningDidKey)(privateKey);
68
+ }
69
+ catch (error) {
70
+ return { success: false, error: error.message };
71
+ }
72
+ let stored;
73
+ try {
74
+ stored = await (0, feed_mutation_1.fetchStoredPlaylist)(feedServerUrl, identifier);
75
+ }
76
+ catch (error) {
77
+ return {
78
+ success: false,
79
+ ...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete'),
80
+ feedServer: feedServerUrl,
81
+ };
82
+ }
83
+ const id = typeof stored.id === 'string' ? stored.id : '';
84
+ const slug = typeof stored.slug === 'string' ? stored.slug : '';
85
+ if (!id || !slug) {
86
+ // Both are stored verbatim from the signed document, so a feed serving one without the other is
87
+ // serving something this envelope cannot target. Failing here beats sending a body the feed will
88
+ // reject as a target mismatch, which reads like the operator's mistake.
89
+ return {
90
+ success: false,
91
+ error: 'The stored playlist is missing an id or slug, so a delete intent cannot target it.',
92
+ feedServer: feedServerUrl,
93
+ };
94
+ }
95
+ const refusal = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'delete');
96
+ if (refusal) {
97
+ return { success: false, ...refusal, feedServer: feedServerUrl };
98
+ }
99
+ const created = (0, feed_mutation_1.intentTimestamp)();
100
+ const intent = (0, feed_mutation_1.buildDeleteIntent)(id, slug, created);
101
+ let body;
102
+ try {
103
+ const signature = await (0, feed_mutation_1.signIntent)(intent, privateKey);
104
+ body = { ...intent, signatures: [signature] };
105
+ }
106
+ catch (error) {
107
+ return {
108
+ success: false,
109
+ error: `Failed to sign the delete intent: ${error.message}`,
110
+ };
111
+ }
112
+ try {
113
+ // axios sends a body on DELETE only when `data` is given explicitly; the feed requires one.
114
+ const response = await axios_1.default.delete(`${feedServerUrl}/playlists/${encodeURIComponent(id)}`, {
115
+ data: body,
116
+ headers: { 'Content-Type': 'application/json' },
117
+ timeout: 30000,
118
+ });
119
+ if (response.status !== 204 && response.status !== 200) {
120
+ return {
121
+ success: false,
122
+ error: `Unexpected response status: ${response.status}`,
123
+ feedServer: feedServerUrl,
124
+ };
125
+ }
126
+ return {
127
+ success: true,
128
+ playlistId: id,
129
+ slug,
130
+ title: typeof stored.title === 'string' ? stored.title : undefined,
131
+ message: 'Deleted from feed server (the id is now tombstoned and cannot be reused)',
132
+ feedServer: feedServerUrl,
133
+ };
134
+ }
135
+ catch (error) {
136
+ return {
137
+ success: false,
138
+ ...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete'),
139
+ feedServer: feedServerUrl,
140
+ };
141
+ }
142
+ }
143
+ /**
144
+ * Resolve the signing key the same way every other signing path does.
145
+ *
146
+ * `getPlaylistConfig` already layers config.json over `PLAYLIST_PRIVATE_KEY` and screens out the sample
147
+ * placeholder, so going through it keeps `unpublish` signing as the identity `ff-cli status` reports.
148
+ */
149
+ function resolveSigningKey(override) {
150
+ if (override && override.trim().length > 0) {
151
+ return override;
152
+ }
153
+ const configured = (0, config_1.getPlaylistConfig)().privateKey;
154
+ if (!configured) {
155
+ throw new Error('No playlist signing key is configured. A delete is authorized by a signature, so one is ' +
156
+ 'required: run "ff-cli setup" or set playlist.privateKey in config.json.');
157
+ }
158
+ return configured;
159
+ }
@@ -133,7 +133,25 @@ DP‑1 Feed API configuration.
133
133
  Endpoints used by the CLI:
134
134
 
135
135
  - `GET /api/v1/playlists` (supports `limit`, `offset`, and sorting)
136
- - `GET /api/v1/playlists/{id}`
136
+ - `GET /api/v1/playlists/{id}` (`fetch`)
137
+ - `POST /api/v1/playlists` (`publish`)
138
+ - `PUT /api/v1/playlists/{id}` (`publish --replace`)
139
+ - `DELETE /api/v1/playlists/{id}` (`unpublish`)
140
+
141
+ No credential is configured for any of these. The feed has no API key: every write is authorized by
142
+ signatures in the request body, and an `apiKey` left in an existing config is ignored.
143
+
144
+ `playlist.privateKey` is needed only where the CLI itself has to sign at request time — `publish
145
+ --replace` and `unpublish`, which sign the owner-bound intent that authorizes the write. A plain
146
+ `publish` signs nothing: it uploads the `signatures[]` envelope the document already carries, so it
147
+ works with no key configured at all (the document must have been signed at some point, by
148
+ `ff-cli sign` or by whoever produced it, but not by this command).
149
+
150
+ **Choosing between several configured servers.** Commands that address a feed (`publish`, `unpublish`,
151
+ `fetch`, `find --publish`) take `-s, --server <index>`, a 0-based index into the list above. With one server
152
+ configured it is optional. With more than one and no `-s`, an interactive session asks; a session with no
153
+ terminal — a pipe, a cron job, a CI step — exits non-zero and prints the list rather than guessing, since
154
+ defaulting to index 0 would silently write to whichever feed happens to be first.
137
155
 
138
156
  Environment variable alternative:
139
157
 
package/docs/EXAMPLES.md CHANGED
@@ -347,10 +347,34 @@ npm run dev -- publish playlist.json
347
347
  # Direct: publish to specific server (server index 0)
348
348
  npm run dev -- publish playlist.json -s 0
349
349
 
350
+ # Replace a playlist already stored under this document id
351
+ npm run dev -- publish playlist.json --replace -s 0
352
+
350
353
  # Show help
351
354
  npm run dev -- publish --help
352
355
  ```
353
356
 
357
+ A bare Enter at the interactive prompt selects nothing either, and fails with the list. The prompt has no
358
+ default by design: it is being asked precisely because the CLI cannot tell which feed was meant, and
359
+ `Number('')` is `0` — the first server, usually production.
360
+
361
+ **With more than one server configured and no `-s`, a non-interactive session fails rather than
362
+ choosing.** Under a pipe, a cron job, or a CI step there is no terminal to answer the prompt, and
363
+ defaulting to the first server would write to production for a script that meant the other one:
364
+
365
+ ```
366
+ $ ff-cli publish playlist.json < /dev/null; echo "exit=$?"
367
+
368
+ Publish playlist
369
+
370
+ Multiple feed servers configured (2); pass --server <index>
371
+ 0: https://feed.feralfile.com/api/v1
372
+ 1: http://localhost:8787
373
+ exit=1
374
+ ```
375
+
376
+ The same rule applies to `unpublish`. With a single server configured, `-s` stays optional everywhere.
377
+
354
378
  ### Flow
355
379
 
356
380
  1. **Verify** - Playlist structure and signatures checked; unsigned or broken playlists are rejected
@@ -519,6 +543,181 @@ only way to add one is to replace the document. Publishing is what makes that pe
519
543
  `find` and `build` sign as `curator` automatically, since they declare the key themselves. This applies to
520
544
  documents you sign by hand, where `playlist.role` (default `agent`) decides the role.
521
545
 
546
+ ## Replace or Delete a Published Playlist
547
+
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
551
+ `curators[]` can authorize either. This is the whole reason `publish` refuses a document with no
552
+ owner-role signature: once such a playlist is created it can never be replaced or deleted.
553
+
554
+ ### Replace
555
+
556
+ ```bash
557
+ # 1. Start from the PUBLISHED document, not a rebuilt one.
558
+ ff-cli fetch <id> -o playlist.json -s 0
559
+
560
+ # 2. Edit it. `id`, `slug`, `created` and the curators[] owner set must all stay as published.
561
+
562
+ # 3. Re-sign FRESH. This flag is required here — see below.
563
+ ff-cli sign playlist.json -r curator --replace-signatures
564
+
565
+ # 4. Replace.
566
+ ff-cli publish playlist.json --replace -s 0
567
+ ```
568
+
569
+ **Step 3 needs `--replace-signatures`.** The document you fetched carries the curator's signature and
570
+ the feed's own, both taken over the content as published. Editing it moves bytes those signatures cover,
571
+ and `sign` **appends** rather than replaces — so a plain re-sign produces an envelope holding two stale
572
+ entries and one good one, which `sign` then refuses to write:
573
+
574
+ ```
575
+ $ ff-cli sign playlist.json -r curator
576
+
577
+ Sign playlist
578
+
579
+ Sign failed: Signed playlist verification failed: signed playlist is not verifiable
580
+
581
+ If you edited this playlist after it was signed, the existing signatures no longer
582
+ cover it, and signing again cannot repair them — signing appends. Sign fresh instead:
583
+ ff-cli sign playlist.json -r curator --replace-signatures
584
+ That discards every existing entry, including any feed signature, and signs the
585
+ document as it stands now. It is the path a feed replace expects.
586
+ ```
587
+
588
+ Signing fresh drops the feed's entry along with the curator's, which is correct: it covers the pre-edit
589
+ content too, and the feed appends a new one of its own after it verifies the replacement.
590
+
591
+ ```
592
+ $ ff-cli sign playlist.json -r curator --replace-signatures
593
+
594
+ Sign playlist
595
+
596
+ ✓ Playlist signed and saved to: /path/to/playlist.json
597
+
598
+ Playlist signed
599
+ Replaced 2 existing signatures
600
+ Signatures: 1
601
+ ```
602
+
603
+ Appending stays the default, because it is right whenever the content has not changed — a second curator
604
+ co-signing an unedited playlist keeps the first endorsement, and the payload hash excludes `signatures`
605
+ so the earlier entry stays valid.
606
+
607
+ ```
608
+ $ ff-cli publish playlist.json --replace -s 0
609
+
610
+ Replace playlist
611
+
612
+ Replaced
613
+ Playlist ID: 97595a2f-a790-477c-aa42-b4f2ec9f1e3b
614
+ Server: https://feed.feralfile.com/api/v1
615
+ Status: Replaced on feed server
616
+ ```
617
+
618
+ `--replace` is never applied on your behalf. A plain `publish` of an id the feed already holds still
619
+ fails with a conflict, because creating and overwriting are different intentions and only one of them is
620
+ recoverable.
621
+
622
+ Re-running `find` or `build` does **not** produce a replacement: each run mints a new `id`, `slug`, and
623
+ `created`, and the feed compares all three against the stored row. `ff-cli` checks them locally and names
624
+ the fields that moved, because the feed's own answer is a bare `400`:
625
+
626
+ ```
627
+ Replace failed
628
+ The document changes fields a replace may not change.
629
+
630
+ A replace keeps identity and ownership fixed: id, slug, and created must equal the stored
631
+ document's, and the curators[] owner set is immutable.
632
+ slug: stored "snowfro-send-receive", document "a-completely-new-slug"
633
+ created: stored "2026-09-07T23:32:39.660Z", document "2026-09-08T01:02:03.000Z"
634
+ ```
635
+
636
+ ### Delete
637
+
638
+ ```bash
639
+ # id, slug, or a feed URL — all resolve to the same playlist.
640
+ ff-cli unpublish 97595a2f-a790-477c-aa42-b4f2ec9f1e3b -s 0
641
+ ff-cli unpublish https://feed.feralfile.com/api/v1/playlists/97595a2f-a790-477c-aa42-b4f2ec9f1e3b -s 0
642
+ ```
643
+
644
+ ```
645
+ $ ff-cli unpublish 97595a2f-a790-477c-aa42-b4f2ec9f1e3b -s 0 -y
646
+
647
+ Unpublish playlist
648
+
649
+ Unpublished
650
+ Playlist ID: 97595a2f-a790-477c-aa42-b4f2ec9f1e3b
651
+ Slug: snowfro-send-receive
652
+ Server: https://feed.feralfile.com/api/v1
653
+ Status: Deleted from feed server (the id is now tombstoned and cannot be reused)
654
+ ```
655
+
656
+ Without `-y`, `unpublish` shows the title and the server and asks, defaulting to **no**. The delete
657
+ tombstones the id: the playlist cannot be restored, and a later publish naming that id is refused. Build
658
+ a new playlist instead of trying to recreate it.
659
+
660
+ ### When ownership cannot be proved
661
+
662
+ A key counts as an owner only when the **stored** playlist names it in `curators[]` **and** carries its
663
+ valid `curator`-role signature. Being named is a claim; signing in the owner role is the proof, and the
664
+ CLI checks both against the stored document before it signs an intent.
665
+
666
+ That distinction has one large real population: playlists published while the default signing role was
667
+ `agent`. They declare a curator and carry only that key's `agent` signature, so nothing can ever
668
+ authorize a write to them — not even the holder of the declared key, because adding the missing proof
669
+ would itself be a replace:
670
+
671
+ ```
672
+ Unpublish failed
673
+ No key has proved ownership of this playlist, so none can delete it.
674
+
675
+ A key counts as an owner only when it is named in curators[] AND signed the document as
676
+ "curator". This playlist names a curator and carries no valid curator signature
677
+ from that key — the shape of a playlist published while the default signing role was
678
+ "agent".
679
+ Declared:
680
+ did:key:z6Mkv7qJ...
681
+ Nothing can repair it, including holding one of those keys: adding the missing signature
682
+ would be a replace, and a replace needs the very proof that is missing. Publish a corrected
683
+ playlist under a new id instead.
684
+ ```
685
+
686
+ This is why `publish` refuses to create such a document in the first place: the refusal at publish time
687
+ is recoverable, and this one is not.
688
+
689
+ ### When the key is not an owner
690
+
691
+ `ff-cli` reads the stored playlist before it signs anything, so this is refused locally and nothing is
692
+ sent. The feed's own answer would be a bare `403`, naming neither the identity you offered nor the ones
693
+ that would have worked:
694
+
695
+ ```
696
+ $ ff-cli unpublish 885b2ea6-74e2-44fe-96d0-e8728f6bba9c -s 0 -y
697
+
698
+ Unpublish playlist
699
+
700
+ Unpublish failed
701
+ The configured signing key is not an owner of this playlist, so it cannot delete it.
702
+
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.
705
+ Your configured identity:
706
+ did:key:z6MkoX8i2dynyvLh4hUHZt8b42q9uAwwCWxM4NSX4YDfMtaC
707
+ Stored owners:
708
+ 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.
712
+ ```
713
+
714
+ Ownership cannot be granted after the fact — the owner set is immutable, and only an owner could change
715
+ it — so the only fix is to hold a declared key. A playlist whose stored `curators[]` is **empty** is a
716
+ harder case with the same shape: nobody owns it, no signature can ever authorize a write, and `ff-cli`
717
+ says so rather than sending you after a key that does not exist. That is the state every playlist
718
+ published without an owner-role signature is frozen in, and it is what the `publish` owner-role gate
719
+ above exists to prevent.
720
+
522
721
  ## Complete Flow (build → validate → sign → play → publish)
523
722
 
524
723
  ```bash
@@ -178,7 +178,39 @@ substituted for a chain the row already named.
178
178
  ### Delivery
179
179
 
180
180
  - `play` (handles playlist files, playlist URLs, and media URLs)
181
- - `publish`
181
+ - `fetch`
182
+ - `publish` (`--replace` sends an owner-bound replacement instead of a create)
183
+ - `unpublish`
184
+
185
+ ### Owner-bound feed mutations
186
+
187
+ Creating on a feed is open; changing or removing something is not. A feed accepts a `POST` from any
188
+ document that is self-signed by a curator it declares, and the signer becomes that resource's owner. A
189
+ `PUT` and a `DELETE` are authorized instead against the **stored** document's `curators[]`, and neither
190
+ accepts an API key. Both therefore carry a signed intent — `{ action, target: { type, id, slug },
191
+ created, signatures }`, with a `payloadHash` for replace — whose `created` must fall inside the feed's
192
+ freshness window; the intent exists because a document's own signatures are public via `GET` and could
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
196
+ named is a claim; the owner-role signature is the proof, and checking only the claim is what would let a
197
+ legacy `agent`-signed document pass preflight and come back as a `403` reported as a missing
198
+ declaration — the one thing not wrong with it. The three local refusals are therefore distinct: no
199
+ declared curators, declared but never proved (terminal, and the shape of anything published while the
200
+ default role was `agent`), and proved by a key other than this one. A `403` that survives all three is
201
+ reported as the feed's own judgement, not as a missing declaration. This is also why `publish` refuses to
202
+ create a document whose declared curator did not sign in the `curator` role: that refusal is
203
+ recoverable and the resulting stored playlist would not be. A delete tombstones the id, so the operation
204
+ is final and the id is not reusable.
205
+
206
+ Editing a published playlist therefore has a required shape: `fetch` the stored document, change it,
207
+ re-sign with `sign --replace-signatures`, then `publish --replace`. `fetch` exists because every other
208
+ way of producing that starting file is wrong: `find` and `build` mint a new identity, and `verify` only
209
+ reports on a document rather than saving one. Signing appends by default, which is
210
+ correct only while the signed content is unchanged; an edit invalidates every entry the document
211
+ carries, including the feed's own co-signature, and the envelope check refuses to persist a mix of stale
212
+ and fresh. The fresh mode drops them all — the feed re-appends its signature after it verifies the
213
+ replacement.
182
214
 
183
215
  ### Device operations
184
216
 
package/docs/README.md CHANGED
@@ -97,7 +97,8 @@ 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>`
100
+ - Options: `-k, --key <privateKey>`, `-r, --role <role>`, `-o, --output <file>`, `--replace-signatures`
101
+ - 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`
101
102
  - `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)
102
103
  - Options: `-d, --device <name>`, `--skip-verify` (skip signature verification; not recommended)
103
104
  - `find <input>` – Resolve a marketplace URL, raw `chain:contract:tokenId`, or wallet address into a playable DP-1 playlist
@@ -109,7 +110,11 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1
109
110
  - 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
111
  - Options: `-o, --output <file>`, `--force` (replace existing manifests), `--assume-ethereum`, `-v, --verbose`
111
112
  - `publish <file>` – Publish a playlist to a feed server (runs `verify` before upload and rejects unsigned or broken playlists)
112
- - Options: `-s, --server <index>` (server index if multiple configured)
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)
114
+ - `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)
113
118
  - `ssh <enable|disable>` – Manage SSH access on an FF1 device
114
119
  - Options: `-d, --device <name>`, `--pubkey <path>`, `--ttl <duration>`
115
120
  - `device list` – List all configured FF1 devices
@@ -323,6 +328,47 @@ is configured. An `apiKey` left over in an existing config is ignored.
323
328
  }
324
329
  ```
325
330
 
331
+ ### Change or remove a published playlist
332
+
333
+ 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
336
+ why `publish` refuses to create a playlist that carries no owner-role signature — such a document can be
337
+ neither replaced nor deleted, ever.
338
+
339
+ ```bash
340
+ # Edit a published playlist: fetch it, change the file, re-sign fresh, then replace.
341
+ ff-cli fetch <id> -o playlist.json -s 0
342
+ # ...edit the title, items, or metadata...
343
+ ff-cli sign playlist.json -r curator --replace-signatures
344
+ ff-cli publish playlist.json --replace -s 0
345
+
346
+ # Delete a published playlist (id, slug, or feed URL all work).
347
+ ff-cli unpublish 97595a2f-a790-477c-aa42-b4f2ec9f1e3b -s 0
348
+ ff-cli unpublish https://feed.example.com/api/v1/playlists/97595a2f-a790-477c-aa42-b4f2ec9f1e3b
349
+ ```
350
+
351
+ `--replace-signatures` is not optional on that path. The document you fetched carries the curator's
352
+ signature and the feed's, both taken over the pre-edit content; a plain `ff-cli sign` appends to them and
353
+ the envelope check then refuses to write a file with stale entries in it. Signing fresh drops both — the
354
+ feed re-appends its own after it verifies the replacement.
355
+
356
+ `--replace` keeps identity fixed: `id`, `slug`, and `created` must equal the stored document's, and the
357
+ `curators[]` owner set may not change. Editing a published playlist therefore means editing the document
358
+ you published — re-running `find` or `build` mints a fresh id, slug, and `created`, which is a new
359
+ playlist rather than a replacement. A `publish` without `--replace` is never silently upgraded to a
360
+ replace; an id the feed already holds fails with a conflict, as before.
361
+
362
+ Both verbs prove ownership locally before signing anything. A key counts as an owner only when the
363
+ **stored** playlist names it in `curators[]` **and** carries its valid `curator`-role signature — being
364
+ named is a claim, signing in the owner role is the proof. A playlist that declares a curator but was
365
+ signed under another role (the shape of anything published while the default role was `agent`) can never
366
+ be replaced or deleted by anyone, and the CLI says so rather than sending a request the feed will refuse.
367
+
368
+ `unpublish` asks for confirmation first, showing the title and the server. `-y` skips it for scripts.
369
+ Deletion is final in a way worth knowing before you run it: the feed **tombstones** the id, so the
370
+ playlist cannot be restored and a later publish naming that id is refused. Build a new playlist instead.
371
+
326
372
  ### FF1 device management
327
373
 
328
374
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.7.0",
3
+ "version": "2.8.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",