@feralfile/cli 2.6.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.
@@ -1,40 +1,8 @@
1
+ "use strict";
1
2
  /**
2
3
  * Playlist Signing Utility.
3
4
  * Uses the DP-1 v1.1.0 signing contract via the `dp1-js` package.
4
5
  */
5
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
- if (k2 === undefined) k2 = k;
7
- var desc = Object.getOwnPropertyDescriptor(m, k);
8
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
- desc = { enumerable: true, get: function() { return m[k]; } };
10
- }
11
- Object.defineProperty(o, k2, desc);
12
- }) : (function(o, m, k, k2) {
13
- if (k2 === undefined) k2 = k;
14
- o[k2] = m[k];
15
- }));
16
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
- Object.defineProperty(o, "default", { enumerable: true, value: v });
18
- }) : function(o, v) {
19
- o["default"] = v;
20
- });
21
- var __importStar = (this && this.__importStar) || (function () {
22
- var ownKeys = function(o) {
23
- ownKeys = Object.getOwnPropertyNames || function (o) {
24
- var ar = [];
25
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
- return ar;
27
- };
28
- return ownKeys(o);
29
- };
30
- return function (mod) {
31
- if (mod && mod.__esModule) return mod;
32
- var result = {};
33
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
- __setModuleDefault(result, mod);
35
- return result;
36
- };
37
- })();
38
6
  const { getPlaylistConfig } = require('../config');
39
7
  const { isDp1PlaylistSigningRole } = require('./playlist-signing-role');
40
8
  const { parsePlaylistPrivateKeyToKeyObject } = require('./ed25519-key-derive');
@@ -130,12 +98,16 @@ async function verifyPlaylist(playlist, publicKeyHex) {
130
98
  * @param {string} playlistPath - Path to playlist JSON file
131
99
  * @param {string} [privateKeyBase64] - Ed25519 private key in hex or base64 format (optional, uses config if not provided)
132
100
  * @param {string} [outputPath] - Output path (optional, overwrites input if not provided)
101
+ * @param {string} [roleOverride] - DP-1 signing role override
102
+ * @param {Object} [options] - Signing options
103
+ * @param {boolean} [options.replaceSignatures=false] - Drop every existing signature and sign fresh
133
104
  * @returns {Promise<Object>} Result with signed playlist
134
105
  * @returns {boolean} returns.success - Whether signing succeeded
135
106
  * @returns {Object} [returns.playlist] - Signed playlist object
107
+ * @returns {number} [returns.droppedSignatures] - How many stale entries were discarded
136
108
  * @returns {string} [returns.error] - Error message if failed
137
109
  */
138
- async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride) {
110
+ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride, options) {
139
111
  const fs = require('fs');
140
112
  const path = require('path');
141
113
  try {
@@ -156,7 +128,12 @@ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, role
156
128
  if (!privateKey) {
157
129
  throw new Error('Private key is required for signing');
158
130
  }
159
- const signedPlaylist = await buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role);
131
+ const replaceSignatures = Boolean(options && options.replaceSignatures);
132
+ const droppedSignatures = replaceSignatures
133
+ ? (Array.isArray(playlist.signatures) ? playlist.signatures.filter(Boolean).length : 0) +
134
+ (typeof playlist.signature === 'string' && playlist.signature.trim() ? 1 : 0)
135
+ : 0;
136
+ const signedPlaylist = await buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role, replaceSignatures);
160
137
  const verification = await verifySignedPlaylistEnvelope(signedPlaylist, dp1);
161
138
  if (!verification.valid) {
162
139
  throw new Error(`Signed playlist verification failed: ${verification.error}`);
@@ -168,6 +145,7 @@ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, role
168
145
  return {
169
146
  success: true,
170
147
  playlist: signedPlaylist,
148
+ droppedSignatures,
171
149
  };
172
150
  }
