@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.
@@ -101,24 +101,58 @@ async function verifyPlaylist(playlist, publicKeyHex) {
101
101
  * @param {string} [roleOverride] - DP-1 signing role override
102
102
  * @param {Object} [options] - Signing options
103
103
  * @param {boolean} [options.replaceSignatures=false] - Drop every existing signature and sign fresh
104
+ * @param {boolean} [options.force=false] - Overwrite a destination holding a different document
105
+ * @param {Object} [options.fs] - Filesystem module override, for tests
104
106
  * @returns {Promise<Object>} Result with signed playlist
105
107
  * @returns {boolean} returns.success - Whether signing succeeded
106
108
  * @returns {Object} [returns.playlist] - Signed playlist object
107
- * @returns {number} [returns.droppedSignatures] - How many stale entries were discarded
109
+ * @returns {Array<Object>} [returns.dropped] - The stale entries that were discarded, classified
110
+ * @returns {boolean} [returns.inPlace] - Whether the input file itself was overwritten
108
111
  * @returns {string} [returns.error] - Error message if failed
109
112
  */
110
113
  async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride, options) {
111
- const fs = require('fs');
114
+ // One fs handle for every filesystem call in this function, so a test can record the ORDER of the
115
+ // backup and the source write. That order is the whole guarantee: a backup synced after the source
116
+ // was truncated protects nothing, and no assertion about either write alone can catch it.
117
+ const fs = (options && options.fs) || require('fs');
112
118
  const path = require('path');
119
+ // Hold the source open for the whole call, and read its bytes through that descriptor.
120
+ //
121
+ // Everything downstream — the parsed document, the signature classification, the decision about what
122
+ // may be discarded — describes the file that was READ. Looking the path up again later to decide
123
+ // whether the output is that file compares against whatever is at the name now, so an atomic
124
+ // replacement in between lets a classification of one document authorize truncating another. The
125
+ // descriptor pins the identity the rest of the call is reasoning about.
126
+ let sourceFd;
127
+ const output = outputPath || playlistPath;
128
+ let createdOutput = false;
129
+ let wrote = false;
113
130
  try {
114
- // Read playlist file
115
- if (!fs.existsSync(playlistPath)) {
116
- throw new Error(`Playlist file not found: ${playlistPath}`);
117
- }
118
- const playlistContent = fs.readFileSync(playlistPath, 'utf-8');
131
+ sourceFd = fs.openSync(playlistPath, fs.constants.O_RDONLY);
132
+ }
133
+ catch (openError) {
134
+ return {
135
+ success: false,
136
+ error: openError && openError.code === 'ENOENT'
137
+ ? `Playlist file not found: ${playlistPath}`
138
+ : openError.message,
139
+ };
140
+ }
141
+ try {
142
+ const sourceBytes = readAllFrom(fs, sourceFd);
143
+ const sourceDigest = digestOf(sourceBytes);
144
+ const playlistContent = sourceBytes.toString('utf-8');
119
145
  const playlist = JSON.parse(playlistContent);
120
146
  const config = getPlaylistConfig();
121
- const privateKey = privateKeyBase64 || config.privateKey;
147
+ // Presence, not truthiness. `--key ""` is what an unset shell variable expands to; treating it as
148
+ // absent signs with the configured key while the user believes they supplied another one. The same
149
+ // hazard the feed mutations were just fixed for, and the signature it produces is just as wrong.
150
+ if (privateKeyBase64 !== undefined && String(privateKeyBase64).trim().length === 0) {
151
+ throw new Error('The --key value is empty. This usually means a shell variable did not expand ' +
152
+ '(for example --key "$SIGNING_KEY" with SIGNING_KEY unset). Refusing rather than falling ' +
153
+ 'back to the configured key, which would sign under an identity you did not choose.');
154
+ }
155
+ const privateKey = privateKeyBase64 !== undefined ? privateKeyBase64 : config.privateKey;
122
156
  const role = resolvePlaylistSigningRole(roleOverride || config.role);
123
157
  const validation = await validatePlaylistForSigning(playlist);
124
158
  if (!validation.valid) {
@@ -129,31 +163,342 @@ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, role
129
163
  throw new Error('Private key is required for signing');
130
164
  }
131
165
  const replaceSignatures = Boolean(options && options.replaceSignatures);
132
- const droppedSignatures = replaceSignatures
133
- ? (Array.isArray(playlist.signatures) ? playlist.signatures.filter(Boolean).length : 0) +
134
- (typeof playlist.signature === 'string' && playlist.signature.trim() ? 1 : 0)
135
- : 0;
166
+ const force = Boolean(options && options.force);
136
167
  const signedPlaylist = await buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role, replaceSignatures);
168
+ // Classify against the signature just produced: its `kid` and `role` are what this run actually
169
+ // asserted, so no separate derivation is needed to tell which entries it supersedes.
170
+ const fresh = signedPlaylist.signatures[signedPlaylist.signatures.length - 1];
171
+ const dropped = replaceSignatures
172
+ ? await describeDroppedSignatures(playlist, fresh?.kid, fresh?.role, dp1)
173
+ : [];
137
174
  const verification = await verifySignedPlaylistEnvelope(signedPlaylist, dp1);
138
175
  if (!verification.valid) {
139
176
  throw new Error(`Signed playlist verification failed: ${verification.error}`);
140
177
  }
141
- // Write to output file
142
- const output = outputPath || playlistPath;
143
- fs.writeFileSync(output, JSON.stringify(signedPlaylist, null, 2), 'utf-8');
144
- console.log(`✓ Playlist signed and saved to: ${path.resolve(output)}`);
145
- return {
146
- success: true,
147
- playlist: signedPlaylist,
148
- droppedSignatures,
149
- };
178
+ // Bind the output to a descriptor BEFORE deciding whether it is the input, and write through that
179
+ // same descriptor. Checking a path and then writing to it is two lookups, and in a shared
180
+ // directory they can disagree: an --output that does not exist when it is checked can be a symlink
181
+ // to the input by the time it is written, so the refusal below never fires and the write follows
182
+ // the link into the input while the report calls it untouched. A descriptor cannot be re-pointed.
183
+ //
184
+ // Overwrite only a document this command has read.
185
+ //
186
+ // Every earlier version answered "is the output the input?" with file identity — inode, name,
187
+ // realpath — and every one of them lost to the same shape of race: a path can be re-pointed
188
+ // between any two syscalls, so a stat taken before the write says nothing about what the write
189
+ // will land on. Identity is the wrong question. What matters is not whether two names refer to one
190
+ // file but whether the bytes about to be destroyed are the bytes that were read and classified.
191
+ //
192
+ // So the output descriptor is read back and digested, and the comparison is against the digest of
193
+ // the source. Equal means this IS the inspected document, however the name got here, and the
194
+ // discard rules apply to it. Unequal means the destination holds something never looked at, which
195
+ // is refused rather than guessed about. Neither answer depends on a name staying still.
196
+ //
197
+ // O_CREAT|O_EXCL first: a success proves the file did not exist a moment ago, so it holds nothing
198
+ // to preserve and nothing to compare. On EEXIST the retry keeps O_CREAT so a dangling symlink gets
199
+ // its target created, and adds O_RDWR because the destination now has to be read back.
200
+ //
201
+ // No O_TRUNC anywhere: nothing is destroyed before the decision.
202
+ // Whether the operator already asked for a different destination, which changes what a refusal
203
+ // can usefully tell them.
204
+ const wroteElsewhere = outputPath !== undefined && path.resolve(outputPath) !== path.resolve(playlistPath);
205
+ let fd;
206
+ try {
207
+ fd = fs.openSync(output, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL);
208
+ createdOutput = true;
209
+ }
210
+ catch (openError) {
211
+ if (!openError || openError.code !== 'EEXIST') {
212
+ throw openError;
213
+ }
214
+ try {
215
+ fd = fs.openSync(output, fs.constants.O_RDWR | fs.constants.O_CREAT);
216
+ }
217
+ catch (retryError) {
218
+ if (retryError && retryError.code === 'EACCES') {
219
+ // The destination has to be read back before it can be destroyed, so a write-only file that
220
+ // already exists is no longer a usable target. Worth saying, because the errno alone reads
221
+ // like a bug rather than a rule.
222
+ throw new Error(`Cannot read ${output} to check what it holds (${retryError.message}). A destination that ` +
223
+ 'already exists is read before it is overwritten, so it needs read permission. Write to ' +
224
+ 'a new name instead.');
225
+ }
226
+ throw retryError;
227
+ }
228
+ }
229
+ try {
230
+ // `inPlace` now means "this descriptor holds the document that was read", which is the only
231
+ // sense the discard rules ever needed.
232
+ let inPlace = false;
233
+ let overwroteAnother = false;
234
+ if (!createdOutput) {
235
+ // This narrows the race; it does not close it. Between reading the destination here and
236
+ // truncating it below, another process can write to the same descriptor's file, and nothing
237
+ // available in userspace prevents that — file locking is not portable, and no command in this
238
+ // CLI attempts it. Every tool that edits a file in place carries the same window; `enrich`
239
+ // says so at its own equivalent check. A fresh --output name is the write that cannot collide,
240
+ // because nothing is there to lose.
241
+ const outputBytes = readAllFrom(fs, fd);
242
+ const outputDigest = digestOf(outputBytes);
243
+ inPlace = outputDigest === sourceDigest;
244
+ // An empty destination holds nothing to destroy, so there is nothing for the rule to protect.
245
+ // This is how a dangling symlink is written: O_EXCL will not follow the link, so the retry
246
+ // creates the target and cannot prove it did — but the file it opened is provably empty, which
247
+ // answers the same question the proof would have.
248
+ const empty = outputBytes.length === 0;
249
+ if (inPlace) {
250
+ // Refuse rather than overwrite a signature only its holder could reproduce. The invariant is
251
+ // that a still-valid endorsement from another key is never destroyed in place; scoped to what
252
+ // cannot be recovered, since your own entry is replaced by this run and an unverified one was
253
+ // not restorable from the input either.
254
+ const unrecoverable = dropped.filter((entry) => entry.kind !== 'replaced' && entry.verified && !entry.sameKey);
255
+ if (unrecoverable.length > 0) {
256
+ const count = unrecoverable.length;
257
+ const signatures = `${count} still-valid signature${count === 1 ? '' : 's'} from other keys`;
258
+ // Telling someone who already passed --output to "write it elsewhere" is not advice. They
259
+ // did; the file there holds the same document, which without identity is indistinguishable
260
+ // from an alias of the input — and identity is gone by design, because it could never
261
+ // answer the question anyway. So say what is true and what to do about it.
262
+ //
263
+ // --force deliberately does not reach here. It exists to replace a DIFFERENT document,
264
+ // where the only thing at risk is something the operator chose to discard. It is not a way
265
+ // to destroy a signature only its holder could reproduce.
266
+ throw new Error(wroteElsewhere
267
+ ? `${output} holds the same document as the input, so writing there would discard ` +
268
+ `${signatures} exactly as writing in place would. --force does not override this: ` +
269
+ 'it replaces a different document, never a signature only its holder could make ' +
270
+ 'again.\n' +
271
+ ` If ${output} is a separate copy you no longer need, delete it and re-run — a ` +
272
+ 'name that does not exist is written with no check at all — or choose another ' +
273
+ '--output name.'
274
+ : `This would discard ${signatures}. Write the result elsewhere so the original ` +
275
+ 'stays:\n' +
276
+ ` ff-cli sign ${playlistPath} -r ${role} --replace-signatures --output <new file>`);
277
+ }
278
+ }
279
+ else if (!empty && !force) {
280
+ // Something is there and it is not what was signed. Overwriting it would destroy a document
281
+ // this command never read, which no flag combination should do silently.
282
+ throw new Error(`${output} already exists and is not the playlist being signed; choose a new name, or ` +
283
+ 'pass --force to overwrite it.');
284
+ }
285
+ else if (!empty) {
286
+ overwroteAnother = true;
287
+ }
288
+ }
289
+ // Truncate only now, past every refusal, through the descriptor whose contents were just read.
290
+ fs.ftruncateSync(fd, 0);
291
+ writeAll(fs, fd, Buffer.from(JSON.stringify(signedPlaylist, null, 2), 'utf-8'));
292
+ // Durable before the success line prints: a report of a file that is not on disk is a lie the
293
+ // operator has no way to detect.
294
+ fs.fsyncSync(fd);
295
+ wrote = true;
296
+ console.log(`✓ Playlist signed and saved to: ${path.resolve(output)}`);
297
+ return {
298
+ success: true,
299
+ playlist: signedPlaylist,
300
+ dropped,
301
+ inPlace,
302
+ overwroteAnother,
303
+ outputPath: path.resolve(output),
304
+ };
305
+ }
306
+ finally {
307
+ // Close, and nothing else. An earlier version removed the empty file a refusal had created, by
308
+ // name — and a name is exactly what this whole function stopped trusting. Between the close and
309
+ // the unlink the name can be another file, and deleting somebody else's document to tidy up
310
+ // after ourselves is a far worse outcome than the litter it was avoiding. The failure path says
311
+ // what may be there instead, and leaves it.
312
+ fs.closeSync(fd);
313
+ }
150
314
  }
