@feralfile/cli 2.7.0 → 2.9.0

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