@feralfile/cli 2.8.0 → 2.10.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.
@@ -12,6 +12,7 @@ const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
12
12
  const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
13
13
  const signing_identity_1 = require("../utilities/signing-identity");
14
14
  const config_placeholders_1 = require("../utilities/config-placeholders");
15
+ const signing_key_source_1 = require("../utilities/signing-key-source");
15
16
  exports.statusCommand = new commander_1.Command('status')
16
17
  .description('Show configuration status')
17
18
  // Identity discovery must cover every key `sign` accepts, not just the configured one. `sign --key`
@@ -19,9 +20,34 @@ exports.statusCommand = new commander_1.Command('status')
19
20
  // user the wrong did:key to declare in curators[] — and a wrong declaration fails exactly like a
20
21
  // missing one.
21
22
  .option('-k, --key <privateKey>', 'Report the signing identity for this key instead of the configured one')
23
+ .option('--key-file <path>', 'Read the key to report on from this file instead of the command line')
22
24
  .action(async (options) => {
23
25
  try {
24
- const overrideKey = options.key?.trim();
26
+ // An unusable key file is reported here rather than allowed to fall through. This command's whole
27
+ // job is to answer "which identity is this?", so silently answering about the configured key
28
+ // instead — which an empty file would do, since the check below is truthiness — would hand back a
29
+ // did:key that belongs to something else and looks exactly like the right answer.
30
+ let explicitKey;
31
+ try {
32
+ explicitKey = (0, signing_key_source_1.resolveExplicitSigningKey)(options);
33
+ }
34
+ catch (error) {
35
+ console.log(chalk_1.default.red(error.message));
36
+ process.exit(1);
37
+ }
38
+ const overrideKey = explicitKey?.material.trim();
39
+ // Presence, not truthiness — the same rule `sign` and the feed mutations enforce, and the reason
40
+ // it is enforced here too. `--key ""` is what `--key "$SIGNING_KEY"` becomes when the variable is
41
+ // unset, and falling through to the configured identity answers "whose key is this?" with a
42
+ // different key's did:key. Nothing about that answer looks wrong, so it would be copied into
43
+ // curators[], and a wrong declaration fails exactly like a missing one.
44
+ if (explicitKey !== undefined && !overrideKey) {
45
+ console.log(chalk_1.default.red(`The ${explicitKey.flag} value is empty. This usually means a shell variable did not ` +
46
+ `expand (for example ${explicitKey.flag} "$SIGNING_KEY" with SIGNING_KEY unset).`));
47
+ console.log(chalk_1.default.yellow(' Refusing rather than reporting the configured identity, which belongs to a different\n' +
48
+ ' key. Run "ff-cli status" with no key flag if that is the identity you wanted.'));
49
+ process.exit(1);
50
+ }
25
51
  if (overrideKey) {
26
52
  try {
27
53
  console.log(chalk_1.default.green(`Signing identity (did:key) ${(0, signing_identity_1.playlistSigningDidKey)(overrideKey)}`));
@@ -29,7 +55,7 @@ exports.statusCommand = new commander_1.Command('status')
29
55
  return;
30
56
  }
31
57
  catch (error) {
32
- console.log(chalk_1.default.red(`--key unusable: ${error.message}`));
58
+ console.log(chalk_1.default.red(`${explicitKey?.flag ?? '--key'} unusable: ${error.message}`));
33
59
  process.exit(1);
34
60
  }
35
61
  }
@@ -8,24 +8,57 @@ const commander_1 = require("commander");
8
8
  const chalk_1 = __importDefault(require("chalk"));
9
9
  const feed_server_1 = require("./helpers/feed-server");
10
10
  const prompt_1 = require("./helpers/prompt");
11
- /**
12
- * `ff-cli unpublish <id-or-url>` — delete a playlist this key owns from a feed.
13
- *
14
- * The delete is irreversible in a way most deletes are not: the feed tombstones the id, so the playlist
15
- * cannot be restored and the id can never be reused. Hence the confirmation, which shows the title and
16
- * server first, and defaults to no. `-y` skips it for scripts; without a terminal and without `-y` the
17
- * command refuses rather than assuming consent.
18
- */
19
11
  exports.unpublishCommand = new commander_1.Command('unpublish')
20
12
  .description('Delete a playlist from a feed server (signed delete; owner key required)')
21
13
  .argument('<id-or-url>', 'Playlist id, slug, or feed URL')
22
14
  .option('-s, --server <index>', 'Feed server index (use this if multiple servers configured)')
23
15
  .option('-y, --yes', 'Skip the confirmation prompt')
16
+ .option('-k, --key <privateKey>', 'Ed25519 private key that signs the delete authorization (overrides config)')
17
+ .option('--key-file <path>', 'Read the key that signs the delete authorization from this file, not the command line')
24
18
  .action(async (idOrUrl, options) => {
25
19
  try {
26
20
  console.log(chalk_1.default.blue('\nUnpublish playlist\n'));
27
21
  const { getFeedConfig } = await import('../config.js');
28
22
  const { unpublishPlaylist, fetchPlaylistForUnpublish } = await import('../utilities/playlist-unpublisher.js');
23
+ // Resolve the signing credential ONCE, before anything else touches the network or the operator,
24
+ // and carry the material itself forward.
25
+ //
26
+ // This runs before the server is chosen, not after. With several feeds configured, selecting one
27
+ // is a question put to the operator, and asking it only to reject the key afterwards spends their
28
+ // attention on a run that could never have completed. A credential the command already holds is
29
+ // checkable without asking anybody anything, so it is checked first.
30
+ //
31
+ // Deriving it here means a malformed or empty --key fails before the lookup and before the
32
+ // confirmation prompt — otherwise someone was shown a playlist, asked to approve destroying it,
33
+ // and only then told their key was unusable.
34
+ //
35
+ // Passing the resolved material on, rather than letting unpublishPlaylist resolve again, closes a
36
+ // narrower gap: the prompt can stay open indefinitely, and a second resolution would re-read
37
+ // config.json at send time. A config edited in that window would sign the delete under an
38
+ // identity other than the one the operator saw and approved — and a delete tombstones the id.
39
+ // The DID displayed below and the key that signs are now the same value.
40
+ //
41
+ // Only the did:key is ever printed; the material never reaches the output from this path.
42
+ //
43
+ // `--key-file` is read here too, in the same step and before the same milestones: a key file that
44
+ // is missing, unreadable, or empty is a credential failure like any other, and it must not be
45
+ // discovered after the operator has approved a delete.
46
+ const { mutationSignerIdentity } = await import('../utilities/feed-mutation.js');
47
+ const { resolveExplicitSigningKey } = await import('../utilities/signing-key-source.js');
48
+ let signerDidKey;
49
+ let signingKey;
50
+ let keySource = 'configured';
51
+ try {
52
+ const explicitKey = resolveExplicitSigningKey(options);
53
+ keySource = explicitKey?.flag ?? 'configured';
54
+ ({ privateKey: signingKey, didKey: signerDidKey } = mutationSignerIdentity(explicitKey?.material, 'delete'));
55
+ }
56
+ catch (error) {
57
+ console.error(chalk_1.default.red('\nCannot sign the delete'));
58
+ console.error(chalk_1.default.red(` ${error.message}`));
59
+ console.log();
60
+ process.exit(1);
61
+ }
29
62
  const feedConfig = getFeedConfig();
30
63
  const selection = await (0, feed_server_1.selectFeedServer)(feedConfig.baseURLs, {
31
64
  serverArg: options.server,
@@ -59,6 +92,7 @@ exports.unpublishCommand = new commander_1.Command('unpublish')
59
92
  }
60
93
  console.log(chalk_1.default.yellow(` ${label}`));
61
94
  console.log(chalk_1.default.dim(` Server: ${selection.url}`));
95
+ console.log(chalk_1.default.dim(` Signing as: ${signerDidKey}`));
62
96
  console.log(chalk_1.default.dim(' This cannot be undone; the id is tombstoned and cannot be reused.'));
63
97
  console.log();
64
98
  const prompt = (0, prompt_1.createPrompt)();
@@ -70,7 +104,16 @@ exports.unpublishCommand = new commander_1.Command('unpublish')
70
104
  return;
71
105
  }
72
106
  }
73
- const result = await unpublishPlaylist(idOrUrl, selection.url);
107
+ // `Signing as` is printed on both paths, not only before the prompt: under -y it is the only
108
+ // record of which identity performed an irreversible delete, and it is what the assertion in the
109
+ // tests ties to the signature on the wire.
110
+ if (options.yes) {
111
+ console.log(chalk_1.default.dim(` Signing as: ${signerDidKey}`));
112
+ }
113
+ const result = await unpublishPlaylist(idOrUrl, selection.url, {
114
+ privateKey: signingKey,
115
+ keySource,
116
+ });
74
117
  if (result.success) {
75
118
  console.log(chalk_1.default.green('Unpublished'));
76
119
  if (result.playlistId) {
@@ -25,12 +25,16 @@ exports.documentPayloadHash = documentPayloadHash;
25
25
  exports.signIntent = signIntent;
26
26
  exports.resolvePlaylistIdentifier = resolvePlaylistIdentifier;
27
27
  exports.fetchStoredPlaylist = fetchStoredPlaylist;
28
+ exports.resolveMutationSigningKey = resolveMutationSigningKey;
29
+ exports.mutationSignerIdentity = mutationSignerIdentity;
28
30
  exports.storedOwnerKeys = storedOwnerKeys;
29
31
  exports.storedOwnership = storedOwnership;
30
32
  exports.ownershipPreflight = ownershipPreflight;
31
33
  exports.describeFeedMutationError = describeFeedMutationError;
32
34
  exports.asPlaylist = asPlaylist;
33
35
  const axios_1 = __importDefault(require("axios"));
36
+ const config_1 = require("../config");
37
+ const signing_identity_1 = require("./signing-identity");
34
38
  // playlist-signer is still CommonJS; require keeps the interop simple.
35
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
40
  const { normalizeSigningKeyToBase64Pkcs8 } = require('./playlist-signer');
@@ -136,6 +140,58 @@ async function fetchStoredPlaylist(feedServerUrl, identifier) {
136
140
  }
137
141
  return stored;
138
142
  }
143
+ /**
144
+ * Resolve the key that signs an owner-bound intent, honouring an explicit key from the command line.
145
+ *
146
+ * `--key-file` material arrives here already read and trimmed by `signing-key-source`, which rejects an
147
+ * empty file there rather than here: the message below is about shell expansion, which is the right
148
+ * diagnosis for a value typed at the prompt and the wrong one for a file that turned out to be empty.
149
+ *
150
+ * **Presence, never truthiness.** `--key ""` — the shape an unset shell variable takes,
151
+ * `--key "$SIGNING_KEY"` with nothing in it — is an override that FAILED, not an absent one. Treating it
152
+ * as absent silently fell back to the configured key, so a delete meant to be authorized by one identity
153
+ * was authorized by another and tombstoned the playlist under it. There is no recovering from that: the
154
+ * id is retired. So anything the caller actually passed goes through validation and is rejected there;
155
+ * only `undefined` means "use the configured key".
156
+ *
157
+ * @param override - Value of `--key` exactly as the command received it
158
+ * @param action - Which mutation is being authorized, for the no-key-configured message
159
+ * @returns Key material for signing
160
+ * @throws Error when the override is empty, or when nothing is configured and none was given
161
+ */
162
+ function resolveMutationSigningKey(override, action) {
163
+ if (override !== undefined) {
164
+ if (override.trim().length === 0) {
165
+ throw new Error('The --key value is empty. This usually means a shell variable did not expand ' +
166
+ '(for example --key "$SIGNING_KEY" with SIGNING_KEY unset). Refusing rather than falling ' +
167
+ 'back to the configured key: a signature made by the wrong identity is not something a ' +
168
+ `${action} can be taken back from.`);
169
+ }
170
+ return override;
171
+ }
172
+ const configured = (0, config_1.getPlaylistConfig)().privateKey;
173
+ if (!configured) {
174
+ throw new Error(`No playlist signing key is configured. A ${action} is authorized by a signature, so one is ` +
175
+ 'required: run "ff-cli setup", set playlist.privateKey in config.json, or pass --key / ' +
176
+ '--key-file for this run.');
177
+ }
178
+ return configured;
179
+ }
180
+ /**
181
+ * Identity a mutation will sign under, resolved and validated together.
182
+ *
183
+ * Commands call this before doing anything else so a bad credential fails before any request — and, for
184
+ * `unpublish`, before the operator is asked to confirm a delete they could not have completed. The key
185
+ * material never appears in what comes back: only the `did:key` it derives to.
186
+ *
187
+ * @param override - Value of `--key` exactly as the command received it
188
+ * @param action - Which mutation is being authorized
189
+ * @returns The key material and the `did:key` it asserts
190
+ */
191
+ function mutationSignerIdentity(override, action) {
192
+ const privateKey = resolveMutationSigningKey(override, action);
193
+ return { privateKey, didKey: (0, signing_identity_1.playlistSigningDidKey)(privateKey) };
194
+ }
139
195
  /** Owner `did:key`s the stored playlist declares, in declaration order. */
140
196
  function storedOwnerKeys(stored) {
141
197
  const curators = Array.isArray(stored.curators) ? stored.curators : [];
@@ -179,13 +235,34 @@ async function storedOwnership(stored) {
179
235
  catch {
180
236
  // An entry that does not verify is not proof. It is also not this command's business to report:
181
237
  // a stale or tampered signature on someone else's key changes nothing about whether the
182
- // configured key can act, and the answer below is derived from the set that survived.
238
+ // signing key can act, and the answer below is derived from the set that survived.
183
239
  }
184
240
  }
185
241
  return { declared, proven };
186
242
  }
243
+ /** How a private key is named as the argument to the flag it arrived on. */
244
+ function flagArgument(keySource) {
245
+ return keySource === '--key-file' ? 'file holding the private key' : 'private key';
246
+ }
247
+ /** How to describe the identity in a refusal, given where it came from. */
248
+ function identityLabel(keySource) {
249
+ return keySource === 'configured'
250
+ ? 'Your configured identity:'
251
+ : `The identity you passed with ${keySource}:`;
252
+ }
253
+ /** What to do about it, given where it came from. */
254
+ function retryAdvice(keySource, plural) {
255
+ const which = plural ? 'one of the keys listed above' : 'the key listed above';
256
+ if (keySource === 'configured') {
257
+ return (` Point playlist.privateKey at ${which}, or pass one for this run with --key (confirm any\n` +
258
+ ` key's identity with "ff-cli status --key <private key>").`);
259
+ }
260
+ const argument = flagArgument(keySource);
261
+ return (` Run it again with an owner key: ${keySource} <${argument} for ${which}>.\n` +
262
+ ` (Confirm which identity a key carries with "ff-cli status ${keySource} <${argument}>".)`);
263
+ }
187
264
  /**
188
- * Refuse locally when the configured key cannot authorize a mutation on the stored playlist.
265
+ * Refuse locally when the signing key cannot authorize a mutation on the stored playlist.
189
266
  *
190
267
  * The feed's own answer to every case here is a bare `403 forbidden`, which says nothing about which
191
268
  * identity was offered, which ones would have worked, or whether the problem is the declaration or the
@@ -194,11 +271,11 @@ async function storedOwnership(stored) {
194
271
  * declaration is right and the signature that would back it was never made.
195
272
  *
196
273
  * @param stored - The playlist as the feed serves it
197
- * @param signerDidKey - `did:key` the configured signing key will assert
274
+ * @param signerDidKey - `did:key` the signing key will assert
198
275
  * @param action - Which mutation is being authorized, for the wording
199
- * @returns A failure to report, or `null` when the configured key is a proven owner.
276
+ * @returns A failure to report, or `null` when the signing key is a proven owner.
200
277
  */
201
- async function ownershipPreflight(stored, signerDidKey, action) {
278
+ async function ownershipPreflight(stored, signerDidKey, action, keySource = 'configured') {
202
279
  const { declared, proven } = await storedOwnership(stored);
203
280
  if (proven.includes(signerDidKey)) {
204
281
  return null;
@@ -213,7 +290,7 @@ async function ownershipPreflight(stored, signerDidKey, action) {
213
290
  error: `This playlist declares no owners, so no key can ${verb} it.`,
214
291
  message: `The feed derives ownership from the stored document's curators[], and this one is empty — the\n` +
215
292
  ` shape a playlist published without an owner-role signature is frozen in. No signature can\n` +
216
- ` authorize a ${verb}, including yours (${signerDidKey}).\n` +
293
+ ` authorize a ${verb}, including this one (${signerDidKey}).\n` +
217
294
  ` Nothing can repair it: the owner set is immutable, and only an owner could change it. Publish\n` +
218
295
  ` a corrected playlist under a new id instead.`,
219
296
  };
@@ -240,30 +317,33 @@ async function ownershipPreflight(stored, signerDidKey, action) {
240
317
  // it is simply not the one configured here.
241
318
  if (declared.includes(signerDidKey)) {
242
319
  return {
243
- error: `Your key is declared on this playlist but never signed it as ${exports.OWNER_ROLE}, so it cannot ${verb} it.`,
320
+ error: `This key is declared on this playlist but never signed it as ${exports.OWNER_ROLE}, so it cannot ${verb} it.`,
244
321
  message: `The feed treats a declared key as an owner only once it has also signed the stored document\n` +
245
- ` as "${exports.OWNER_ROLE}". Yours is named in curators[] but carries no such signature, so an intent\n` +
246
- ` signed with it would be refused.\n` +
247
- ` Your configured identity:\n` +
322
+ ` as "${exports.OWNER_ROLE}". This one is named in curators[] but carries no such signature, so an\n` +
323
+ ` intent signed with it would be refused.\n` +
324
+ ` ${identityLabel(keySource)}\n` +
248
325
  ` ${signerDidKey}\n` +
249
326
  ` Keys that have proved ownership:\n` +
250
327
  `${proven.map((key) => ` ${key}`).join('\n')}\n` +
251
- ` Point playlist.privateKey at one of those (confirm any key's identity with\n` +
252
- ` "ff-cli status --key <private key>"). Your own declaration cannot be upgraded after the fact:\n` +
253
- ` the proof would have to be a signature over the document as published.`,
328
+ `${retryAdvice(keySource, proven.length > 1)}\n` +
329
+ ` The declaration cannot be upgraded after the fact: the proof would have to be a signature\n` +
330
+ ` over the document as published.`,
254
331
  };
255
332
  }
333
+ const source = keySource === 'configured'
334
+ ? 'The configured signing key is'
335
+ : `The key you passed with ${keySource} is`;
256
336
  return {
257
- error: `The configured signing key is not an owner of this playlist, so it cannot ${verb} it.`,
337
+ error: `${source} not an owner of this playlist, so it cannot ${verb} it.`,
258
338
  message: `Only a key the stored playlist names in curators[] AND that signed it as "${exports.OWNER_ROLE}" can\n` +
259
339
  ` authorize a ${verb}; the feed derives ownership from the stored document, not from a local copy.\n` +
260
- ` Your configured identity:\n` +
340
+ ` ${identityLabel(keySource)}\n` +
261
341
  ` ${signerDidKey}\n` +
262
342
  ` Keys that have proved ownership:\n` +
263
343
  `${proven.map((key) => ` ${key}`).join('\n')}\n` +
264
- ` Point playlist.privateKey at a key listed above (confirm any key's identity with\n` +
265
- ` "ff-cli status --key <private key>"). Ownership cannot be granted after the fact: the owner set\n` +
266
- ` is immutable, so a playlist signed by the wrong key stays that way.`,
344
+ `${retryAdvice(keySource, proven.length > 1)}\n` +
345
+ ` Ownership cannot be granted after the fact: the owner set is immutable, so a playlist signed\n` +
346
+ ` by the wrong key stays that way.`,
267
347
  };
268
348
  }
269
349
  /** Feed error code carried in an error response body, when there is one. */
@@ -290,8 +370,17 @@ function feedErrorDetail(error) {
290
370
  *
291
371
  * The codes handled are exactly those dp1-feed-v2 documents for these routes; anything else falls
292
372
  * through with the server's own words rather than being guessed at.
373
+ *
374
+ * `keySource` matters here for the same reason it does locally: two of these messages talk about the
375
+ * signing identity, and calling it "configured" when the operator passed `--key` describes a file this
376
+ * run never read. Worse, after a 403 it reads as though the override had been dropped — which is
377
+ * precisely the doubt an operator should not be left with when their key was used and refused.
378
+ *
379
+ * @param error - The failure thrown by the request
380
+ * @param action - Which mutation was attempted
381
+ * @param keySource - Where the signing identity came from
293
382
  */
294
- function describeFeedMutationError(error, action) {
383
+ function describeFeedMutationError(error, action, keySource = 'configured') {
295
384
  const axiosError = error;
296
385
  const status = axiosError.response?.status;
297
386
  const code = feedErrorCode(axiosError);
@@ -305,19 +394,26 @@ function describeFeedMutationError(error, action) {
305
394
  return {
306
395
  error: `${verb} refused: the feed saw no signatures on the request.`,
307
396
  message: `Every mutating request is authorized by the signatures in its body — there is no API key.\n` +
308
- ` This usually means no signing key is configured: run "ff-cli status" to check, and\n` +
309
- ` "ff-cli setup" to generate one.${suffix}`,
397
+ (keySource !== 'configured'
398
+ ? ` The key passed with ${keySource} produced no signature the feed could read, which should not\n` +
399
+ ` happen once it has been accepted locally — please report this.\n`
400
+ : ` This usually means no signing key is configured: run "ff-cli status" to check, and\n` +
401
+ ` "ff-cli setup" to generate one.\n`) +
402
+ ` ${suffix.trim() || 'The feed gave no further detail.'}`,
310
403
  };
311
404
  }
312
- // A 403 that reaches here has already passed the local ownership proof: the configured key was found
405
+ // A 403 that reaches here has already passed the local ownership proof: the key that signed was found
313
406
  // in the stored curators[] with a valid curator-role signature over the stored bytes. Repeating "your
314
407
  // key is not declared" would therefore be a lie, and it is the wrong place to send someone — the
315
408
  // remaining causes are the feed disagreeing about the stored document, or a replace touching the
316
409
  // owner set. Say that the feed refused, and that the local check disagreed.
317
410
  if (status === 403) {
411
+ const which = keySource === 'configured'
412
+ ? 'the configured signing key is'
413
+ : `the key you passed with ${keySource} is`;
318
414
  return {
319
415
  error: `${verb} refused by the feed: it did not accept the signing key as an owner.`,
320
- message: `The local check disagreed — the configured key is named in the stored playlist's curators[]\n` +
416
+ message: `The local check disagreed — ${which} named in the stored playlist's curators[]\n` +
321
417
  ` and carries a valid "${exports.OWNER_ROLE}" signature over the stored document — so this is the feed's\n` +
322
418
  ` own judgement, not a missing declaration.\n` +
323
419
  (action === 'replace'
@@ -8,9 +8,7 @@ exports.replacePlaylist = replacePlaylist;
8
8
  const axios_1 = __importDefault(require("axios"));
9
9
  const fs_1 = __importDefault(require("fs"));
10
10
  const playlist_verifier_1 = require("./playlist-verifier");
11
- const config_1 = require("../config");
12
11
  const feed_mutation_1 = require("./feed-mutation");
13
- const signing_identity_1 = require("./signing-identity");
14
12
  /**
15
13
  * Publish a verified playlist to a DP-1 feed server
16
14
  *
@@ -126,11 +124,16 @@ async function replacePlaylist(filePath, feedServerUrl, options = {}) {
126
124
  message: 'A replace targets an existing resource by its id. Publish it without --replace to create it.',
127
125
  };
128
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 ? '--key' : '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.
129
133
  let privateKey;
130
134
  let signerDidKey;
131
135
  try {
132
- privateKey = resolveIntentSigningKey(options.privateKey);
133
- signerDidKey = (0, signing_identity_1.playlistSigningDidKey)(privateKey);
136
+ ({ privateKey, didKey: signerDidKey } = (0, feed_mutation_1.mutationSignerIdentity)(options.privateKey, 'replace'));
134
137
  }
135
138
  catch (error) {
136
139
  return { success: false, error: error.message };
@@ -140,7 +143,7 @@ async function replacePlaylist(filePath, feedServerUrl, options = {}) {
140
143
  stored = await (0, feed_mutation_1.fetchStoredPlaylist)(feedServerUrl, documentId);
141
144
  }
142
145
  catch (error) {
143
- const described = (0, feed_mutation_1.describeFeedMutationError)(error, 'replace');
146
+ const described = (0, feed_mutation_1.describeFeedMutationError)(error, 'replace', keySource);
144
147
  const missing = error.response?.status === 404;
145
148
  return {
146
149
  success: false,
@@ -161,7 +164,7 @@ async function replacePlaylist(filePath, feedServerUrl, options = {}) {
161
164
  if (mismatch) {
162
165
  return { success: false, ...mismatch, feedServer: feedServerUrl };
163
166
  }
164
- const ownership = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'replace');
167
+ const ownership = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'replace', keySource);
165
168
  if (ownership) {
166
169
  return { success: false, ...ownership, feedServer: feedServerUrl };
167
170
  }
@@ -189,7 +192,7 @@ async function replacePlaylist(filePath, feedServerUrl, options = {}) {
189
192
  catch (error) {
190
193
  return {
191
194
  success: false,
192
- ...(0, feed_mutation_1.describeFeedMutationError)(error, 'replace'),
195
+ ...(0, feed_mutation_1.describeFeedMutationError)(error, 'replace', keySource),
193
196
  feedServer: feedServerUrl,
194
197
  };
195
198
  }
@@ -220,7 +223,11 @@ function replaceIdentityMismatch(playlist, stored) {
220
223
  }
221
224
  const documentOwners = (0, feed_mutation_1.storedOwnerKeys)(playlist);
222
225
  const owners = (0, feed_mutation_1.storedOwnerKeys)(stored);
223
- if (documentOwners.join('') !== owners.join('')) {
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) {
224
231
  differences.push(` curators: stored [${owners.join(', ')}], document [${documentOwners.join(', ')}]`);
225
232
  }
226
233
  if (differences.length === 0) {
@@ -254,24 +261,6 @@ function sameInstant(left, right) {
254
261
  const b = Date.parse(right);
255
262
  return Number.isFinite(a) && Number.isFinite(b) && a === b;
256
263
  }
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
264
  /**
276
265
  * readPlaylistFile reads and parses a playlist document from disk.
277
266
  *