151
315
  catch (error) {
152
316
  return {
153
317
  success: false,
154
- error: error.message,
318
+ // Said only when this call created the file, which O_EXCL told us for certain. Anything else
319
+ // would be a guess about a name we no longer hold open, and the point of saying it at all is so
320
+ // an operator is not surprised by a zero-byte playlist we deliberately did not remove.
321
+ error: createdOutput && !wrote
322
+ ? `${error.message}\n An incomplete output may remain at ${output}.`
323
+ : error.message,
155
324
  };
156
325
  }
326
+ finally {
327
+ try {
328
+ fs.closeSync(sourceFd);
329
+ }
330
+ catch {
331
+ // Nothing to do; the result has already been decided.
332
+ }
333
+ }
334
+ }
335
+ /**
336
+ * readAllFrom reads a descriptor from byte zero, without depending on or moving its file offset.
337
+ *
338
+ * Positioned reads are used throughout so the same descriptor can be read twice — once for the
339
+ * document and once to check it has not changed — with no seek between them.
340
+ *
341
+ * @param {Object} fs - Node fs module
342
+ * @param {number} fd - Descriptor open for reading
343
+ * @returns {Buffer} Everything the descriptor holds
344
+ */
345
+ function readAllFrom(fs, fd) {
346
+ const chunks = [];
347
+ const buffer = Buffer.alloc(64 * 1024);
348
+ let position = 0;
349
+ for (;;) {
350
+ const bytes = fs.readSync(fd, buffer, 0, buffer.length, position);
351
+ if (!(bytes > 0)) {
352
+ break;
353
+ }
354
+ chunks.push(Buffer.from(buffer.subarray(0, bytes)));
355
+ position += bytes;
356
+ }
357
+ return Buffer.concat(chunks);
358
+ }
359
+ /** Content digest, the only comparison that answers "are these the same bytes". */
360
+ function digestOf(buffer) {
361
+ return require('crypto').createHash('sha256').update(buffer).digest('hex');
362
+ }
363
+ /**
364
+ * writeAll writes every byte of `buffer` to `fd`, or throws.
365
+ *
366
+ * `write(2)` may write fewer bytes than asked — a short write is not an error, it is the contract — and
367
+ * the return value was being ignored. Since this runs immediately after truncating the destination, a
368
+ * short write left a partial document on disk under a "Playlist signed" report: the one failure the
369
+ * operator cannot see, because the command said it succeeded.
370
+ *
371
+ * @param {Object} fs - Node fs module
372
+ * @param {number} fd - Descriptor open for writing
373
+ * @param {Buffer} buffer - Bytes to write
374
+ * @throws {Error} If the descriptor stops accepting bytes before the buffer is exhausted
375
+ */
376
+ function writeAll(fs, fd, buffer) {
377
+ let offset = 0;
378
+ while (offset < buffer.length) {
379
+ const written = fs.writeSync(fd, buffer, offset, buffer.length - offset, offset);
380
+ if (!(written > 0)) {
381
+ throw new Error(`Wrote only ${offset} of ${buffer.length} bytes; the file on disk is incomplete. ` +
382
+ 'Re-run the command, and check the destination has space and is writable.');
383
+ }
384
+ offset += written;
385
+ }
386
+ }
387
+ /**
388
+ * Describe the signatures `--replace-signatures` discards, so the output can name them.
389
+ *
390
+ * A count alone is not actionable on a co-curated playlist: the owner has to know whose signatures are
391
+ * gone before they publish the replacement, not after someone notices their name missing.
392
+ *
393
+ * **An entry counts as replaced only when the fresh signature matches it in BOTH `kid` and `role`.**
394
+ * Matching on the key alone was wrong in a way that quietly produced unpublishable documents: re-signing
395
+ * as `agent` with a key that had signed as `curator` dropped the curator entry, called it "replaced",
396
+ * and left it out of the backup — and the result then fails the publisher's declared-curator preflight,
397
+ * with nothing left on disk to recover the lost signature from. Ownership lives in the role as much as
398
+ * in the key, so a role change is a loss like any other.
399
+ *
400
+ * **There is no "void" verdict here, and that is deliberate.** Calling an entry void asserts that it
401
+ * verified against the previous content and no longer does — a claim about a document this command has
402
+ * never seen. The operator edited in place; the pre-edit bytes exist nowhere by the time `sign` runs.
403
+ * A failed `VerifyMultiSignature` proves only that the entry does not verify against the document as it
404
+ * stands, which is equally consistent with an edit, with an entry that was tampered with, and with one
405
+ * that was never valid. So the verdict is exactly what can be checked: it verifies against this
406
+ * document, or it does not.
407
+ *
408
+ * A flat legacy `signature` is a third case again — no `kid`, no `role`, and nothing the
409
+ * multi-signature verifier can check — so it is reported as unverifiable rather than as a failure.
410
+ *
411
+ * It does not treat a `feed` role as "the feed's, so it comes back" either. Any key can emit a
412
+ * signature carrying `role: "feed"`, and this CLI holds no feed identity to check a `kid` against.
413
+ * Non-replaced entries are reported as removed, with their own role carried through — a document may
414
+ * legitimately hold `agent`, `institution`, or `licensor` entries. That a feed re-appends its own
415
+ * signature after verifying a replacement is stated separately, as the general fact it is.
416
+ *
417
+ * `kid` is reported as its last 8 characters: enough to match a curators[] row at a glance, with the
418
+ * full value still in the file.
419
+ *
420
+ * @param {Object} playlist - The playlist as read, before signing
421
+ * @param {string} [signingKid] - `did:key` of the key that just signed
422
+ * @param {string} [signingRole] - DP-1 role the fresh signature asserts
423
+ * @param {Object} dp1 - Loaded dp1-js module
424
+ * @returns {Promise<Array<{kind: string, sameKey: boolean, claimsSigningKey: boolean, role: string|null, kid: string|null, verified: boolean, checkable: boolean, label: string}>>}
425
+ */
426
+ async function describeDroppedSignatures(playlist, signingKid, signingRole, dp1) {
427
+ const dropped = [];
428
+ const raw = Buffer.from(JSON.stringify(playlist));
429
+ for (const entry of Array.isArray(playlist.signatures) ? playlist.signatures : []) {
430
+ if (!entry) {
431
+ continue;
432
+ }
433
+ const kid = typeof entry.kid === 'string' && entry.kid ? entry.kid : null;
434
+ const role = typeof entry.role === 'string' && entry.role ? entry.role : null;
435
+ const short = kid ? kid.slice(-8) : 'unknown';
436
+ const descriptor = role ? `${role}, ...${short}` : `no role, ...${short}`;
437
+ let verified = false;
438
+ try {
439
+ dp1.VerifyMultiSignature(raw, entry);
440
+ verified = true;
441
+ }
442
+ catch {
443
+ verified = false;
444
+ }
445
+ // A `kid` and a `role` are claims the entry makes about itself, and nothing in a document stops an
446
+ // attacker — or a corrupted file — from copying the signer's own. Only a signature that verifies
447
+ // has established whose it is, so an unverified entry is never credited as this key's, and never
448
+ // reported as replaced. Otherwise a forged entry carrying the signing key's kid and role would be
449
+ // labelled "your own earlier signature, replaced" and vanish from the unverified summary, which is
450
+ // precisely where a tampered signature most needs to appear.
451
+ const claimsSigningKey = Boolean(kid && signingKid && kid === signingKid);
452
+ const sameKey = verified && claimsSigningKey;
453
+ const replaced = sameKey && role === signingRole;
454
+ const outcome = verified
455
+ ? 'removed; still valid over this content'
456
+ : 'removed; could not be verified against this document';
457
+ let label;
458
+ if (replaced) {
459
+ label = `your own earlier signature (${descriptor}) — replaced by this signing`;
460
+ }
461
+ else if (sameKey) {
462
+ // Same key, different role. Naming it as another key's would be wrong, and naming it as replaced
463
+ // would be worse: this run asserts a different role, so the entry is gone and not reinstated.
464
+ label = `your signature in another role (${descriptor}) — ${outcome}`;
465
+ }
466
+ else if (claimsSigningKey) {
467
+ // Carries this key's kid but does not verify, so whose it is was never established. Said as the
468
+ // claim it is: asserting it IS yours would credit a possible forgery, and asserting it is
469
+ // someone else's would misdescribe the ordinary case where you edited the document after signing.
470
+ label = `a signature claiming your key (${descriptor}) — ${outcome}`;
471
+ }
472
+ else {
473
+ label = `another key's signature (${descriptor}) — ${outcome}`;
474
+ }
475
+ dropped.push({
476
+ kind: replaced ? 'replaced' : 'other',
477
+ sameKey,
478
+ claimsSigningKey,
479
+ role,
480
+ kid,
481
+ verified,
482
+ checkable: true,
483
+ label,
484
+ });
485
+ }
486
+ if (typeof playlist.signature === 'string' && playlist.signature.trim()) {
487
+ // A legacy flat signature carries neither kid nor role, and the multi-signature verifier has
488
+ // nothing to check it with. `checkable: false` keeps it out of both summaries: it is not a
489
+ // signature that failed, it is one nothing here can judge.
490
+ dropped.push({
491
+ kind: 'other',
492
+ sameKey: false,
493
+ claimsSigningKey: false,
494
+ role: null,
495
+ kid: null,
496
+ verified: false,
497
+ checkable: false,
498
+ label: 'a legacy flat signature (no kid, no role) — removed; not checkable here',
499
+ });
500
+ }
501
+ return dropped;
157
502
  }
