@feralfile/cli 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -98,23 +98,61 @@ async function verifyPlaylist(playlist, publicKeyHex) {
98
98
  * @param {string} playlistPath - Path to playlist JSON file
99
99
  * @param {string} [privateKeyBase64] - Ed25519 private key in hex or base64 format (optional, uses config if not provided)
100
100
  * @param {string} [outputPath] - Output path (optional, overwrites input if not provided)
101
+ * @param {string} [roleOverride] - DP-1 signing role override
102
+ * @param {Object} [options] - Signing options
103
+ * @param {boolean} [options.replaceSignatures=false] - Drop every existing signature and sign fresh
104
+ * @param {boolean} [options.force=false] - Overwrite a destination holding a different document
105
+ * @param {Object} [options.fs] - Filesystem module override, for tests
101
106
  * @returns {Promise<Object>} Result with signed playlist
102
107
  * @returns {boolean} returns.success - Whether signing succeeded
103
108
  * @returns {Object} [returns.playlist] - Signed playlist object
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
104
111
  * @returns {string} [returns.error] - Error message if failed
105
112
  */
106
- async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride) {
107
- const fs = require('fs');
113
+ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride, options) {
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');
108
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;
109
130
  try {
110
- // Read playlist file
111
- if (!fs.existsSync(playlistPath)) {
112
- throw new Error(`Playlist file not found: ${playlistPath}`);
113
- }
114
- 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');
115
145
  const playlist = JSON.parse(playlistContent);
116
146
  const config = getPlaylistConfig();
117
- 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;
118
156
  const role = resolvePlaylistSigningRole(roleOverride || config.role);
119
157
  const validation = await validatePlaylistForSigning(playlist);
120
158
  if (!validation.valid) {
@@ -124,31 +162,352 @@ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, role
124
162
  if (!privateKey) {
125
163
  throw new Error('Private key is required for signing');
126
164
  }
127
- const signedPlaylist = await buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role);
165
+ const replaceSignatures = Boolean(options && options.replaceSignatures);
166
+ const force = Boolean(options && options.force);
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
+ : [];
128
174
  const verification = await verifySignedPlaylistEnvelope(signedPlaylist, dp1);
129
175
  if (!verification.valid) {
130
176
  throw new Error(`Signed playlist verification failed: ${verification.error}`);
131
177
  }
132
- // Write to output file
133
- const output = outputPath || playlistPath;
134
- fs.writeFileSync(output, JSON.stringify(signedPlaylist, null, 2), 'utf-8');
135
- console.log(`✓ Playlist signed and saved to: ${path.resolve(output)}`);
136
- return {
137
- success: true,
138
- playlist: signedPlaylist,
139
- };
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
+ }
140
314
  }
141
315
  catch (error) {
142
316
  return {
143
317
  success: false,
144
- 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,
145
324
  };
146
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;
147
502
  }
148
503
  module.exports = {
149
504
  signPlaylist,
150
505
  verifyPlaylist,
151
506
  signPlaylistFile,
507
+ // Exported for the feed-mutation intents: a delete/replace intent is signed with the same key
508
+ // material as a playlist, so it must accept the same encodings and raise the same guidance when the
509
+ // key is malformed. Duplicating the normalizer there would let the two paths drift.
510
+ normalizeSigningKeyToBase64Pkcs8,
152
511
  };
