@feralfile/cli 2.8.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.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.enrichCommand = void 0;
7
+ exports.writePlaylistAtomically = writePlaylistAtomically;
7
8
  const commander_1 = require("commander");
8
9
  const chalk_1 = __importDefault(require("chalk"));
9
10
  const fs_1 = require("fs");
@@ -11,6 +12,12 @@ const crypto_1 = require("crypto");
11
12
  const path_1 = require("path");
12
13
  const enrich_playlist_1 = require("../utilities/enrich-playlist");
13
14
  const playlist_verifier_1 = require("../utilities/playlist-verifier");
15
+ // Rename semantics, not inode identity. This command writes a temp file and renames it over the
16
+ // destination, and rename() replaces a directory ENTRY: onto a hard link it repoints that one name and
17
+ // leaves the input's own name on the untouched original inode. So a hard-link -o is a genuinely
18
+ // distinct output here, and treating it as in-place makes the stale-input guard below refuse a write
19
+ // that was never unsafe. The signer's direct-write path needs the opposite predicate; see same-file.ts.
20
+ const same_file_1 = require("../utilities/same-file");
14
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
15
22
  const { resolveTokenBatch } = require('../utilities/nft-indexer');
16
23
  /**
@@ -28,6 +35,10 @@ const { resolveTokenBatch } = require('../utilities/nft-indexer');
28
35
  * The temporary file is created alongside the destination rather than in the
29
36
  * system temp directory: rename is only atomic within a filesystem, and those
30
37
  * are not guaranteed to be the same one.
38
+ *
39
+ * Exported for tests: whether a destination counts as the input depends on this
40
+ * function's rename semantics, and pairing it with the wrong predicate is a
41
+ * mistake that has already been made once.
31
42
  */
32
43
  async function writePlaylistAtomically(destination, contents, expectedDigest) {
33
44
  // Follow a symlink to its target before replacing anything. rename() would
@@ -132,23 +143,6 @@ class OwnershipError extends Error {
132
143
  this.name = 'OwnershipError';
133
144
  }
134
145
  }