158
503
  module.exports = {
159
504
  signPlaylist,
@@ -20,8 +20,6 @@ exports.unpublishPlaylist = unpublishPlaylist;
20
20
  const axios_1 = __importDefault(require("axios"));
21
21
  const feed_mutation_1 = require("./feed-mutation");
22
22
  Object.defineProperty(exports, "storedOwnerKeys", { enumerable: true, get: function () { return feed_mutation_1.storedOwnerKeys; } });
23
- const signing_identity_1 = require("./signing-identity");
24
- const config_1 = require("../config");
25
23
  /**
26
24
  * Look up a playlist on a feed without deleting it.
27
25
  *
@@ -38,7 +36,7 @@ async function fetchPlaylistForUnpublish(idOrUrl, feedServerUrl) {
38
36
  * 1. Resolve the id or URL the user supplied to a feed path segment.
39
37
  * 2. `GET` the stored playlist — the intent must carry the stored id and slug, and only the stored
40
38
  * document says who owns it.
41
- * 3. Refuse locally when the configured key is not a stored owner.
39
+ * 3. Refuse locally when the signing key is not a stored owner.
42
40
  * 4. Build and sign the delete-intent in the `curator` role.
43
41
  * 5. `DELETE` with the intent as the body and report the outcome.
44
42
  *
@@ -55,16 +53,16 @@ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
55
53
  if (!identifier) {
56
54
  return { success: false, error: 'No playlist id or URL was given' };
57
55
  }
56
+ // Where the identity came from, resolved before the first request so every refusal — local or from
57
+ // the feed — names the key the operator actually used rather than a config file this run may not
58
+ // have read.
59
+ const keySource = options.keySource ?? (options.privateKey !== undefined ? 'supplied' : 'configured');
60
+ // Resolve and validate together: `--key ""` is a failed override, not an absent one, and must never
61
+ // fall through to the configured key on an operation that tombstones an id.
58
62
  let privateKey;
59
- try {
60
- privateKey = resolveSigningKey(options.privateKey);
61
- }
62
- catch (error) {
63
- return { success: false, error: error.message };
64
- }
65
63
  let signerDidKey;
66
64
  try {
67
- signerDidKey = (0, signing_identity_1.playlistSigningDidKey)(privateKey);
65
+ ({ privateKey, didKey: signerDidKey } = (0, feed_mutation_1.mutationSignerIdentity)(options.privateKey, 'delete'));
68
66
  }
69
67
  catch (error) {
70
68
  return { success: false, error: error.message };
@@ -76,7 +74,7 @@ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
76
74
  catch (error) {
77
75
  return {
78
76
  success: false,
79
- ...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete'),
77
+ ...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete', keySource),
80
78
  feedServer: feedServerUrl,
81
79
  };
82
80
  }
@@ -92,7 +90,7 @@ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
92
90
  feedServer: feedServerUrl,
93
91
  };
94
92
  }
95
- const refusal = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'delete');
93
+ const refusal = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'delete', keySource);
96
94
  if (refusal) {
97
95
  return { success: false, ...refusal, feedServer: feedServerUrl };
98
96
  }
@@ -135,25 +133,8 @@ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
135
133
  catch (error) {
136
134
  return {
137
135
  success: false,
138
- ...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete'),
136
+ ...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete', keySource),
139
137
  feedServer: feedServerUrl,
140
138
  };
141
139
  }
142
140
  }
143
- /**
144
- * Resolve the signing key the same way every other signing path does.
145
- *
146
- * `getPlaylistConfig` already layers config.json over `PLAYLIST_PRIVATE_KEY` and screens out the sample
147
- * placeholder, so going through it keeps `unpublish` signing as the identity `ff-cli status` reports.
148
- */
149
- function resolveSigningKey(override) {
150
- if (override && override.trim().length > 0) {
151
- return override;
152
- }
153
- const configured = (0, config_1.getPlaylistConfig)().privateKey;
154
- if (!configured) {
155
- throw new Error('No playlist signing key is configured. A delete is authorized by a signature, so one is ' +
156
- 'required: run "ff-cli setup" or set playlist.privateKey in config.json.');
157
- }
158
- return configured;
159
- }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ /**
3
+ * "Would renaming onto this destination replace the file the caller read?"
4
+ *
5
+ * One predicate, for one caller. `enrich` writes a temp file and renames it over the destination, and
6
+ * `rename()` does not follow the final component — so onto a hard link it repoints that one name and
7
+ * leaves the input's own name on the untouched original inode. A hard-link destination is a genuinely
8
+ * distinct output there, and treating it as in-place makes enrich's stale-input guard refuse a write
9
+ * that was never unsafe.
10
+ *
11
+ * There used to be an inode-identity predicate here too, for `sign`, which writes directly rather than
12
+ * by rename. It is gone, and deliberately: identity answers "is this the same file", when the question
13
+ * `sign` actually has is "are these the same bytes". A path can be re-pointed between any two calls, so
14
+ * no amount of stat comparison can promise that the thing about to be truncated is the thing that was
15
+ * read — while comparing contents through the descriptor being written can, and does. That check lives
16
+ * in playlist-signer.js, where the bytes are.
17
+ */
18
+ var __importDefault = (this && this.__importDefault) || function (mod) {
19
+ return (mod && mod.__esModule) ? mod : { "default": mod };
20
+ };
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.isSameEntryForRename = isSameEntryForRename;
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const path_1 = __importDefault(require("path"));
25
+ /**
26
+ * isSameEntryForRename reports whether renaming onto `a` would replace the name `b` is read from.
27
+ *
28
+ * True for the same path and for a symlink alias — both cases where the caller reads back changed
29
+ * content at the name they gave, since `writePlaylistAtomically` resolves a symlink to its target
30
+ * before replacing. Deliberately **false** for a hard link, because a rename repoints one directory
31
+ * entry and leaves the other name on the original inode.
32
+ *
33
+ * @param a - Destination of the rename
34
+ * @param b - Path whose contents matter
35
+ * @param fsLike - Filesystem operations, for tests
36
+ * @returns True when the rename would replace what `b` names
37
+ */
38
+ async function isSameEntryForRename(a, b, fsLike = fs_1.default) {
39
+ if (path_1.default.resolve(a) === path_1.default.resolve(b)) {
40
+ return true;
41
+ }
42
+ try {
43
+ const [left, right] = await Promise.all([
44
+ fsLike.promises.realpath(a),
45
+ fsLike.promises.realpath(b),
46
+ ]);
47
+ return left === right;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
@@ -39,7 +39,7 @@ Optional settings used where headless/browser‑like behavior is needed.
39
39
 
40
40
  Used for signing DP‑1 playlists.
41
41
 
42
- - `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures. The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `play` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `play` and `publish` verify before delivery or upload and reject unsigned or broken playlists.
42
+ - `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures, and as the default signing key for the owner-bound feed mutations (`publish --replace`, `unpublish`), which sign an authorization intent at request time. Those commands and `sign` all take `-k, --key` to override it for a single run. The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `play` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `play` and `publish` verify before delivery or upload and reject unsigned or broken playlists.
43
43
 
44
44
  **Signing and key encoding:** Signing paths (`sign`, deterministic `build` when configured, and `-k/--key` overrides) accept the private key in any of these encodings:
45
45
 
@@ -142,7 +142,9 @@ No credential is configured for any of these. The feed has no API key: every wri
142
142
  signatures in the request body, and an `apiKey` left in an existing config is ignored.
143
143
 
144
144
  `playlist.privateKey` is needed only where the CLI itself has to sign at request time — `publish
145
- --replace` and `unpublish`, which sign the owner-bound intent that authorizes the write. A plain
145
+ --replace` and `unpublish`, which sign the owner-bound intent that authorizes the write. Both accept
146
+ `-k, --key` to use a different key for one run, so a second owner identity does not require editing this
147
+ file. A plain
146
148
  `publish` signs nothing: it uploads the `signatures[]` envelope the document already carries, so it
147
149
  works with no key configured at all (the document must have been signed at some point, by
148
150
  `ff-cli sign` or by whoever produced it, but not by this command).