@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.
@@ -4,15 +4,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.publishPlaylist = publishPlaylist;
7
+ exports.replacePlaylist = replacePlaylist;
7
8
  const axios_1 = __importDefault(require("axios"));
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  const playlist_verifier_1 = require("./playlist-verifier");
10
- /**
11
- * DP-1 signature role that carries ownership of a playlist.
12
- *
13
- * Mirrors the feed's owner role for playlists (channels use `publisher`, which ff-cli does not publish).
14
- */
15
- const OWNER_ROLE = 'curator';
11
+ const config_1 = require("../config");
12
+ const feed_mutation_1 = require("./feed-mutation");
13
+ const signing_identity_1 = require("./signing-identity");
16
14
  /**
17
15
  * Publish a verified playlist to a DP-1 feed server
18
16
  *
@@ -34,149 +32,16 @@ const OWNER_ROLE = 'curator';
34
32
  * }
35
33
  */
36
34
  async function publishPlaylist(filePath, feedServerUrl) {
35
+ const loaded = readPlaylistFile(filePath);
36
+ if ('failure' in loaded) {
37
+ return loaded.failure;
38
+ }
39
+ const { playlist } = loaded;
40
+ const refusal = await preflightPlaylistForUpload(playlist);
41
+ if (refusal) {
42
+ return refusal;
43
+ }
37
44
  try {
38
- // Step 1: Read and parse playlist file
39
- if (!fs_1.default.existsSync(filePath)) {
40
- return {
41
- success: false,
42
- error: `Playlist file not found: ${filePath}`,
43
- };
44
- }
45
- let playlist;
46
- try {
47
- const content = fs_1.default.readFileSync(filePath, 'utf-8');
48
- playlist = JSON.parse(content);
49
- }
50
- catch (_parseError) {
51
- return {
52
- success: false,
53
- error: `Invalid JSON in playlist file: ${filePath}`,
54
- };
55
- }
56
- // Step 2: refuse legacy flat-signature documents outright, before verification.
57
- //
58
- // A flat `signature` string carries no kid, so the curator rule below has nothing to match and such
59
- // a document would reach the feed only to be refused as unauthenticated — verified against a running
60
- // feed. This runs ahead of verification deliberately: the document is unpublishable whether or not
61
- // its legacy signature is valid, and 're-sign as a v1.1 envelope' is the remedy either way, which
62
- // 'signature verification failed' would not convey.
63
- const hasLegacyFlatSignature = typeof playlist.signature === 'string' &&
64
- playlist.signature.trim().length > 0;
65
- const hasEnvelope = Array.isArray(playlist.signatures)
66
- ? (playlist.signatures ?? []).length > 0
67
- : false;
68
- if (!hasEnvelope && hasLegacyFlatSignature) {
69
- return {
70
- success: false,
71
- error: 'Playlist carries a legacy flat signature, which the feed cannot authorize.',
72
- message: `The feed authorizes a publish from a signatures[] envelope, matching a signature's kid\n` +
73
- ` against the playlist's own curators[]. A flat "signature" string carries no kid.\n` +
74
- ` Re-sign it as a DP-1 v1.1 envelope:\n` +
75
- ` 1. remove the "signature" field\n` +
76
- ` 2. declare your key: "curators": [{ "name": "Your name", "key": "<did:key from ff-cli status>" }]\n` +
77
- ` 3. ff-cli sign <file> -r ${OWNER_ROLE}\n` +
78
- ` The role matters: a declared key counts as an owner only when it also signed as "${OWNER_ROLE}",\n` +
79
- ` and plain "ff-cli sign" uses playlist.role, which defaults to "agent".`,
80
- };
81
- }
82
- // Step 3: Verify signature integrity before publishing.
83
- const deliveryResult = await (0, playlist_verifier_1.verifyPlaylist)(playlist);
84
- if (!deliveryResult.valid) {
85
- return {
86
- success: false,
87
- error: `Playlist verification failed: ${deliveryResult.error}`,
88
- message: deliveryResult.details?.map((d) => ` • ${d.path}: ${d.message}`).join('\n'),
89
- };
90
- }
91
- // Step 4: fail here, not at the feed, when the signer is not declared as a curator.
92
- //
93
- // The feed accepts a create only when a signature's `kid` matches a key the document declares in
94
- // `curators[]`. Signing alone does not satisfy that, and the server's answer — "no valid curator
95
- // signature found" — reads as a signing problem, which sends people to check their key instead of
96
- // their document. Checking locally turns a confusing 400 into an instruction, and costs no request.
97
- const curatorKeys = new Set((Array.isArray(playlist.curators)
98
- ? (playlist.curators ?? [])
99
- : [])
100
- .map((c) => (typeof c?.key === 'string' ? c.key.trim() : ''))
101
- .filter((k) => k.length > 0));
102
- const signatures = Array.isArray(playlist.signatures)
103
- ? (playlist
104
- .signatures ?? [])
105
- : [];
106
- const signingKids = signatures
107
- .map((sig) => (typeof sig?.kid === 'string' ? sig.kid.trim() : ''))
108
- .filter((k) => k.length > 0);
109
- const declaredSignatures = signatures.filter((sig) => curatorKeys.has(typeof sig?.kid === 'string' ? sig.kid.trim() : ''));
110
- // Same predicate as `no declared signature`, expressed through the set the next gate also needs.
111
- if (signingKids.length > 0 && declaredSignatures.length === 0) {
112
- return {
113
- success: false,
114
- error: 'Playlist is signed, but the signing key is not declared as a curator.',
115
- message: `The feed accepts a publish when a signature's kid appears in the playlist's own curators[].\n` +
116
- ` Add this to the playlist before signing:\n` +
117
- ` "curators": [{ "name": "Your name", "key": "${signingKids[0]}" }]\n` +
118
- ` then sign again from the unsigned file, with the same key you just declared:\n` +
119
- ` ff-cli sign <file> -r ${OWNER_ROLE} --key <private key for ${signingKids[0]}>\n` +
120
- ` Drop --key if that key is your configured one; "sign" uses the configured key otherwise, and a\n` +
121
- ` signature from an undeclared key would not satisfy the feed. The role is ff-cli's requirement\n` +
122
- ` rather than every feed's: see the owner-role check for why publishing without it is refused.\n` +
123
- ` Start from the unsigned file\n` +
124
- ` because declaring curators[] changes the signed payload: signing appends, so the earlier\n` +
125
- ` signature would be left covering a document that no longer exists.`,
126
- };
127
- }
128
- // Step 4b: a declared key must also have signed AS curator.
129
- //
130
- // Being named in curators[] is a claim; signing in the owner role is the proof, and the feed requires
131
- // both before it treats a key as an owner. A document that is declared but signed under another role
132
- // (ff-cli's own default was `agent`) verifies cleanly and is still refused, and the server answers in
133
- // terms of ownership — which reads as "fix curators[]", the one part that is already correct. Naming
134
- // the role and the remedy here is the difference between a one-flag fix and a wrong-end search.
135
- //
136
- // This is deliberately stricter than a feed that ignores the role: such a feed accepts the document
137
- // today and freezes it the moment role-aware ownership lands, because a replace and a delete both
138
- // need an owner signature the document does not carry. Refusing now keeps that document from being
139
- // created at all.
140
- if (declaredSignatures.length > 0 &&
141
- !declaredSignatures.some((sig) => sig.role === OWNER_ROLE)) {
142
- const rolesUsed = [
143
- ...new Set(declaredSignatures
144
- .map((sig) => (typeof sig.role === 'string' ? sig.role.trim() : ''))
145
- .filter((role) => role.length > 0)),
146
- ];
147
- const seen = rolesUsed.length > 0 ? rolesUsed.map((role) => `"${role}"`).join(', ') : 'no role';
148
- // List every key eligible to repair this, not the one that happens to have signed.
149
- //
150
- // The gate above accepts an owner-role signature from ANY declared key, so on a playlist declaring
151
- // several curators the holder of any one of them can append it. Naming only the key that signed
152
- // under the wrong role describes an impossible fix to everyone else — they may not hold it — while
153
- // a key they do hold would have worked. `sign` still defaults to the configured key, which need not
154
- // be declared at all, so the eligible set has to be stated rather than assumed.
155
- const eligibleKeys = [...curatorKeys];
156
- const eligibleList = eligibleKeys.length > 0 ? eligibleKeys.join(', ') : 'a key declared in curators[]';
157
- return {
158
- success: false,
159
- error: `Playlist is signed by a declared curator, but under a non-owner role (${seen}).`,
160
- message: `This is ff-cli's check, not a feed's answer. Feeds are moving to role-aware ownership, where\n` +
161
- ` a key in curators[] counts as an owner only if it also signed as "${OWNER_ROLE}". A feed that\n` +
162
- ` does not check the role yet will accept this document, and then be unable to authorize a\n` +
163
- ` replace or a delete for it: both need an owner signature it does not carry. Publishing it is\n` +
164
- ` what makes that permanent, so ff-cli refuses here instead.\n` +
165
- `\n` +
166
- ` curators[] is already correct — only the role is missing, so add that signature to this file:\n` +
167
- ` ff-cli sign <file> -r ${OWNER_ROLE} --key <private key for a declared curator>\n` +
168
- ` Any key this playlist declares will do — you do not need the one that signed under the wrong\n` +
169
- ` role. Declared: ${eligibleList}\n` +
170
- ` It must be one of those: a ${OWNER_ROLE} signature from an undeclared key satisfies neither\n` +
171
- ` this check nor role-aware ownership, since both read roles only from declared keys. "sign"\n` +
172
- ` uses the configured key unless --key says otherwise; drop --key if a declared key is already\n` +
173
- ` your configured one, and confirm which identity a key carries with\n` +
174
- ` "ff-cli status --key <private key>".\n` +
175
- ` Signing appends, and the payload hash excludes signatures, so the existing entry stays valid\n` +
176
- ` and the document ends up carrying both. No unsigned copy is needed: you only have to start\n` +
177
- ` from one when changing signed content such as curators[] itself.`,
178
- };
179
- }
180
45
  // Step 5: Send validated playlist to feed server.
181
46
  //
182
47
  // No auth header. The feed authorizes a create from the document's own signatures: it requires a
@@ -213,3 +78,372 @@ async function publishPlaylist(filePath, feedServerUrl) {
213
78
  };
214
79
  }
215
80
  }