135
- /**
136
- * isSameFile reports whether two paths name the same file on disk.
137
- *
138
- * Compares resolved paths rather than the strings the caller typed, so
139
- * `-o ./playlist.json` against `playlist.json`, and a symlink against its
140
- * target, are recognized as the in-place case they are.
141
- */
142
- async function isSameFile(a, b) {
143
- if (a === b) {
144
- return true;
145
- }
146
- const [left, right] = await Promise.all([
147
- fs_1.promises.realpath(a).catch(() => null),
148
- fs_1.promises.realpath(b).catch(() => null),
149
- ]);
150
- return left !== null && left === right;
151
- }
152
146
  /**
153
147
  * syncDirectory flushes a directory entry so a rename survives power loss.
154
148
  *
@@ -284,7 +278,7 @@ exports.enrichCommand = new commander_1.Command('enrich')
284
278
  if (!after.valid) {
285
279
  reportInvalid('Enrichment produced an invalid playlist:', after);
286
280
  }
287
- const replaced = await writePlaylistAtomically(destination, JSON.stringify(result.playlist, null, 2), (await isSameFile(file, destination)) ? originalDigest : undefined);
281
+ const replaced = await writePlaylistAtomically(destination, JSON.stringify(result.playlist, null, 2), (await (0, same_file_1.isSameEntryForRename)(destination, file)) ? originalDigest : undefined);
288
282
  if (!replaced) {
289
283
  console.error(chalk_1.default.red('\nThat playlist changed while the lookup ran.'));
290
284
  console.error(chalk_1.default.dim(' Enrichment was computed from the earlier version, so writing it\n' +
@@ -12,11 +12,49 @@ exports.publishCommand = new commander_1.Command('publish')
12
12
  .argument('<file>', 'Path to the playlist file')
13
13
  .option('-s, --server <index>', 'Feed server index (use this if multiple servers configured)')
14
14
  .option('--replace', 'Replace the playlist already stored under this document id, instead of creating a new one')
15
+ .option('-k, --key <privateKey>', 'Ed25519 private key that signs the replace authorization (overrides config; --replace only)')
15
16
  .action(async (file, options) => {
16
17
  try {
18
+ // A plain publish signs nothing at request time — it uploads the signatures[] envelope the
19
+ // document already carries — so --key would do nothing here. Refusing beats accepting it
20
+ // silently: someone passing a key believes it is being used, and a no-op flag on a command
21
+ // that writes to a feed is the kind of quiet lie this CLI has been removing.
22
+ //
23
+ // Tested for PRESENCE, not truthiness. `--key ""` is what an unset shell variable expands to,
24
+ // and it is still a key the user meant to supply; skipping the refusal for it published the
25
+ // document while leaving them believing a key had been checked.
26
+ if (options.key !== undefined && !options.replace) {
27
+ console.error(chalk_1.default.red('\n--key has no effect on a plain publish'));
28
+ console.log(chalk_1.default.yellow(' A publish is authorized by the signatures already inside the document; the command\n' +
29
+ ' signs nothing. Sign the file first, with the key you meant:\n' +
30
+ ' ff-cli sign <file> -r curator --key <private key>\n' +
31
+ ' --key applies to "publish --replace", which signs an authorization intent.\n'));
32
+ process.exit(1);
33
+ }
17
34
  console.log(chalk_1.default.blue(options.replace ? '\nReplace playlist\n' : '\nPublish playlist\n'));
18
35
  const { getFeedConfig } = await import('../config.js');
19
36
  const { publishPlaylist, replacePlaylist } = await import('../utilities/playlist-publisher.js');
37
+ // Validate the signing credential before the server is chosen, for a replace.
38
+ //
39
+ // Selecting between several configured feeds is a question put to the operator; asking it and
40
+ // only then rejecting their key spends their attention on a run that could never have completed.
41
+ // The credential is already in hand, so it is checked without asking anybody anything.
42
+ //
43
+ // Only for --replace: a plain publish signs nothing at request time and must keep working with
44
+ // no key configured at all, so requiring one here would break it.
45
+ let replaceKey;
46
+ if (options.replace) {
47
+ const { mutationSignerIdentity } = await import('../utilities/feed-mutation.js');
48
+ try {
49
+ replaceKey = mutationSignerIdentity(options.key, 'replace').privateKey;
50
+ }
51
+ catch (error) {
52
+ console.error(chalk_1.default.red('\nCannot sign the replacement'));
53
+ console.error(chalk_1.default.red(` ${error.message}`));
54
+ console.log();
55
+ process.exit(1);
56
+ }
57
+ }
20
58
  const feedConfig = getFeedConfig();
21
59
  const selection = await (0, feed_server_1.selectFeedServer)(feedConfig.baseURLs, { serverArg: options.server });
22
60
  if (!selection.ok) {
@@ -31,7 +69,11 @@ exports.publishCommand = new commander_1.Command('publish')
31
69
  // loudly on a duplicate id, a replace overwrites a published document — and an operator who typed
32
70
  // neither flag meant the safe one.
33
71
  const result = options.replace
34
- ? await replacePlaylist(file, selection.url)
72
+ ? await replacePlaylist(file, selection.url, {
73
+ // The material resolved above, so the identity validated here is the one that signs.
74
+ privateKey: replaceKey,
75
+ keySource: options.key !== undefined ? 'supplied' : 'configured',
76
+ })
35
77
  : await publishPlaylist(file, selection.url);
36
78
  if (result.success) {
37
79
  console.log(chalk_1.default.green(options.replace ? 'Replaced' : 'Published'));
@@ -16,20 +16,88 @@ exports.signCommand = new commander_1.Command('sign')
16
16
  .option('-r, --role <role>', 'DP-1 signing role (overrides config)')
17
17
  .option('-o, --output <file>', 'Output file path (defaults to overwriting input file)')
18
18
  .option('--replace-signatures', 'Discard the existing signatures and sign fresh (use after editing a signed playlist)')
19
+ .option('--force', 'Overwrite an output file that holds a different document (refused without this)')
19
20
  .action(async (file, options) => {
20
21
  try {
21
22
  console.log(chalk_1.default.blue('\nSign playlist\n'));
22
23
  const result = await signPlaylistFile(file, options.key, options.output, options.role, {
23
24
  replaceSignatures: !!options.replaceSignatures,
25
+ force: !!options.force,
24
26
  });
25
27
  if (result.success) {
26
28
  console.log(chalk_1.default.green('\nPlaylist signed'));
27
- // Report the discard explicitly. Dropping an entry is a real change to the document —
28
- // someone else's endorsement may have been in it — and staying silent would make a fresh
29
- // sign indistinguishable from an appending one.
30
- if (result.droppedSignatures > 0) {
31
- const plural = result.droppedSignatures === 1 ? '' : 's';
32
- console.log(chalk_1.default.dim(` Replaced ${result.droppedSignatures} existing signature${plural}`));
29
+ // Name the casualty. --force overwrote a document this command never read, and the only
30
+ // record that it existed is now this line.
31
+ if (result.overwroteAnother) {
32
+ console.log(chalk_1.default.yellow(` Overwrote a different document at ${result.outputPath} (--force)`));
33
+ }
34
+ // Name every discarded entry, and say only what was actually checked.
35
+ //
36
+ // Your own earlier signature is free: this command replaces it. For the rest the only
37
+ // establishable fact is whether the entry verifies against the document as it stands —
38
+ // "void" would assert it verified against the PREVIOUS content, which was edited in place
39
+ // and exists nowhere by the time this runs. A failed verification is equally consistent
40
+ // with an edit, with a tampered entry, and with one that was never valid, so it is reported
41
+ // as unverified rather than diagnosed.
42
+ //
43
+ // Roles are carried through rather than assumed: a document may hold `agent`,
44
+ // `institution`, or `licensor` entries, and a `feed` role is not special-cased because any
45
+ // key can emit one and this CLI has no feed identity to check a kid against.
46
+ const dropped = result.dropped ?? [];
47
+ if (dropped.length > 0) {
48
+ const plural = dropped.length === 1 ? '' : 's';
49
+ console.log(chalk_1.default.dim(` Replaced ${dropped.length} existing signature${plural}:`));
50
+ for (const entry of dropped) {
51
+ console.log(chalk_1.default.dim(` - ${entry.label}`));
52
+ }
53
+ // Everything this run did not supersede. A same-key entry in another role belongs here:
54
+ // the fresh signature asserts a different role, so that entry is gone and not reinstated.
55
+ const others = dropped.filter((entry) => entry.kind !== 'replaced' && entry.checkable);
56
+ const stillValid = others.filter((entry) => entry.verified);
57
+ const unverified = others.filter((entry) => !entry.verified);
58
+ if (stillValid.length > 0) {
59
+ const noun = stillValid.length === 1 ? 'signature' : 'signatures';
60
+ const it = stillValid.length === 1 ? 'it' : 'them';
61
+ // Say where they still exist. Only two cases reach here: an --output run, where the
62
+ // input is untouched and holds them; or an in-place run whose still-valid entries are
63
+ // all this key's own in another role, which nobody else has to be asked for. An
64
+ // in-place run that would drop another key's still-valid signature never gets this far
65
+ // — it is refused, because that file is the only copy and its holder is the only one
66
+ // who could make another.
67
+ const where = result.inPlace
68
+ ? ` ${it.charAt(0).toUpperCase()}${it.slice(1)} ${stillValid.length === 1 ? 'was' : 'were'} made by your own key in another role; sign again to add back any you still want.`
69
+ : ` Your input file is untouched, so ${it} ${stillValid.length === 1 ? 'remains' : 'remain'} valid there.`;
70
+ console.log(chalk_1.default.yellow(` ${stillValid.length} other ${noun} still verified over this content and ` +
71
+ `${stillValid.length === 1 ? 'was' : 'were'} removed anyway.\n` +
72
+ where));
73
+ }
74
+ if (unverified.length > 0) {
75
+ const noun = unverified.length === 1 ? 'signature' : 'signatures';
76
+ // Stated as what was observed, with both explanations, and no instruction that would
77
+ // only make sense under one of them.
78
+ console.log(chalk_1.default.yellow(` ${unverified.length} other ${noun} could not be verified against this document:`));
79
+ for (const entry of unverified) {
80
+ const who = entry.kid ? `...${entry.kid.slice(-8)}` : 'unknown key';
81
+ // "claims" is not decoration: an unverified entry carrying this key's kid has not
82
+ // established whose it is, and reading it as yours is how a forgery would hide.
83
+ const mine = entry.sameKey
84
+ ? ' — your own key'
85
+ : entry.claimsSigningKey
86
+ ? ' — claims your key, unverified'
87
+ : '';
88
+ console.log(chalk_1.default.yellow(` ${who}${entry.role ? ` (${entry.role})` : ''}${mine}`));
89
+ }
90
+ console.log(chalk_1.default.yellow(` That is consistent with the content having changed since they were made, and\n` +
91
+ ` equally with their never having been valid — this command only has the document\n` +
92
+ ` as it stands, so it cannot tell which. If you want those signatures on what you\n` +
93
+ ` publish, their holders have to sign this document; signing appends, so they can\n` +
94
+ ` add to this file without disturbing yours.`));
95
+ }
96
+ if (dropped.some((entry) => !entry.checkable)) {
97
+ console.log(chalk_1.default.yellow(` A legacy flat signature carries no kid or role, so nothing here can judge it.`));
98
+ }
99
+ // The general fact, never a claim about a specific entry above.
100
+ console.log(chalk_1.default.dim(` A feed appends its own signature again after it verifies a replacement.`));
33
101
  }
34
102
  if (Array.isArray(result.playlist?.signatures)) {
35
103
  console.log(chalk_1.default.dim(` Signatures: ${result.playlist.signatures.length}`));
@@ -21,11 +21,43 @@ exports.unpublishCommand = new commander_1.Command('unpublish')
21
21
  .argument('<id-or-url>', 'Playlist id, slug, or feed URL')
22
22
  .option('-s, --server <index>', 'Feed server index (use this if multiple servers configured)')
23
23
  .option('-y, --yes', 'Skip the confirmation prompt')
24
+ .option('-k, --key <privateKey>', 'Ed25519 private key that signs the delete authorization (overrides config)')
24
25
  .action(async (idOrUrl, options) => {
25
26
  try {
26
27
  console.log(chalk_1.default.blue('\nUnpublish playlist\n'));
27
28
  const { getFeedConfig } = await import('../config.js');
28
29
  const { unpublishPlaylist, fetchPlaylistForUnpublish } = await import('../utilities/playlist-unpublisher.js');
30
+ // Resolve the signing credential ONCE, before anything else touches the network or the operator,
31
+ // and carry the material itself forward.
32
+ //
33
+ // This runs before the server is chosen, not after. With several feeds configured, selecting one
34
+ // is a question put to the operator, and asking it only to reject the key afterwards spends their
35
+ // attention on a run that could never have completed. A credential the command already holds is
36
+ // checkable without asking anybody anything, so it is checked first.
37
+ //
38
+ // Deriving it here means a malformed or empty --key fails before the lookup and before the
39
+ // confirmation prompt — otherwise someone was shown a playlist, asked to approve destroying it,
40
+ // and only then told their key was unusable.
41
+ //
42
+ // Passing the resolved material on, rather than letting unpublishPlaylist resolve again, closes a
43
+ // narrower gap: the prompt can stay open indefinitely, and a second resolution would re-read
44
+ // config.json at send time. A config edited in that window would sign the delete under an
45
+ // identity other than the one the operator saw and approved — and a delete tombstones the id.
46
+ // The DID displayed below and the key that signs are now the same value.
47
+ //
48
+ // Only the did:key is ever printed; the material never reaches the output from this path.
49
+ const { mutationSignerIdentity } = await import('../utilities/feed-mutation.js');
50
+ let signerDidKey;
51
+ let signingKey;
52
+ try {
53
+ ({ privateKey: signingKey, didKey: signerDidKey } = mutationSignerIdentity(options.key, 'delete'));
54
+ }
55
+ catch (error) {
56
+ console.error(chalk_1.default.red('\nCannot sign the delete'));
57
+ console.error(chalk_1.default.red(` ${error.message}`));
58
+ console.log();
59
+ process.exit(1);
60
+ }
29
61
  const feedConfig = getFeedConfig();
30
62
  const selection = await (0, feed_server_1.selectFeedServer)(feedConfig.baseURLs, {
31
63
  serverArg: options.server,
@@ -59,6 +91,7 @@ exports.unpublishCommand = new commander_1.Command('unpublish')
59
91
  }
60
92
  console.log(chalk_1.default.yellow(` ${label}`));
61
93
  console.log(chalk_1.default.dim(` Server: ${selection.url}`));
94
+ console.log(chalk_1.default.dim(` Signing as: ${signerDidKey}`));
62
95
  console.log(chalk_1.default.dim(' This cannot be undone; the id is tombstoned and cannot be reused.'));
63
96
  console.log();
64
97
  const prompt = (0, prompt_1.createPrompt)();
@@ -70,7 +103,16 @@ exports.unpublishCommand = new commander_1.Command('unpublish')
70
103
  return;
71
104
  }
72
105
  }
73
- const result = await unpublishPlaylist(idOrUrl, selection.url);
106
+ // `Signing as` is printed on both paths, not only before the prompt: under -y it is the only
107
+ // record of which identity performed an irreversible delete, and it is what the assertion in the
108
+ // tests ties to the signature on the wire.
109
+ if (options.yes) {
110
+ console.log(chalk_1.default.dim(` Signing as: ${signerDidKey}`));
111
+ }
112
+ const result = await unpublishPlaylist(idOrUrl, selection.url, {
113
+ privateKey: signingKey,
114
+ keySource: options.key !== undefined ? 'supplied' : 'configured',
115
+ });
74
116
  if (result.success) {
75
117
  console.log(chalk_1.default.green('Unpublished'));
76
118
  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,53 @@ 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`.
145
+ *
146
+ * **Presence, never truthiness.** `--key ""` — the shape an unset shell variable takes,
147
+ * `--key "$SIGNING_KEY"` with nothing in it — is an override that FAILED, not an absent one. Treating it
148
+ * as absent silently fell back to the configured key, so a delete meant to be authorized by one identity
149
+ * was authorized by another and tombstoned the playlist under it. There is no recovering from that: the
150
+ * id is retired. So anything the caller actually passed goes through validation and is rejected there;
151
+ * only `undefined` means "use the configured key".
152
+ *
153
+ * @param override - Value of `--key` exactly as the command received it
154
+ * @param action - Which mutation is being authorized, for the no-key-configured message
155
+ * @returns Key material for signing
156
+ * @throws Error when the override is empty, or when nothing is configured and none was given
157
+ */
158
+ function resolveMutationSigningKey(override, action) {
159
+ if (override !== undefined) {
160
+ if (override.trim().length === 0) {
161
+ throw new Error('The --key value is empty. This usually means a shell variable did not expand ' +
162
+ '(for example --key "$SIGNING_KEY" with SIGNING_KEY unset). Refusing rather than falling ' +
163
+ 'back to the configured key: a signature made by the wrong identity is not something a ' +
164
+ `${action} can be taken back from.`);
165
+ }
166
+ return override;
167
+ }
168
+ const configured = (0, config_1.getPlaylistConfig)().privateKey;
169
+ if (!configured) {
170
+ throw new Error(`No playlist signing key is configured. A ${action} is authorized by a signature, so one is ` +
171
+ 'required: run "ff-cli setup", set playlist.privateKey in config.json, or pass --key.');
172
+ }
173
+ return configured;
174
+ }
175
+ /**
176
+ * Identity a mutation will sign under, resolved and validated together.
177
+ *
178
+ * Commands call this before doing anything else so a bad credential fails before any request — and, for
179
+ * `unpublish`, before the operator is asked to confirm a delete they could not have completed. The key
180
+ * material never appears in what comes back: only the `did:key` it derives to.
181
+ *
182
+ * @param override - Value of `--key` exactly as the command received it
183
+ * @param action - Which mutation is being authorized
184
+ * @returns The key material and the `did:key` it asserts
185
+ */
186
+ function mutationSignerIdentity(override, action) {
187
+ const privateKey = resolveMutationSigningKey(override, action);
188
+ return { privateKey, didKey: (0, signing_identity_1.playlistSigningDidKey)(privateKey) };
189
+ }
139
190
  /** Owner `did:key`s the stored playlist declares, in declaration order. */