153
512
  function resolvePlaylistSigningRole(role) {
154
513
  const candidate = typeof role === 'string' ? role.trim() : '';
@@ -176,13 +535,25 @@ async function validatePlaylistForSigning(playlist) {
176
535
  * as dp1-js/dp1-go §7.1); prior `signatures[]` entries are kept on the returned
177
536
  * object so repeated `sign` runs accumulate endorsements instead of replacing them.
178
537
  *
538
+ * `replaceSignatures` discards them instead, and exists because appending is only
539
+ * correct while the signed content is unchanged. Editing a signed document moves
540
+ * bytes the earlier entries cover, so they become unverifiable — and this function's
541
+ * caller verifies the whole envelope before writing, which means an edited document
542
+ * cannot be re-signed at all without dropping them. That is the ordinary path for a
543
+ * feed replace: fetch the published playlist, change a field, sign fresh, PUT.
544
+ *
545
+ * The feed's own `feed` entry is dropped by the same rule rather than as a special
546
+ * case: it covers the pre-edit content too, and the feed appends a new one after it
547
+ * verifies the replacement, so carrying the old one forward can only fail.
548
+ *
179
549
  * @param {Object} playlist - Parsed playlist (may already include `signatures[]`)
180
550
  * @param {string} privateKey - Private key material forwarded to dp1-js
181
551
  * @param {Object} dp1 - Loaded dp1-js module
182
552
  * @param {string} role - DP-1 signing role
553
+ * @param {boolean} [replaceSignatures=false] - Discard existing signatures instead of appending
183
554
  * @returns {Promise<Object>} Playlist with legacy `signature` cleared and merged `signatures[]`
184
555
  */
185
- async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
556
+ async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role, replaceSignatures) {
186
557
  const playlistToSign = { ...playlist };
187
558
  delete playlistToSign.signature;
188
559
  delete playlistToSign.signatures;
@@ -190,7 +561,7 @@ async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
190
561
  // seed, PEM) works and a malformed key surfaces a clear error instead of
191
562
  // dp1-js's cryptic OpenSSL ASN.1 failure ("header too long" / "wrong tag").
192
563
  const normalizedKey = normalizeSigningKeyToBase64Pkcs8(privateKey);
193
- const existingSignatures = Array.isArray(playlist.signatures)
564
+ const existingSignatures = !replaceSignatures && Array.isArray(playlist.signatures)
194
565
  ? playlist.signatures.filter((entry) => Boolean(entry))
195
566
  : [];
196
567
  if (typeof dp1.SignMultiEd25519 === 'function') {
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ /**
3
+ * Deletes a playlist from a DP-1 feed.
4
+ *
5
+ * `curl -X DELETE` alone answers `401`: the feed has no API key, and a delete is authorized by a signed
6
+ * **delete-intent** in the request body (dp1-feed-v2 `docs/api_design.md`, "DELETE — owner-bound, signed
7
+ * delete-intent"). The intent must name the stored `id` and `slug` exactly, carry a `created` inside the
8
+ * server's freshness window, and be signed by a key the stored playlist names as an owner.
9
+ *
10
+ * The delete is permanent in a stronger sense than most: it tombstones the id, and a later create naming
11
+ * that id is refused. There is no undo, which is why the command layer confirms before calling this.
12
+ */
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.storedOwnerKeys = void 0;
18
+ exports.fetchPlaylistForUnpublish = fetchPlaylistForUnpublish;
19
+ exports.unpublishPlaylist = unpublishPlaylist;
20
+ const axios_1 = __importDefault(require("axios"));
21
+ const feed_mutation_1 = require("./feed-mutation");
22
+ Object.defineProperty(exports, "storedOwnerKeys", { enumerable: true, get: function () { return feed_mutation_1.storedOwnerKeys; } });
23
+ /**
24
+ * Look up a playlist on a feed without deleting it.
25
+ *
26
+ * Exposed so the command layer can show the operator what they are about to destroy — and so the
27
+ * ownership refusal happens before any confirmation prompt, rather than after they have already said yes.
28
+ */
29
+ async function fetchPlaylistForUnpublish(idOrUrl, feedServerUrl) {
30
+ return (0, feed_mutation_1.fetchStoredPlaylist)(feedServerUrl, (0, feed_mutation_1.resolvePlaylistIdentifier)(idOrUrl));
31
+ }
32
+ /**
33
+ * Delete a playlist from a feed server.
34
+ *
35
+ * Flow:
36
+ * 1. Resolve the id or URL the user supplied to a feed path segment.
37
+ * 2. `GET` the stored playlist — the intent must carry the stored id and slug, and only the stored
38
+ * document says who owns it.
39
+ * 3. Refuse locally when the signing key is not a stored owner.
40
+ * 4. Build and sign the delete-intent in the `curator` role.
41
+ * 5. `DELETE` with the intent as the body and report the outcome.
42
+ *
43
+ * @param idOrUrl - Playlist id, slug, or a feed URL ending in either
44
+ * @param feedServerUrl - Feed server base URL, including `/api/v1`
45
+ * @param options - Optional signing-key override
46
+ * @returns Result carrying the deleted playlist's identity, or a diagnosis and a remedy
47
+ * @example
48
+ * const result = await unpublishPlaylist('9f...c1', 'https://feed.example.com/api/v1');
49
+ * if (!result.success) console.error(result.error);
50
+ */
51
+ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
52
+ const identifier = (0, feed_mutation_1.resolvePlaylistIdentifier)(idOrUrl);
53
+ if (!identifier) {
54
+ return { success: false, error: 'No playlist id or URL was given' };
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.
62
+ let privateKey;
63
+ let signerDidKey;
64
+ try {
65
+ ({ privateKey, didKey: signerDidKey } = (0, feed_mutation_1.mutationSignerIdentity)(options.privateKey, 'delete'));
66
+ }
67
+ catch (error) {
68
+ return { success: false, error: error.message };
69
+ }
70
+ let stored;
71
+ try {
72
+ stored = await (0, feed_mutation_1.fetchStoredPlaylist)(feedServerUrl, identifier);
73
+ }
74
+ catch (error) {
75
+ return {
76
+ success: false,
77
+ ...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete', keySource),
78
+ feedServer: feedServerUrl,
79
+ };
80
+ }
81
+ const id = typeof stored.id === 'string' ? stored.id : '';
82
+ const slug = typeof stored.slug === 'string' ? stored.slug : '';
83
+ if (!id || !slug) {
84
+ // Both are stored verbatim from the signed document, so a feed serving one without the other is
85
+ // serving something this envelope cannot target. Failing here beats sending a body the feed will
86
+ // reject as a target mismatch, which reads like the operator's mistake.
87
+ return {
88
+ success: false,
89
+ error: 'The stored playlist is missing an id or slug, so a delete intent cannot target it.',
90
+ feedServer: feedServerUrl,
91
+ };
92
+ }
93
+ const refusal = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'delete', keySource);
94
+ if (refusal) {
95
+ return { success: false, ...refusal, feedServer: feedServerUrl };
96
+ }
97
+ const created = (0, feed_mutation_1.intentTimestamp)();
98
+ const intent = (0, feed_mutation_1.buildDeleteIntent)(id, slug, created);
99
+ let body;
100
+ try {
101
+ const signature = await (0, feed_mutation_1.signIntent)(intent, privateKey);
102
+ body = { ...intent, signatures: [signature] };
103
+ }
104
+ catch (error) {
105
+ return {
106
+ success: false,
107
+ error: `Failed to sign the delete intent: ${error.message}`,
108
+ };
109
+ }
110
+ try {
111
+ // axios sends a body on DELETE only when `data` is given explicitly; the feed requires one.
112
+ const response = await axios_1.default.delete(`${feedServerUrl}/playlists/${encodeURIComponent(id)}`, {
113
+ data: body,
114
+ headers: { 'Content-Type': 'application/json' },
115
+ timeout: 30000,
116
+ });
117
+ if (response.status !== 204 && response.status !== 200) {
118
+ return {
119
+ success: false,
120
+ error: `Unexpected response status: ${response.status}`,
121
+ feedServer: feedServerUrl,
122
+ };
123
+ }
124
+ return {
125
+ success: true,
126
+ playlistId: id,
127
+ slug,
128
+ title: typeof stored.title === 'string' ? stored.title : undefined,
129
+ message: 'Deleted from feed server (the id is now tombstoned and cannot be reused)',
130
+ feedServer: feedServerUrl,
131
+ };
132
+ }
133
+ catch (error) {
134
+ return {
135
+ success: false,
136
+ ...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete', keySource),
137
+ feedServer: feedServerUrl,
138
+ };
139
+ }
140
+ }
@@ -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
+ }