173
151
  catch (error) {
@@ -181,6 +159,10 @@ module.exports = {
181
159
  signPlaylist,
182
160
  verifyPlaylist,
183
161
  signPlaylistFile,
162
+ // Exported for the feed-mutation intents: a delete/replace intent is signed with the same key
163
+ // material as a playlist, so it must accept the same encodings and raise the same guidance when the
164
+ // key is malformed. Duplicating the normalizer there would let the two paths drift.
165
+ normalizeSigningKeyToBase64Pkcs8,
184
166
  };
185
167
  function resolvePlaylistSigningRole(role) {
186
168
  const candidate = typeof role === 'string' ? role.trim() : '';
@@ -208,13 +190,25 @@ async function validatePlaylistForSigning(playlist) {
208
190
  * as dp1-js/dp1-go §7.1); prior `signatures[]` entries are kept on the returned
209
191
  * object so repeated `sign` runs accumulate endorsements instead of replacing them.
210
192
  *
193
+ * `replaceSignatures` discards them instead, and exists because appending is only
194
+ * correct while the signed content is unchanged. Editing a signed document moves
195
+ * bytes the earlier entries cover, so they become unverifiable — and this function's
196
+ * caller verifies the whole envelope before writing, which means an edited document
197
+ * cannot be re-signed at all without dropping them. That is the ordinary path for a
198
+ * feed replace: fetch the published playlist, change a field, sign fresh, PUT.
199
+ *
200
+ * The feed's own `feed` entry is dropped by the same rule rather than as a special
201
+ * case: it covers the pre-edit content too, and the feed appends a new one after it
202
+ * verifies the replacement, so carrying the old one forward can only fail.
203
+ *
211
204
  * @param {Object} playlist - Parsed playlist (may already include `signatures[]`)
212
205
  * @param {string} privateKey - Private key material forwarded to dp1-js
213
206
  * @param {Object} dp1 - Loaded dp1-js module
214
207
  * @param {string} role - DP-1 signing role
208
+ * @param {boolean} [replaceSignatures=false] - Discard existing signatures instead of appending
215
209
  * @returns {Promise<Object>} Playlist with legacy `signature` cleared and merged `signatures[]`
216
210
  */
217
- async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
211
+ async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role, replaceSignatures) {
218
212
  const playlistToSign = { ...playlist };
219
213
  delete playlistToSign.signature;
220
214
  delete playlistToSign.signatures;
@@ -222,7 +216,7 @@ async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
222
216
  // seed, PEM) works and a malformed key surfaces a clear error instead of
223
217
  // dp1-js's cryptic OpenSSL ASN.1 failure ("header too long" / "wrong tag").
224
218
  const normalizedKey = normalizeSigningKeyToBase64Pkcs8(privateKey);
225
- const existingSignatures = Array.isArray(playlist.signatures)
219
+ const existingSignatures = !replaceSignatures && Array.isArray(playlist.signatures)
226
220
  ? playlist.signatures.filter((entry) => Boolean(entry))
227
221
  : [];
228
222
  if (typeof dp1.SignMultiEd25519 === 'function') {
@@ -261,7 +255,7 @@ async function verifySignedPlaylistEnvelope(signedPlaylist, dp1) {
261
255
  * instead of leaving a runtime require that can't resolve.
262
256
  */
263
257
  async function loadDp1() {
264
- return Promise.resolve().then(() => __importStar(require('dp1-js')));
258
+ return import('dp1-js');
265
259
  }
266
260
  function currentTimestamp() {
267
261
  return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
@@ -1,3 +1,4 @@
1
+ "use strict";
1
2
  /**
2
3
  * Supported DP-1 playlist signing roles.
3
4
  *
@@ -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
+ }
@@ -4,39 +4,6 @@
4
4
  * Delegates signature-shape handling to dp1-js and keeps the CLI as a thin
5
5
  * orchestration layer.
6
6
  */
7
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
- if (k2 === undefined) k2 = k;
9
- var desc = Object.getOwnPropertyDescriptor(m, k);
10
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
- desc = { enumerable: true, get: function() { return m[k]; } };
12
- }
13
- Object.defineProperty(o, k2, desc);
14
- }) : (function(o, m, k, k2) {
15
- if (k2 === undefined) k2 = k;
16
- o[k2] = m[k];
17
- }));
18
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
- Object.defineProperty(o, "default", { enumerable: true, value: v });
20
- }) : function(o, v) {
21
- o["default"] = v;
22
- });
23
- var __importStar = (this && this.__importStar) || (function () {
24
- var ownKeys = function(o) {
25
- ownKeys = Object.getOwnPropertyNames || function (o) {
26
- var ar = [];
27
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
- return ar;
29
- };
30
- return ownKeys(o);
31
- };
32
- return function (mod) {
33
- if (mod && mod.__esModule) return mod;
34
- var result = {};
35
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
- __setModuleDefault(result, mod);
37
- return result;
38
- };
39
- })();
40
7
  var __importDefault = (this && this.__importDefault) || function (mod) {
41
8
  return (mod && mod.__esModule) ? mod : { "default": mod };
42
9
  };