140
191
  function storedOwnerKeys(stored) {
141
192
  const curators = Array.isArray(stored.curators) ? stored.curators : [];
@@ -179,13 +230,28 @@ async function storedOwnership(stored) {
179
230
  catch {
180
231
  // An entry that does not verify is not proof. It is also not this command's business to report:
181
232
  // 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.
233
+ // signing key can act, and the answer below is derived from the set that survived.
183
234
  }
184
235
  }
185
236
  return { declared, proven };
186
237
  }
238
+ /** How to describe the identity in a refusal, given where it came from. */
239
+ function identityLabel(keySource) {
240
+ return keySource === 'supplied'
241
+ ? 'The identity you passed with --key:'
242
+ : 'Your configured identity:';
243
+ }
244
+ /** What to do about it, given where it came from. */
245
+ function retryAdvice(keySource, plural) {
246
+ const which = plural ? 'one of the keys listed above' : 'the key listed above';
247
+ return keySource === 'supplied'
248
+ ? ` Run it again with an owner key: --key <private key for ${which}>.\n` +
249
+ ` (Confirm which identity a key carries with "ff-cli status --key <private key>".)`
250
+ : ` Point playlist.privateKey at ${which}, or pass one for this run with --key (confirm any\n` +
251
+ ` key's identity with "ff-cli status --key <private key>").`;
252
+ }
187
253
  /**
188
- * Refuse locally when the configured key cannot authorize a mutation on the stored playlist.
254
+ * Refuse locally when the signing key cannot authorize a mutation on the stored playlist.
189
255
  *
190
256
  * The feed's own answer to every case here is a bare `403 forbidden`, which says nothing about which
191
257
  * identity was offered, which ones would have worked, or whether the problem is the declaration or the
@@ -194,11 +260,11 @@ async function storedOwnership(stored) {
194
260
  * declaration is right and the signature that would back it was never made.
195
261
  *
196
262
  * @param stored - The playlist as the feed serves it
197
- * @param signerDidKey - `did:key` the configured signing key will assert
263
+ * @param signerDidKey - `did:key` the signing key will assert
198
264
  * @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.
265
+ * @returns A failure to report, or `null` when the signing key is a proven owner.
200
266
  */