81
+ /**
82
+ * Replace a playlist already stored on a DP-1 feed with a re-signed document.
83
+ *
84
+ * A `POST` of an id the feed already holds is a `409`; editing an existing playlist goes through `PUT`,
85
+ * which is owner-bound, owner-immutable and replay-bound (dp1-feed-v2 `docs/api_design.md`, "PUT
86
+ * (replace)"). The body is not the document alone but `{ document, authorization }`: an owner's document
87
+ * signatures are public via `GET`, so a document on its own could be replayed to roll a playlist back to
88
+ * an earlier version. The authorization intent carries a `payloadHash` binding it to these exact bytes
89
+ * and a `created` inside the freshness window, both inside a signed payload, which is what a replayed
90
+ * body cannot forge.
91
+ *
92
+ * Identity is validated, not substituted: `id`, `slug`, and `created` must equal the stored document's,
93
+ * and the owner set may not change. Those three are checked locally first — the feed's answer is a bare
94
+ * `400`, which does not say which field moved, and the usual cause is a rebuilt playlist that minted a
95
+ * fresh `created` rather than an edited one.
96
+ *
97
+ * Flow:
98
+ * 1. Read, verify, and preflight the document exactly as a create would.
99
+ * 2. `GET` the stored playlist by the document's own id.
100
+ * 3. Refuse locally on an identity change, an owner-set change, or a non-owner signing key.
101
+ * 4. Sign an authorization intent bound to the document's payload hash.
102
+ * 5. `PUT` both halves and report the outcome.
103
+ *
104
+ * @param filePath - Path to the fully re-signed playlist JSON file
105
+ * @param feedServerUrl - Feed server base URL, including `/api/v1`
106
+ * @param options - Optional signing-key override for the authorization intent
107
+ * @returns Result with the replaced playlist id, or a diagnosis and a remedy
108
+ * @example
109
+ * const result = await replacePlaylist('playlist.json', 'https://feed.example.com/api/v1');
110
+ */
111
+ async function replacePlaylist(filePath, feedServerUrl, options = {}) {
112
+ const loaded = readPlaylistFile(filePath);
113
+ if ('failure' in loaded) {
114
+ return loaded.failure;
115
+ }
116
+ const { playlist } = loaded;
117
+ const refusal = await preflightPlaylistForUpload(playlist);
118
+ if (refusal) {
119
+ return refusal;
120
+ }
121
+ const documentId = typeof playlist.id === 'string' ? playlist.id.trim() : '';
122
+ if (!documentId) {
123
+ return {
124
+ success: false,
125
+ error: 'The playlist has no id, so there is nothing on the feed for it to replace.',
126
+ message: 'A replace targets an existing resource by its id. Publish it without --replace to create it.',
127
+ };
128
+ }
129
+ let privateKey;
130
+ let signerDidKey;
131
+ try {
132
+ privateKey = resolveIntentSigningKey(options.privateKey);
133
+ signerDidKey = (0, signing_identity_1.playlistSigningDidKey)(privateKey);
134
+ }
135
+ catch (error) {
136
+ return { success: false, error: error.message };
137
+ }
138
+ let stored;
139
+ try {
140
+ stored = await (0, feed_mutation_1.fetchStoredPlaylist)(feedServerUrl, documentId);
141
+ }
142
+ catch (error) {
143
+ const described = (0, feed_mutation_1.describeFeedMutationError)(error, 'replace');
144
+ const missing = error.response?.status === 404;
145
+ return {
146
+ success: false,
147
+ ...described,
148
+ // A 404 here is not the same failure as a 404 on the PUT itself: the operator asked to replace a
149
+ // playlist this feed has never held, and the fix is to publish it instead of guessing at ids.
150
+ ...(missing
151
+ ? {
152
+ error: `This feed has no playlist with id ${documentId}, so there is nothing to replace.`,
153
+ message: 'Publish it without --replace to create it. If it was deleted, the id is tombstoned and\n' +
154
+ ' cannot be recreated — build the playlist again so it gets a new id.',
155
+ }
156
+ : {}),
157
+ feedServer: feedServerUrl,
158
+ };
159
+ }
160
+ const mismatch = replaceIdentityMismatch(playlist, stored);
161
+ if (mismatch) {
162
+ return { success: false, ...mismatch, feedServer: feedServerUrl };
163
+ }
164
+ const ownership = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'replace');
165
+ if (ownership) {
166
+ return { success: false, ...ownership, feedServer: feedServerUrl };
167
+ }
168
+ try {
169
+ const created = (0, feed_mutation_1.intentTimestamp)();
170
+ const payloadHash = await (0, feed_mutation_1.documentPayloadHash)(playlist);
171
+ const storedSlug = typeof stored.slug === 'string' ? stored.slug : '';
172
+ const intent = (0, feed_mutation_1.buildReplaceIntent)(documentId, storedSlug, payloadHash, created);
173
+ const signature = await (0, feed_mutation_1.signIntent)(intent, privateKey);
174
+ const response = await axios_1.default.put(`${feedServerUrl}/playlists/${encodeURIComponent(documentId)}`, { document: playlist, authorization: { ...intent, signatures: [signature] } }, { headers: { 'Content-Type': 'application/json' }, timeout: 30000 });
175
+ if (response.status !== 200) {
176
+ return {
177
+ success: false,
178
+ error: `Unexpected response status: ${response.status}`,
179
+ feedServer: feedServerUrl,
180
+ };
181
+ }
182
+ return {
183
+ success: true,
184
+ playlistId: response.data?.id || documentId,
185
+ message: 'Replaced on feed server',
186
+ feedServer: feedServerUrl,
187
+ };
188
+ }
189
+ catch (error) {
190
+ return {
191
+ success: false,
192
+ ...(0, feed_mutation_1.describeFeedMutationError)(error, 'replace'),
193
+ feedServer: feedServerUrl,
194
+ };
195
+ }
196
+ }
197
+ /**
198
+ * replaceIdentityMismatch reports the immutable fields a replace would have changed.
199
+ *
200
+ * The feed compares `id`, `slug` and `created` against the stored row and holds the owner set fixed; any
201
+ * disagreement is a `400` or `403` that names none of them. The common cause is a rebuilt document rather
202
+ * than an edited one — `find`/`build` mint a fresh id, slug and `created` every run — so the remedy is to
203
+ * edit the published document, not to rebuild it.
204
+ *
205
+ * `created` is compared as an instant because the feed does: two spellings of the same moment are equal.
206
+ *
207
+ * @returns The failure to report, or `null` when identity and ownership are unchanged
208
+ */
209
+ function replaceIdentityMismatch(playlist, stored) {
210
+ const differences = [];
211
+ const documentSlug = typeof playlist.slug === 'string' ? playlist.slug : '';
212
+ const storedSlug = typeof stored.slug === 'string' ? stored.slug : '';
213
+ if (documentSlug !== storedSlug) {
214
+ differences.push(` slug: stored "${storedSlug}", document "${documentSlug}"`);
215
+ }
216
+ const documentCreated = typeof playlist.created === 'string' ? playlist.created : '';
217
+ const storedCreated = typeof stored.created === 'string' ? stored.created : '';
218
+ if (!sameInstant(documentCreated, storedCreated)) {
219
+ differences.push(` created: stored "${storedCreated}", document "${documentCreated}"`);
220
+ }
221
+ const documentOwners = (0, feed_mutation_1.storedOwnerKeys)(playlist);
222
+ const owners = (0, feed_mutation_1.storedOwnerKeys)(stored);
223
+ if (documentOwners.join('') !== owners.join('')) {
224
+ differences.push(` curators: stored [${owners.join(', ')}], document [${documentOwners.join(', ')}]`);
225
+ }
226
+ if (differences.length === 0) {
227
+ return null;
228
+ }
229
+ // The remedy has to hand back a runnable sequence, and every step of it has to produce the input the
230
+ // next one needs. `verify` was named here before and does not: it validates and prints a summary, so
231
+ // someone following it literally ends up with no document to edit and a second dead end.
232
+ const storedId = typeof stored.id === 'string' && stored.id ? stored.id : '<id>';
233
+ return {
234
+ error: 'The document changes fields a replace may not change.',
235
+ message: `A replace keeps identity and ownership fixed: id, slug, and created must equal the stored\n` +
236
+ ` document's, and the curators[] owner set is immutable.\n` +
237
+ `${differences.join('\n')}\n` +
238
+ ` Start from the published document, not a rebuilt one — "find" and "build" mint a new id, slug,\n` +
239
+ ` and created every run, which is a new playlist rather than a replacement:\n` +
240
+ ` ff-cli fetch ${storedId} -o playlist.json\n` +
241
+ ` (edit the fields you meant to change)\n` +
242
+ ` ff-cli sign playlist.json -r ${feed_mutation_1.OWNER_ROLE} --replace-signatures\n` +
243
+ ` ff-cli publish playlist.json --replace\n` +
244
+ ` --replace-signatures is required after an edit: the fetched document's signatures cover the\n` +
245
+ ` content as published, and signing appends rather than replacing.`,
246
+ };
247
+ }
248
+ /** True when two RFC 3339 strings name the same instant, whatever their spelling. */
249
+ function sameInstant(left, right) {
250
+ if (left === right) {
251
+ return true;
252
+ }
253
+ const a = Date.parse(left);
254
+ const b = Date.parse(right);
255
+ return Number.isFinite(a) && Number.isFinite(b) && a === b;
256
+ }
257
+ /**
258
+ * resolveIntentSigningKey resolves the key that signs the authorization intent.
259
+ *
260
+ * The intent is signed separately from the document, and by whoever is running the command: the document
261
+ * may legitimately carry several curators' signatures, while the intent proves that *this* operator holds
262
+ * one of those keys right now.
263
+ */
264
+ function resolveIntentSigningKey(override) {
265
+ if (override && override.trim().length > 0) {
266
+ return override;
267
+ }
268
+ const configured = (0, config_1.getPlaylistConfig)().privateKey;
269
+ if (!configured) {
270
+ throw new Error('No playlist signing key is configured. A replace is authorized by a signed intent, so one is ' +
271
+ 'required: run "ff-cli setup" or set playlist.privateKey in config.json.');
272
+ }
273
+ return configured;
274
+ }
275
+ /**
276
+ * readPlaylistFile reads and parses a playlist document from disk.
277
+ *
278
+ * Split out because both `publishPlaylist` and `replacePlaylist` start here and must answer identically:
279
+ * a missing or malformed file is a local problem, and neither should reach the network to learn that.
280
+ *
281
+ * @param filePath - Path to a playlist JSON file
282
+ * @returns The parsed playlist, or the failure to report
283
+ */
284
+ function readPlaylistFile(filePath) {
285
+ if (!fs_1.default.existsSync(filePath)) {
286
+ return { failure: { success: false, error: `Playlist file not found: ${filePath}` } };
287
+ }
288
+ let parsed;
289
+ try {
290
+ parsed = JSON.parse(fs_1.default.readFileSync(filePath, 'utf-8'));
291
+ }
292
+ catch (_parseError) {
293
+ return { failure: { success: false, error: `Invalid JSON in playlist file: ${filePath}` } };
294
+ }
295
+ // Well-formed JSON is not yet a document. `null`, `[]`, `"text"` and numbers all parse cleanly, and
296
+ // casting them straight to Playlist made the very next step — reading `playlist.signature` — throw a
297
+ // TypeError from inside a helper that sits outside both public entry points' try/catch. The caller
298
+ // asked for a PublishResult and got an exception instead, so a stray file answered with a stack
299
+ // trace rather than a diagnosis. Both verbs must fail here, structurally, as they do for bad JSON.
300
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
301
+ const found = parsed === null ? 'null' : Array.isArray(parsed) ? 'an array' : typeof parsed;
302
+ return {
303
+ failure: {
304
+ success: false,
305
+ error: `Playlist file does not contain a playlist document: ${filePath}`,
306
+ message: `The file is valid JSON but holds ${found}, not an object. A DP-1 playlist is a JSON object\n` +
307
+ ` with dpVersion, id, title, and items at the top level.`,
308
+ },
309
+ };
310
+ }
311
+ return { playlist: parsed };
312
+ }
313
+ /**
314
+ * preflightPlaylistForUpload runs every local gate a document must pass before it is sent to a feed.
315
+ *
316
+ * Create and replace share this entirely: both are authorized by the document's own signatures, and both
317
+ * need a `curator`-role signature from a declared key. Keeping one implementation is what stops the two
318
+ * verbs from drifting into different diagnoses for the same broken document — a replace that accepted a
319
+ * document `publish` refuses would let exactly the unauthorizable state this guards against back in.
320
+ *
321
+ * @param playlist - Parsed playlist document
322
+ * @returns The failure to report, or `null` when the document may be sent
323
+ */
324
+ async function preflightPlaylistForUpload(playlist) {
325
+ // Step 2: refuse legacy flat-signature documents outright, before verification.
326
+ //
327
+ // A flat `signature` string carries no kid, so the curator rule below has nothing to match and such
328
+ // a document would reach the feed only to be refused as unauthenticated — verified against a running
329
+ // feed. This runs ahead of verification deliberately: the document is unpublishable whether or not
330
+ // its legacy signature is valid, and 're-sign as a v1.1 envelope' is the remedy either way, which
331
+ // 'signature verification failed' would not convey.
332
+ const hasLegacyFlatSignature = typeof playlist.signature === 'string' &&
333
+ playlist.signature.trim().length > 0;
334
+ const hasEnvelope = Array.isArray(playlist.signatures)
335
+ ? (playlist.signatures ?? []).length > 0
336
+ : false;
337
+ if (!hasEnvelope && hasLegacyFlatSignature) {
338
+ return {
339
+ success: false,
340
+ error: 'Playlist carries a legacy flat signature, which the feed cannot authorize.',
341
+ message: `The feed authorizes a publish from a signatures[] envelope, matching a signature's kid\n` +
342
+ ` against the playlist's own curators[]. A flat "signature" string carries no kid.\n` +
343
+ ` Re-sign it as a DP-1 v1.1 envelope:\n` +
344
+ ` 1. remove the "signature" field\n` +
345
+ ` 2. declare your key: "curators": [{ "name": "Your name", "key": "<did:key from ff-cli status>" }]\n` +
346
+ ` 3. ff-cli sign <file> -r ${feed_mutation_1.OWNER_ROLE}\n` +
347
+ ` The role matters: a declared key counts as an owner only when it also signed as "${feed_mutation_1.OWNER_ROLE}",\n` +
348
+ ` and plain "ff-cli sign" uses playlist.role, which defaults to "agent".`,
349
+ };
350
+ }
351
+ // Step 3: Verify signature integrity before publishing.
352
+ const deliveryResult = await (0, playlist_verifier_1.verifyPlaylist)(playlist);
353
+ if (!deliveryResult.valid) {
354
+ return {
355
+ success: false,
356
+ error: `Playlist verification failed: ${deliveryResult.error}`,
357
+ message: deliveryResult.details?.map((d) => ` • ${d.path}: ${d.message}`).join('\n'),
358
+ };
359
+ }
360
+ // Step 4: fail here, not at the feed, when the signer is not declared as a curator.
361
+ //
362
+ // The feed accepts a create only when a signature's `kid` matches a key the document declares in
363
+ // `curators[]`. Signing alone does not satisfy that, and the server's answer — "no valid curator
364
+ // signature found" — reads as a signing problem, which sends people to check their key instead of
365
+ // their document. Checking locally turns a confusing 400 into an instruction, and costs no request.
366
+ const curatorKeys = new Set((Array.isArray(playlist.curators)
367
+ ? (playlist.curators ?? [])
368
+ : [])
369
+ .map((c) => (typeof c?.key === 'string' ? c.key.trim() : ''))
370
+ .filter((k) => k.length > 0));
371
+ const signatures = Array.isArray(playlist.signatures)
372
+ ? (playlist
373
+ .signatures ?? [])
374
+ : [];
375
+ const signingKids = signatures
376
+ .map((sig) => (typeof sig?.kid === 'string' ? sig.kid.trim() : ''))
377
+ .filter((k) => k.length > 0);
378
+ const declaredSignatures = signatures.filter((sig) => curatorKeys.has(typeof sig?.kid === 'string' ? sig.kid.trim() : ''));
379
+ // Same predicate as `no declared signature`, expressed through the set the next gate also needs.
380
+ if (signingKids.length > 0 && declaredSignatures.length === 0) {
381
+ return {
382
+ success: false,
383
+ error: 'Playlist is signed, but the signing key is not declared as a curator.',
384
+ message: `The feed accepts a publish when a signature's kid appears in the playlist's own curators[].\n` +
385
+ ` Add this to the playlist before signing:\n` +
386
+ ` "curators": [{ "name": "Your name", "key": "${signingKids[0]}" }]\n` +
387
+ ` then sign again from the unsigned file, with the same key you just declared:\n` +
388
+ ` ff-cli sign <file> -r ${feed_mutation_1.OWNER_ROLE} --key <private key for ${signingKids[0]}>\n` +
389
+ ` Drop --key if that key is your configured one; "sign" uses the configured key otherwise, and a\n` +
390
+ ` signature from an undeclared key would not satisfy the feed. The role is ff-cli's requirement\n` +
391
+ ` rather than every feed's: see the owner-role check for why publishing without it is refused.\n` +
392
+ ` Start from the unsigned file\n` +
393
+ ` because declaring curators[] changes the signed payload: signing appends, so the earlier\n` +
394
+ ` signature would be left covering a document that no longer exists.`,
395
+ };
396
+ }
397
+ // Step 4b: a declared key must also have signed AS curator.
398
+ //
399
+ // Being named in curators[] is a claim; signing in the owner role is the proof, and the feed requires
400
+ // both before it treats a key as an owner. A document that is declared but signed under another role
401
+ // (ff-cli's own default was `agent`) verifies cleanly and is still refused, and the server answers in
402
+ // terms of ownership — which reads as "fix curators[]", the one part that is already correct. Naming
403
+ // the role and the remedy here is the difference between a one-flag fix and a wrong-end search.
404
+ //
405
+ // This is deliberately stricter than a feed that ignores the role: such a feed accepts the document
406
+ // today and freezes it the moment role-aware ownership lands, because a replace and a delete both
407
+ // need an owner signature the document does not carry. Refusing now keeps that document from being
408
+ // created at all.
409
+ if (declaredSignatures.length > 0 && !declaredSignatures.some((sig) => sig.role === feed_mutation_1.OWNER_ROLE)) {
410
+ const rolesUsed = [
411
+ ...new Set(declaredSignatures
412
+ .map((sig) => (typeof sig.role === 'string' ? sig.role.trim() : ''))
413
+ .filter((role) => role.length > 0)),
414
+ ];
415
+ const seen = rolesUsed.length > 0 ? rolesUsed.map((role) => `"${role}"`).join(', ') : 'no role';
416
+ // List every key eligible to repair this, not the one that happens to have signed.
417
+ //
418
+ // The gate above accepts an owner-role signature from ANY declared key, so on a playlist declaring
419
+ // several curators the holder of any one of them can append it. Naming only the key that signed
420
+ // under the wrong role describes an impossible fix to everyone else — they may not hold it — while
421
+ // a key they do hold would have worked. `sign` still defaults to the configured key, which need not
422
+ // be declared at all, so the eligible set has to be stated rather than assumed.
423
+ const eligibleKeys = [...curatorKeys];
424
+ const eligibleList = eligibleKeys.length > 0 ? eligibleKeys.join(', ') : 'a key declared in curators[]';
425
+ return {
426
+ success: false,
427
+ error: `Playlist is signed by a declared curator, but under a non-owner role (${seen}).`,
428
+ message: `This is ff-cli's check, not a feed's answer. Feeds are moving to role-aware ownership, where\n` +
429
+ ` a key in curators[] counts as an owner only if it also signed as "${feed_mutation_1.OWNER_ROLE}". A feed that\n` +
430
+ ` does not check the role yet will accept this document, and then be unable to authorize a\n` +
431
+ ` replace or a delete for it: both need an owner signature it does not carry. Publishing it is\n` +
432
+ ` what makes that permanent, so ff-cli refuses here instead.\n` +
433
+ `\n` +
434
+ ` curators[] is already correct — only the role is missing, so add that signature to this file:\n` +
435
+ ` ff-cli sign <file> -r ${feed_mutation_1.OWNER_ROLE} --key <private key for a declared curator>\n` +
436
+ ` Any key this playlist declares will do — you do not need the one that signed under the wrong\n` +
437
+ ` role. Declared: ${eligibleList}\n` +
438
+ ` It must be one of those: a ${feed_mutation_1.OWNER_ROLE} signature from an undeclared key satisfies neither\n` +
439
+ ` this check nor role-aware ownership, since both read roles only from declared keys. "sign"\n` +
440
+ ` uses the configured key unless --key says otherwise; drop --key if a declared key is already\n` +
441
+ ` your configured one, and confirm which identity a key carries with\n` +
442
+ ` "ff-cli status --key <private key>".\n` +
443
+ ` Signing appends, and the payload hash excludes signatures, so the existing entry stays valid\n` +
444
+ ` and the document ends up carrying both. No unsigned copy is needed: you only have to start\n` +
445
+ ` from one when changing signed content such as curators[] itself.`,
446
+ };
447
+ }
448
+ return null;
449
+ }
@@ -98,12 +98,16 @@ async function verifyPlaylist(playlist, publicKeyHex) {
98
98
  * @param {string} playlistPath - Path to playlist JSON file
99
99
  * @param {string} [privateKeyBase64] - Ed25519 private key in hex or base64 format (optional, uses config if not provided)
100
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
101
104
  * @returns {Promise<Object>} Result with signed playlist
102
105
  * @returns {boolean} returns.success - Whether signing succeeded
103
106
  * @returns {Object} [returns.playlist] - Signed playlist object
107
+ * @returns {number} [returns.droppedSignatures] - How many stale entries were discarded
104
108
  * @returns {string} [returns.error] - Error message if failed
105
109
  */
106
- async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride) {
110
+ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride, options) {
107
111
  const fs = require('fs');
108
112
  const path = require('path');
109
113
  try {
@@ -124,7 +128,12 @@ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, role
124
128
  if (!privateKey) {
125
129
  throw new Error('Private key is required for signing');
126
130
  }
127
- 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);
128
137
  const verification = await verifySignedPlaylistEnvelope(signedPlaylist, dp1);
129
138
  if (!verification.valid) {
130
139
  throw new Error(`Signed playlist verification failed: ${verification.error}`);
@@ -136,6 +145,7 @@ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, role
136
145
  return {
137
146
  success: true,
138
147
  playlist: signedPlaylist,
148
+ droppedSignatures,
139
149
  };
140
150
  }
141
151
  catch (error) {
@@ -149,6 +159,10 @@ module.exports = {
149
159
  signPlaylist,
150
160
  verifyPlaylist,
151
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,
152
166
  };
153
167
  function resolvePlaylistSigningRole(role) {
154
168
  const candidate = typeof role === 'string' ? role.trim() : '';
@@ -176,13 +190,25 @@ async function validatePlaylistForSigning(playlist) {
176
190
  * as dp1-js/dp1-go §7.1); prior `signatures[]` entries are kept on the returned
177
191
  * object so repeated `sign` runs accumulate endorsements instead of replacing them.
178
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
+ *
179
204
  * @param {Object} playlist - Parsed playlist (may already include `signatures[]`)
180
205
  * @param {string} privateKey - Private key material forwarded to dp1-js
181
206
  * @param {Object} dp1 - Loaded dp1-js module
182
207
  * @param {string} role - DP-1 signing role
208
+ * @param {boolean} [replaceSignatures=false] - Discard existing signatures instead of appending
183
209
  * @returns {Promise<Object>} Playlist with legacy `signature` cleared and merged `signatures[]`
184
210
  */
185
- async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
211
+ async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role, replaceSignatures) {
186
212
  const playlistToSign = { ...playlist };
187
213
  delete playlistToSign.signature;
188
214
  delete playlistToSign.signatures;
@@ -190,7 +216,7 @@ async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
190
216
  // seed, PEM) works and a malformed key surfaces a clear error instead of
191
217
  // dp1-js's cryptic OpenSSL ASN.1 failure ("header too long" / "wrong tag").
192
218
  const normalizedKey = normalizeSigningKeyToBase64Pkcs8(privateKey);
193
- const existingSignatures = Array.isArray(playlist.signatures)
219
+ const existingSignatures = !replaceSignatures && Array.isArray(playlist.signatures)
194
220
  ? playlist.signatures.filter((entry) => Boolean(entry))
195
221
  : [];
196
222
  if (typeof dp1.SignMultiEd25519 === 'function') {