@@ -84,10 +51,10 @@ async function verifyPlaylist(playlist, publicKey) {
84
51
  let key = publicKey?.trim() || undefined;
85
52
  if (!key) {
86
53
  try {
87
- const { getPlaylistConfig } = await Promise.resolve().then(() => __importStar(require('../config')));
54
+ const { getPlaylistConfig } = await import('../config.js');
88
55
  const privateKeyMaterial = getPlaylistConfig().privateKey;
89
56
  if (privateKeyMaterial) {
90
- const { deriveEd25519PublicKeyForVerify } = await Promise.resolve().then(() => __importStar(require('./ed25519-key-derive')));
57
+ const { deriveEd25519PublicKeyForVerify } = await import('./ed25519-key-derive.js');
91
58
  key = deriveEd25519PublicKeyForVerify(privateKeyMaterial);
92
59
  }
93
60
  }
@@ -98,7 +65,7 @@ async function verifyPlaylist(playlist, publicKey) {
98
65
  }
99
66
  if (key) {
100
67
  try {
101
- const { normalizeVerifyPublicKeyToPem } = await Promise.resolve().then(() => __importStar(require('./ed25519-key-derive')));
68
+ const { normalizeVerifyPublicKeyToPem } = await import('./ed25519-key-derive.js');
102
69
  key = normalizeVerifyPublicKeyToPem(key);
103
70
  }
104
71
  catch (err) {
@@ -162,7 +129,7 @@ async function parseDp1Playlist(playlist) {
162
129
  * stays lazy, so dp1-js's transitive deps don't load on unrelated commands.
163
130
  */
164
131
  async function loadDp1() {
165
- return (await Promise.resolve().then(() => __importStar(require('dp1-js'))));
132
+ return (await import('dp1-js'));
166
133
  }
167
134
  /**
168
135
  * Verify playlist file
@@ -63,7 +63,7 @@ Used for signing DP‑1 playlists.
63
63
  `PLAYLIST_PRIVATE_KEY` even when no config file exists yet, and `ff-cli status --key <privateKey>`
64
64
  reports it for an explicit key — the one `sign --key` would use.
65
65
 
66
- - `playlist.role` (string): DP-1 signing role used by `ff-cli sign`. 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`). It does **not** apply to `find` and `build`, which always sign as `curator` — see [Signing role and ownership](#signing-role-and-ownership).
66
+ - `playlist.role` (string): DP-1 signing role used by `ff-cli sign`. The shipped `config.json.example` sets `curator`, because a document signed as `agent` is ownerless to a role-aware feed and `ff-cli publish` refuses it. The code default when the key is omitted entirely is still `agent`. 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`). It does **not** apply to `find` and `build`, which always sign as `curator` — see [Signing role and ownership](#signing-role-and-ownership).
67
67
 
68
68
  ### Generate an Ed25519 private key
69
69
 
@@ -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
 
@@ -267,7 +285,7 @@ Minimal `config.json` example (selected fields):
267
285
  "defaultDuration": 10,
268
286
  "playlist": {
269
287
  "privateKey": "your_ed25519_private_key_hex_or_base64_here",
270
- "role": "agent"
288
+ "role": "curator"
271
289
  },
272
290
  "feed": {
273
291
  "baseURLs": ["https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"]
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