201
- async function ownershipPreflight(stored, signerDidKey, action) {
267
+ async function ownershipPreflight(stored, signerDidKey, action, keySource = 'configured') {
202
268
  const { declared, proven } = await storedOwnership(stored);
203
269
  if (proven.includes(signerDidKey)) {
204
270
  return null;
@@ -213,7 +279,7 @@ async function ownershipPreflight(stored, signerDidKey, action) {
213
279
  error: `This playlist declares no owners, so no key can ${verb} it.`,
214
280
  message: `The feed derives ownership from the stored document's curators[], and this one is empty — the\n` +
215
281
  ` shape a playlist published without an owner-role signature is frozen in. No signature can\n` +
216
- ` authorize a ${verb}, including yours (${signerDidKey}).\n` +
282
+ ` authorize a ${verb}, including this one (${signerDidKey}).\n` +
217
283
  ` Nothing can repair it: the owner set is immutable, and only an owner could change it. Publish\n` +
218
284
  ` a corrected playlist under a new id instead.`,
219
285
  };
@@ -240,30 +306,31 @@ async function ownershipPreflight(stored, signerDidKey, action) {
240
306
  // it is simply not the one configured here.
241
307
  if (declared.includes(signerDidKey)) {
242
308
  return {
243
- error: `Your key is declared on this playlist but never signed it as ${exports.OWNER_ROLE}, so it cannot ${verb} it.`,
309
+ error: `This key is declared on this playlist but never signed it as ${exports.OWNER_ROLE}, so it cannot ${verb} it.`,
244
310
  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` +
311
+ ` as "${exports.OWNER_ROLE}". This one is named in curators[] but carries no such signature, so an\n` +
312
+ ` intent signed with it would be refused.\n` +
313
+ ` ${identityLabel(keySource)}\n` +
248
314
  ` ${signerDidKey}\n` +
249
315
  ` Keys that have proved ownership:\n` +
250
316
  `${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.`,
317
+ `${retryAdvice(keySource, proven.length > 1)}\n` +
318
+ ` The declaration cannot be upgraded after the fact: the proof would have to be a signature\n` +
319
+ ` over the document as published.`,
254
320
  };
255
321
  }
322
+ const source = keySource === 'supplied' ? 'The key you passed with --key is' : 'The configured signing key is';
256
323
  return {
257
- error: `The configured signing key is not an owner of this playlist, so it cannot ${verb} it.`,
324
+ error: `${source} not an owner of this playlist, so it cannot ${verb} it.`,
258
325
  message: `Only a key the stored playlist names in curators[] AND that signed it as "${exports.OWNER_ROLE}" can\n` +
259
326
  ` authorize a ${verb}; the feed derives ownership from the stored document, not from a local copy.\n` +
260
- ` Your configured identity:\n` +
327
+ ` ${identityLabel(keySource)}\n` +
261
328
  ` ${signerDidKey}\n` +
262
329
  ` Keys that have proved ownership:\n` +
263
330
  `${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.`,
331
+ `${retryAdvice(keySource, proven.length > 1)}\n` +
332
+ ` Ownership cannot be granted after the fact: the owner set is immutable, so a playlist signed\n` +
333
+ ` by the wrong key stays that way.`,
267
334
  };
268
335
  }
269
336
  /** Feed error code carried in an error response body, when there is one. */
@@ -290,8 +357,17 @@ function feedErrorDetail(error) {
290
357
  *
291
358
  * The codes handled are exactly those dp1-feed-v2 documents for these routes; anything else falls
292
359
  * through with the server's own words rather than being guessed at.
360
+ *
361
+ * `keySource` matters here for the same reason it does locally: two of these messages talk about the
362
+ * signing identity, and calling it "configured" when the operator passed `--key` describes a file this
363
+ * run never read. Worse, after a 403 it reads as though the override had been dropped — which is
364
+ * precisely the doubt an operator should not be left with when their key was used and refused.
365
+ *
366
+ * @param error - The failure thrown by the request
367
+ * @param action - Which mutation was attempted
368
+ * @param keySource - Where the signing identity came from
293
369
  */
294
- function describeFeedMutationError(error, action) {
370
+ function describeFeedMutationError(error, action, keySource = 'configured') {
295
371
  const axiosError = error;
296
372
  const status = axiosError.response?.status;
297
373
  const code = feedErrorCode(axiosError);
@@ -305,19 +381,26 @@ function describeFeedMutationError(error, action) {
305
381
  return {
306
382
  error: `${verb} refused: the feed saw no signatures on the request.`,
307
383
  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}`,
384
+ (keySource === 'supplied'
385
+ ? ` The key passed with --key produced no signature the feed could read, which should not\n` +
386
+ ` happen once it has been accepted locally — please report this.\n`
387
+ : ` This usually means no signing key is configured: run "ff-cli status" to check, and\n` +
388
+ ` "ff-cli setup" to generate one.\n`) +
389
+ ` ${suffix.trim() || 'The feed gave no further detail.'}`,
310
390
  };
311
391
  }
312
- // A 403 that reaches here has already passed the local ownership proof: the configured key was found
392
+ // A 403 that reaches here has already passed the local ownership proof: the key that signed was found
313
393
  // in the stored curators[] with a valid curator-role signature over the stored bytes. Repeating "your
314
394
  // key is not declared" would therefore be a lie, and it is the wrong place to send someone — the
315
395
  // remaining causes are the feed disagreeing about the stored document, or a replace touching the
316
396
  // owner set. Say that the feed refused, and that the local check disagreed.
317
397
  if (status === 403) {
398
+ const which = keySource === 'supplied'
399
+ ? 'the key you passed with --key is'
400
+ : 'the configured signing key is';
318
401
  return {
319
402
  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` +
403
+ message: `The local check disagreed — ${which} named in the stored playlist's curators[]\n` +
321
404
  ` and carries a valid "${exports.OWNER_ROLE}" signature over the stored document — so this is the feed's\n` +
322
405
  ` own judgement, not a missing declaration.\n` +
323
406
  (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 ? '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.
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
  *