@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.
- package/dist/index.js +4 -0
- package/dist/package.json +1 -1
- package/dist/src/commands/enrich.js +12 -18
- package/dist/src/commands/fetch.js +89 -0
- package/dist/src/commands/find.js +13 -40
- package/dist/src/commands/helpers/feed-server.js +105 -0
- package/dist/src/commands/publish.js +59 -29
- package/dist/src/commands/sign.js +90 -1
- package/dist/src/commands/unpublish.js +145 -0
- package/dist/src/utilities/feed-mutation.js +454 -0
- package/dist/src/utilities/playlist-publisher.js +371 -148
- package/dist/src/utilities/playlist-signer.js +391 -20
- package/dist/src/utilities/playlist-unpublisher.js +140 -0
- package/dist/src/utilities/same-file.js +52 -0
- package/docs/CONFIGURATION.md +22 -2
- package/docs/EXAMPLES.md +370 -0
- package/docs/PROJECT_SPEC.md +63 -1
- package/docs/README.md +63 -2
- package/package.json +1 -1
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
|
|
@@ -133,7 +133,27 @@ DP‑1 Feed API configuration.
|
|
|
133
133
|
Endpoints used by the CLI:
|
|
134
134
|
|
|
135
135
|
- `GET /api/v1/playlists` (supports `limit`, `offset`, and sorting)
|
|
136
|
-
- `GET /api/v1/playlists/{id}`
|
|
136
|
+
- `GET /api/v1/playlists/{id}` (`fetch`)
|
|
137
|
+
- `POST /api/v1/playlists` (`publish`)
|
|
138
|
+
- `PUT /api/v1/playlists/{id}` (`publish --replace`)
|
|
139
|
+
- `DELETE /api/v1/playlists/{id}` (`unpublish`)
|
|
140
|
+
|
|
141
|
+
No credential is configured for any of these. The feed has no API key: every write is authorized by
|
|
142
|
+
signatures in the request body, and an `apiKey` left in an existing config is ignored.
|
|
143
|
+
|
|
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. 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
|
|
148
|
+
`publish` signs nothing: it uploads the `signatures[]` envelope the document already carries, so it
|
|
149
|
+
works with no key configured at all (the document must have been signed at some point, by
|
|
150
|
+
`ff-cli sign` or by whoever produced it, but not by this command).
|
|
151
|
+
|
|
152
|
+
**Choosing between several configured servers.** Commands that address a feed (`publish`, `unpublish`,
|
|
153
|
+
`fetch`, `find --publish`) take `-s, --server <index>`, a 0-based index into the list above. With one server
|
|
154
|
+
configured it is optional. With more than one and no `-s`, an interactive session asks; a session with no
|
|
155
|
+
terminal — a pipe, a cron job, a CI step — exits non-zero and prints the list rather than guessing, since
|
|
156
|
+
defaulting to index 0 would silently write to whichever feed happens to be first.
|
|
137
157
|
|
|
138
158
|
Environment variable alternative:
|
|
139
159
|
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -347,10 +347,34 @@ npm run dev -- publish playlist.json
|
|
|
347
347
|
# Direct: publish to specific server (server index 0)
|
|
348
348
|
npm run dev -- publish playlist.json -s 0
|
|
349
349
|
|
|
350
|
+
# Replace a playlist already stored under this document id
|
|
351
|
+
npm run dev -- publish playlist.json --replace -s 0
|
|
352
|
+
|
|
350
353
|
# Show help
|
|
351
354
|
npm run dev -- publish --help
|
|
352
355
|
```
|
|
353
356
|
|
|
357
|
+
A bare Enter at the interactive prompt selects nothing either, and fails with the list. The prompt has no
|
|
358
|
+
default by design: it is being asked precisely because the CLI cannot tell which feed was meant, and
|
|
359
|
+
`Number('')` is `0` — the first server, usually production.
|
|
360
|
+
|
|
361
|
+
**With more than one server configured and no `-s`, a non-interactive session fails rather than
|
|
362
|
+
choosing.** Under a pipe, a cron job, or a CI step there is no terminal to answer the prompt, and
|
|
363
|
+
defaulting to the first server would write to production for a script that meant the other one:
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
$ ff-cli publish playlist.json < /dev/null; echo "exit=$?"
|
|
367
|
+
|
|
368
|
+
Publish playlist
|
|
369
|
+
|
|
370
|
+
Multiple feed servers configured (2); pass --server <index>
|
|
371
|
+
0: https://feed.feralfile.com/api/v1
|
|
372
|
+
1: http://localhost:8787
|
|
373
|
+
exit=1
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The same rule applies to `unpublish`. With a single server configured, `-s` stays optional everywhere.
|
|
377
|
+
|
|
354
378
|
### Flow
|
|
355
379
|
|
|
356
380
|
1. **Verify** - Playlist structure and signatures checked; unsigned or broken playlists are rejected
|
|
@@ -519,6 +543,352 @@ only way to add one is to replace the document. Publishing is what makes that pe
|
|
|
519
543
|
`find` and `build` sign as `curator` automatically, since they declare the key themselves. This applies to
|
|
520
544
|
documents you sign by hand, where `playlist.role` (default `agent`) decides the role.
|
|
521
545
|
|
|
546
|
+
## Replace or Delete a Published Playlist
|
|
547
|
+
|
|
548
|
+
A feed's `PUT` and `DELETE` are **owner-bound**, and neither accepts an API key. Both carry a short-lived
|
|
549
|
+
signed **intent** — `ff-cli` builds it, signs it in the `curator` role with the configured
|
|
550
|
+
`playlist.privateKey` (or with `-k, --key` when you name one for that command), and sends it alongside
|
|
551
|
+
(replace) or as (delete) the request body. Only a key the **stored** playlist names in
|
|
552
|
+
`curators[]` can authorize either. This is the whole reason `publish` refuses a document with no
|
|
553
|
+
owner-role signature: once such a playlist is created it can never be replaced or deleted.
|
|
554
|
+
|
|
555
|
+
### Replace
|
|
556
|
+
|
|
557
|
+
```bash
|
|
558
|
+
# 1. Start from the PUBLISHED document, not a rebuilt one.
|
|
559
|
+
ff-cli fetch <id> -o playlist.json -s 0
|
|
560
|
+
|
|
561
|
+
# 2. Edit it. `id`, `slug`, `created` and the curators[] owner set must all stay as published.
|
|
562
|
+
|
|
563
|
+
# 3. Re-sign FRESH. This flag is required here — see below.
|
|
564
|
+
ff-cli sign playlist.json -r curator --replace-signatures
|
|
565
|
+
|
|
566
|
+
# 4. Replace.
|
|
567
|
+
ff-cli publish playlist.json --replace -s 0
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
**Step 3 needs `--replace-signatures`.** The document you fetched carries the curator's signature and
|
|
571
|
+
the feed's own, both taken over the content as published. Editing it moves bytes those signatures cover,
|
|
572
|
+
and `sign` **appends** rather than replaces — so a plain re-sign produces an envelope holding two stale
|
|
573
|
+
entries and one good one, which `sign` then refuses to write:
|
|
574
|
+
|
|
575
|
+
```
|
|
576
|
+
$ ff-cli sign playlist.json -r curator
|
|
577
|
+
|
|
578
|
+
Sign playlist
|
|
579
|
+
|
|
580
|
+
Sign failed: Signed playlist verification failed: signed playlist is not verifiable
|
|
581
|
+
|
|
582
|
+
If you edited this playlist after it was signed, the existing signatures no longer
|
|
583
|
+
cover it, and signing again cannot repair them — signing appends. Sign fresh instead:
|
|
584
|
+
ff-cli sign playlist.json -r curator --replace-signatures
|
|
585
|
+
That discards every existing entry, including any feed signature, and signs the
|
|
586
|
+
document as it stands now. It is the path a feed replace expects.
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Signing fresh drops the feed's entry along with the curator's, which is correct: it covers the pre-edit
|
|
590
|
+
content too, and the feed appends a new one of its own after it verifies the replacement.
|
|
591
|
+
|
|
592
|
+
**On a co-curated playlist this drops the other curators' signatures too.** The command verifies every
|
|
593
|
+
discarded entry against the document as it now stands and names each one, so you can see whose
|
|
594
|
+
signatures will not be on what you publish. Entries that still verify — all of them, if you have not
|
|
595
|
+
actually edited anything — are reported as removed; keep the previous file if you want them back.
|
|
596
|
+
Entries that do not verify are reported as exactly that, with no explanation attached: this command sees
|
|
597
|
+
only the document as it stands, so it cannot tell a changed document from a signature that was never
|
|
598
|
+
valid.
|
|
599
|
+
|
|
600
|
+
```
|
|
601
|
+
$ ff-cli sign playlist.json -r curator --replace-signatures
|
|
602
|
+
|
|
603
|
+
Sign playlist
|
|
604
|
+
|
|
605
|
+
✓ Playlist signed and saved to: /path/to/playlist.json
|
|
606
|
+
|
|
607
|
+
Playlist signed
|
|
608
|
+
Replaced 3 existing signatures:
|
|
609
|
+
- a signature claiming your key (curator, ...Dy8sokwC) — removed; could not be verified against this document
|
|
610
|
+
- another key's signature (curator, ...L5ufrpW3) — removed; could not be verified against this document
|
|
611
|
+
- another key's signature (feed, ...1Rq7Lzik) — removed; could not be verified against this document
|
|
612
|
+
3 other signatures could not be verified against this document:
|
|
613
|
+
...Dy8sokwC (curator) — claims your key, unverified
|
|
614
|
+
...L5ufrpW3 (curator)
|
|
615
|
+
...1Rq7Lzik (feed)
|
|
616
|
+
That is consistent with the content having changed since they were made, and
|
|
617
|
+
equally with their never having been valid — this command only has the document
|
|
618
|
+
as it stands, so it cannot tell which. If you want those signatures on what you
|
|
619
|
+
publish, their holders have to sign this document; signing appends, so they can
|
|
620
|
+
add to this file without disturbing yours.
|
|
621
|
+
A feed appends its own signature again after it verifies a replacement.
|
|
622
|
+
Signatures: 1
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
**Your own earlier entry is in that list, and named as a claim rather than as yours.** It was made over
|
|
626
|
+
the content before the edit, so it no longer verifies — and an entry that does not verify has not
|
|
627
|
+
established whose it is. Reading a `kid` as proof of authorship is how a forged entry would hide behind
|
|
628
|
+
your identity, so the command says what it checked: this signature claims your key, and could not be
|
|
629
|
+
confirmed. On an unedited document the same entry verifies and is reported plainly as replaced.
|
|
630
|
+
|
|
631
|
+
Four things that report deliberately does **not** say.
|
|
632
|
+
|
|
633
|
+
It does not call an entry *void*. That would assert it verified against the previous content and no
|
|
634
|
+
longer does — and the previous content was edited in place, so it exists nowhere by the time `sign`
|
|
635
|
+
runs. A failed verification is equally consistent with an edit, with a tampered entry, and with one
|
|
636
|
+
that was never valid; the command reports what it checked and offers both explanations.
|
|
637
|
+
|
|
638
|
+
It does not treat `role: "feed"` as the feed's own. Any key can emit that role and the CLI holds no
|
|
639
|
+
feed identity to check a `kid` against. That a feed re-appends its signature after verifying a
|
|
640
|
+
replacement is a general fact, printed as one, not a claim about the entry above it.
|
|
641
|
+
|
|
642
|
+
It does not rewrite anyone's role. `agent`, `institution` and `licensor` are valid DP-1 roles, so each
|
|
643
|
+
entry is named with the role it actually carries.
|
|
644
|
+
|
|
645
|
+
It does not credit an unverified entry to the key it names — not even to yours. Attribution follows
|
|
646
|
+
verification, never the other way round.
|
|
647
|
+
|
|
648
|
+
A flat legacy `signature` gets its own line, because it carries no `kid` and no `role` and the
|
|
649
|
+
multi-signature verifier has nothing to check it with:
|
|
650
|
+
|
|
651
|
+
```
|
|
652
|
+
- a legacy flat signature (no kid, no role) — removed; not checkable here
|
|
653
|
+
A legacy flat signature carries no kid or role, so nothing here can judge it.
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Running the flag on a document you have **not** edited is a different situation, because nothing was
|
|
657
|
+
invalidated — the signing payload excludes `signatures`, so the discarded entries still cover the
|
|
658
|
+
content. **If any of them belongs to another key, an in-place run refuses:**
|
|
659
|
+
|
|
660
|
+
```
|
|
661
|
+
$ ff-cli sign playlist.json -r curator --replace-signatures
|
|
662
|
+
|
|
663
|
+
Sign playlist
|
|
664
|
+
|
|
665
|
+
Sign failed: This would discard 1 still-valid signature from other keys. Write the result elsewhere so the original stays:
|
|
666
|
+
ff-cli sign playlist.json -r curator --replace-signatures --output <new file>
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
Only its holder could make that signature again, so overwriting the one file that carries it costs
|
|
670
|
+
something this command cannot restore. Writing elsewhere keeps the original exactly where it is:
|
|
671
|
+
|
|
672
|
+
```
|
|
673
|
+
$ ff-cli sign playlist.json -r curator --replace-signatures -o out.json
|
|
674
|
+
|
|
675
|
+
Sign playlist
|
|
676
|
+
|
|
677
|
+
✓ Playlist signed and saved to: /path/to/out.json
|
|
678
|
+
|
|
679
|
+
Playlist signed
|
|
680
|
+
Replaced 2 existing signatures:
|
|
681
|
+
- your own earlier signature (curator, ...XppfYVjB) — replaced by this signing
|
|
682
|
+
- another key's signature (curator, ...UNFmeU8D) — removed; still valid over this content
|
|
683
|
+
1 other signature still verified over this content and was removed anyway.
|
|
684
|
+
Your input file is untouched, so it remains valid there.
|
|
685
|
+
A feed appends its own signature again after it verifies a replacement.
|
|
686
|
+
Signatures: 1
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
The refusal is narrow: it is about what cannot be recovered, not about how many signatures go. An
|
|
690
|
+
in-place run still proceeds when the only entries dropped are **your own** — sign again to add any of
|
|
691
|
+
them back — or ones that **no longer verify**, which the input could not restore either.
|
|
692
|
+
|
|
693
|
+
**`sign` overwrites only a document it has read.** Before writing, it reads the destination back and
|
|
694
|
+
compares it with the document being signed. Equal means this is that document, wherever the name now
|
|
695
|
+
points, and the rule above applies to it. Different means the destination holds something never
|
|
696
|
+
inspected, and the run is refused:
|
|
697
|
+
|
|
698
|
+
```
|
|
699
|
+
$ ff-cli sign playlist.json -r curator --replace-signatures -o notes.json
|
|
700
|
+
|
|
701
|
+
Sign failed: notes.json already exists and is not the playlist being signed; choose a new name, or pass --force to overwrite it.
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
`--force` allows it, and the report names what it replaced. This is a content rule, not an identity
|
|
705
|
+
one: no comparison of paths or inodes can promise that the file about to be truncated is the file that
|
|
706
|
+
was read, because a name can be re-pointed between any two system calls — but comparing the bytes
|
|
707
|
+
through the descriptor being written can. A destination that already exists needs read permission for
|
|
708
|
+
that reason.
|
|
709
|
+
|
|
710
|
+
**It narrows the race; it does not close it.** The read and the overwrite are two operations, and an
|
|
711
|
+
editor writing in the same instant can still lose its change — every tool that edits a file in place
|
|
712
|
+
carries this, and nothing in userspace removes it portably. A fresh `--output` name is the write that
|
|
713
|
+
cannot collide, because nothing is there to lose.
|
|
714
|
+
|
|
715
|
+
If the destination turns out to hold the **same** document as the input — a copy taken with `cp`, say —
|
|
716
|
+
it is treated exactly as an in-place run, because without file identity a copy and a second name for the
|
|
717
|
+
input are the same thing. So the same narrow rule decides it: the run is refused **only when it would
|
|
718
|
+
discard a still-valid signature from another key**. That is the whole of what the refusal protects, and
|
|
719
|
+
a copy carrying only your own signatures, or only ones that no longer verify, is simply written — the
|
|
720
|
+
input is a different file and is not touched either way.
|
|
721
|
+
|
|
722
|
+
When it does refuse, `--force` deliberately does **not** override it: force is for replacing a different
|
|
723
|
+
document, never for destroying a signature only its holder could make again. Delete the copy and re-run,
|
|
724
|
+
or pick another `--output` name; a name that does not exist is written with no check at all.
|
|
725
|
+
|
|
726
|
+
The rule holds on every platform and assumes nothing about the filesystem. An earlier version kept a
|
|
727
|
+
copy of the input instead; that requires reproducing the source's access, which is not portable — POSIX
|
|
728
|
+
ACLs grant what mode bits do not describe, macOS extended ACLs are not constrained by the mask, and
|
|
729
|
+
Windows mode bits constrain nothing — so every implementation of it was a way for the copy to disclose
|
|
730
|
+
the document. Refusing needs none of that.
|
|
731
|
+
|
|
732
|
+
Appending stays the default, because it is right whenever the content has not changed — a second curator
|
|
733
|
+
co-signing an unedited playlist keeps the first endorsement, and the payload hash excludes `signatures`
|
|
734
|
+
so the earlier entry stays valid.
|
|
735
|
+
|
|
736
|
+
```
|
|
737
|
+
$ ff-cli publish playlist.json --replace -s 0
|
|
738
|
+
|
|
739
|
+
Replace playlist
|
|
740
|
+
|
|
741
|
+
Replaced
|
|
742
|
+
Playlist ID: 97595a2f-a790-477c-aa42-b4f2ec9f1e3b
|
|
743
|
+
Server: https://feed.feralfile.com/api/v1
|
|
744
|
+
Status: Replaced on feed server
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
`--replace` is never applied on your behalf. A plain `publish` of an id the feed already holds still
|
|
748
|
+
fails with a conflict, because creating and overwriting are different intentions and only one of them is
|
|
749
|
+
recoverable.
|
|
750
|
+
|
|
751
|
+
Re-running `find` or `build` does **not** produce a replacement: each run mints a new `id`, `slug`, and
|
|
752
|
+
`created`, and the feed compares all three against the stored row. `ff-cli` checks them locally and names
|
|
753
|
+
the fields that moved, because the feed's own answer is a bare `400`:
|
|
754
|
+
|
|
755
|
+
```
|
|
756
|
+
Replace failed
|
|
757
|
+
The document changes fields a replace may not change.
|
|
758
|
+
|
|
759
|
+
A replace keeps identity and ownership fixed: id, slug, and created must equal the stored
|
|
760
|
+
document's, and the curators[] owner set is immutable.
|
|
761
|
+
slug: stored "snowfro-send-receive", document "a-completely-new-slug"
|
|
762
|
+
created: stored "2026-09-07T23:32:39.660Z", document "2026-09-08T01:02:03.000Z"
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
### Delete
|
|
766
|
+
|
|
767
|
+
```bash
|
|
768
|
+
# id, slug, or a feed URL — all resolve to the same playlist.
|
|
769
|
+
ff-cli unpublish 97595a2f-a790-477c-aa42-b4f2ec9f1e3b -s 0
|
|
770
|
+
ff-cli unpublish https://feed.feralfile.com/api/v1/playlists/97595a2f-a790-477c-aa42-b4f2ec9f1e3b -s 0
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
```
|
|
774
|
+
$ ff-cli unpublish 97595a2f-a790-477c-aa42-b4f2ec9f1e3b -s 0 -y
|
|
775
|
+
|
|
776
|
+
Unpublish playlist
|
|
777
|
+
|
|
778
|
+
Unpublished
|
|
779
|
+
Playlist ID: 97595a2f-a790-477c-aa42-b4f2ec9f1e3b
|
|
780
|
+
Slug: snowfro-send-receive
|
|
781
|
+
Server: https://feed.feralfile.com/api/v1
|
|
782
|
+
Status: Deleted from feed server (the id is now tombstoned and cannot be reused)
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
Without `-y`, `unpublish` shows the title and the server and asks, defaulting to **no**. The delete
|
|
786
|
+
tombstones the id: the playlist cannot be restored, and a later publish naming that id is refused. Build
|
|
787
|
+
a new playlist instead of trying to recreate it.
|
|
788
|
+
|
|
789
|
+
### Signing with a key other than the configured one
|
|
790
|
+
|
|
791
|
+
The configured `playlist.privateKey` is the default, not the only option. `unpublish` and
|
|
792
|
+
`publish --replace` take `-k, --key`, like `ff-cli sign` and `ff-cli status`, and it overrides that
|
|
793
|
+
default both for the intent signature and for the local ownership check — so holding a second owner key
|
|
794
|
+
no longer means editing `config.json`:
|
|
795
|
+
|
|
796
|
+
```bash
|
|
797
|
+
ff-cli unpublish <id> -s 0 --key <private key for a stored owner>
|
|
798
|
+
ff-cli publish playlist.json --replace -s 0 --key <private key for a stored owner>
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
`ff-cli status --key <private key>` reports which identity a key carries, which is how you check it
|
|
802
|
+
against the `Keys that have proved ownership:` list in a refusal. `unpublish` also prints `Signing as:`, both in its
|
|
803
|
+
confirmation and under `-y`, so the identity that performs an irreversible delete is always in the
|
|
804
|
+
record. It resolves that key once, before the lookup and before the prompt, and signs with the same
|
|
805
|
+
material — editing `config.json` while a confirmation is open cannot change who the delete runs as.
|
|
806
|
+
|
|
807
|
+
A refusal names the key you actually used. If it came from `--key`, the retry says `--key`; it will not
|
|
808
|
+
send you to `playlist.privateKey` for a run that never read it.
|
|
809
|
+
|
|
810
|
+
An empty `--key` is **rejected**, not ignored:
|
|
811
|
+
|
|
812
|
+
```
|
|
813
|
+
$ ff-cli unpublish <id> -y --key "$SIGNING_KEY" # variable unset
|
|
814
|
+
|
|
815
|
+
Cannot sign the delete
|
|
816
|
+
The --key value is empty. This usually means a shell variable did not expand
|
|
817
|
+
(for example --key "$SIGNING_KEY" with SIGNING_KEY unset). Refusing rather than falling
|
|
818
|
+
back to the configured key: a signature made by the wrong identity is not something a
|
|
819
|
+
delete can be taken back from.
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
A plain `publish` **refuses** `--key` rather than ignoring it: it signs nothing at request time, so the
|
|
823
|
+
flag would do nothing. `ff-cli fetch` has no `--key` for the same reason — it is a read.
|
|
824
|
+
|
|
825
|
+
### When ownership cannot be proved
|
|
826
|
+
|
|
827
|
+
A key counts as an owner only when the **stored** playlist names it in `curators[]` **and** carries its
|
|
828
|
+
valid `curator`-role signature. Being named is a claim; signing in the owner role is the proof, and the
|
|
829
|
+
CLI checks both against the stored document before it signs an intent.
|
|
830
|
+
|
|
831
|
+
That distinction has one large real population: playlists published while the default signing role was
|
|
832
|
+
`agent`. They declare a curator and carry only that key's `agent` signature, so nothing can ever
|
|
833
|
+
authorize a write to them — not even the holder of the declared key, because adding the missing proof
|
|
834
|
+
would itself be a replace:
|
|
835
|
+
|
|
836
|
+
```
|
|
837
|
+
Unpublish failed
|
|
838
|
+
No key has proved ownership of this playlist, so none can delete it.
|
|
839
|
+
|
|
840
|
+
A key counts as an owner only when it is named in curators[] AND signed the document as
|
|
841
|
+
"curator". This playlist names a curator and carries no valid curator signature
|
|
842
|
+
from that key — the shape of a playlist published while the default signing role was
|
|
843
|
+
"agent".
|
|
844
|
+
Declared:
|
|
845
|
+
did:key:z6Mkv7qJ...
|
|
846
|
+
Nothing can repair it, including holding one of those keys: adding the missing signature
|
|
847
|
+
would be a replace, and a replace needs the very proof that is missing. Publish a corrected
|
|
848
|
+
playlist under a new id instead.
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
This is why `publish` refuses to create such a document in the first place: the refusal at publish time
|
|
852
|
+
is recoverable, and this one is not.
|
|
853
|
+
|
|
854
|
+
### When the key is not an owner
|
|
855
|
+
|
|
856
|
+
`ff-cli` reads the stored playlist before it signs anything, so this is refused locally and nothing is
|
|
857
|
+
sent. The feed's own answer would be a bare `403`, naming neither the identity you offered nor the ones
|
|
858
|
+
that would have worked:
|
|
859
|
+
|
|
860
|
+
```
|
|
861
|
+
$ ff-cli unpublish 885b2ea6-74e2-44fe-96d0-e8728f6bba9c -s 0 -y
|
|
862
|
+
|
|
863
|
+
Unpublish playlist
|
|
864
|
+
|
|
865
|
+
Unpublish failed
|
|
866
|
+
The configured signing key is not an owner of this playlist, so it cannot delete it.
|
|
867
|
+
|
|
868
|
+
Only a key the stored playlist names in curators[] AND that signed it as "curator" can
|
|
869
|
+
authorize a delete; the feed derives ownership from the stored document, not from a local copy.
|
|
870
|
+
Your configured identity:
|
|
871
|
+
did:key:z6MkoX8i2dynyvLh4hUHZt8b42q9uAwwCWxM4NSX4YDfMtaC
|
|
872
|
+
Keys that have proved ownership:
|
|
873
|
+
did:key:z6MkoDkq5YXsFGXPiD6HDUVfze5mvhU5QF4hTy59pVVPYg82
|
|
874
|
+
Point playlist.privateKey at the key listed above, or pass one for this run with --key (confirm
|
|
875
|
+
any key's identity with "ff-cli status --key <private key>").
|
|
876
|
+
Ownership cannot be granted after the fact: the owner set is immutable, so a playlist signed
|
|
877
|
+
by the wrong key stays that way.
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
That run used the configured key, so the refusal names it as configured. Had it been given
|
|
881
|
+
`--key`, the same refusal would name that key instead and the retry line would read
|
|
882
|
+
`Run it again with an owner key: --key <private key for the key listed above>` — a refusal always
|
|
883
|
+
points at the key the command actually used, never at a config file the run never read.
|
|
884
|
+
|
|
885
|
+
Ownership cannot be granted after the fact — the owner set is immutable, and only an owner could change
|
|
886
|
+
it — so the only fix is to hold a declared key. A playlist whose stored `curators[]` is **empty** is a
|
|
887
|
+
harder case with the same shape: nobody owns it, no signature can ever authorize a write, and `ff-cli`
|
|
888
|
+
says so rather than sending you after a key that does not exist. That is the state every playlist
|
|
889
|
+
published without an owner-role signature is frozen in, and it is what the `publish` owner-role gate
|
|
890
|
+
above exists to prevent.
|
|
891
|
+
|
|
522
892
|
## Complete Flow (build → validate → sign → play → publish)
|
|
523
893
|
|
|
524
894
|
```bash
|
package/docs/PROJECT_SPEC.md
CHANGED
|
@@ -178,7 +178,69 @@ substituted for a chain the row already named.
|
|
|
178
178
|
### Delivery
|
|
179
179
|
|
|
180
180
|
- `play` (handles playlist files, playlist URLs, and media URLs)
|
|
181
|
-
- `
|
|
181
|
+
- `fetch`
|
|
182
|
+
- `publish` (`--replace` sends an owner-bound replacement instead of a create)
|
|
183
|
+
- `unpublish`
|
|
184
|
+
|
|
185
|
+
### Owner-bound feed mutations
|
|
186
|
+
|
|
187
|
+
Creating on a feed is open; changing or removing something is not. A feed accepts a `POST` from any
|
|
188
|
+
document that is self-signed by a curator it declares, and the signer becomes that resource's owner. A
|
|
189
|
+
`PUT` and a `DELETE` are authorized instead against the **stored** document's `curators[]`, and neither
|
|
190
|
+
accepts an API key. Both therefore carry a signed intent — `{ action, target: { type, id, slug },
|
|
191
|
+
created, signatures }`, with a `payloadHash` for replace — whose `created` must fall inside the feed's
|
|
192
|
+
freshness window; the intent exists because a document's own signatures are public via `GET` and could
|
|
193
|
+
otherwise be replayed to roll a resource back. The CLI proves ownership before it signs anything: it
|
|
194
|
+
`GET`s the stored playlist and requires the signing key — the configured `playlist.privateKey` unless
|
|
195
|
+
`-k, --key` overrides it for that command — to be both named in the stored `curators[]` and the signer of
|
|
196
|
+
a cryptographically valid `curator`-role signature over that stored document. Being
|
|
197
|
+
named is a claim; the owner-role signature is the proof, and checking only the claim is what would let a
|
|
198
|
+
legacy `agent`-signed document pass preflight and come back as a `403` reported as a missing
|
|
199
|
+
declaration — the one thing not wrong with it. The three local refusals are therefore distinct: no
|
|
200
|
+
declared curators, declared but never proved (terminal, and the shape of anything published while the
|
|
201
|
+
default role was `agent`), and proved by a key other than this one. A `403` that survives all three is
|
|
202
|
+
reported as the feed's own judgement, not as a missing declaration. This is also why `publish` refuses to
|
|
203
|
+
create a document whose declared curator did not sign in the `curator` role: that refusal is
|
|
204
|
+
recoverable and the resulting stored playlist would not be. A delete tombstones the id, so the operation
|
|
205
|
+
is final and the id is not reusable.
|
|
206
|
+
|
|
207
|
+
A `--replace-signatures` run writing over its own input refuses when it would discard a still-valid
|
|
208
|
+
signature from another key. Only that key's holder could produce it again, so overwriting the one file
|
|
209
|
+
that carries it destroys something the command cannot restore; `--output` writes the result elsewhere
|
|
210
|
+
and leaves the original in place. The refusal is scoped to what is unrecoverable — an in-place run still
|
|
211
|
+
proceeds when the entries dropped are the signer's own, or no longer verify.
|
|
212
|
+
|
|
213
|
+
Refusal rather than a preserved copy is deliberate. A copy has to reproduce the source's access, and no
|
|
214
|
+
portable means of doing so exists: POSIX ACLs grant what the mode bits do not describe, macOS extended
|
|
215
|
+
ACLs are not constrained by the mask, and Windows mode bits constrain nothing at all. Every version of
|
|
216
|
+
that copy was a way to disclose the document. The refusal assumes nothing about the filesystem, so the
|
|
217
|
+
invariant — a still-valid endorsement is never overwritten in place — holds identically everywhere.
|
|
218
|
+
|
|
219
|
+
Which document a destination holds is decided by content, not by file identity. The destination is read
|
|
220
|
+
back through the descriptor that will write it and compared with the document being signed; a
|
|
221
|
+
destination holding anything else is refused unless `--force` says otherwise. Identity cannot answer
|
|
222
|
+
this — a name can be re-pointed between any two system calls, so no comparison of paths or inodes
|
|
223
|
+
describes what a later write will land on — while the bytes behind a descriptor can. It narrows the
|
|
224
|
+
race and does not close it: the read and the overwrite are two operations, and an editor writing in the
|
|
225
|
+
same instant can still lose its change, as with any tool that edits in place. A `--output` name that
|
|
226
|
+
does not yet exist is the write that cannot collide.
|
|
227
|
+
|
|
228
|
+
A destination holding the *same* document as the input is treated as an in-place run, since without
|
|
229
|
+
identity a copy and a second name for the input are indistinguishable — and so the same narrow rule
|
|
230
|
+
applies: it is refused only when the run would discard a still-valid signature from another key. A copy
|
|
231
|
+
carrying only the signer's own entries, or only unverifiable ones, is written like any other
|
|
232
|
+
destination; the input is a separate file and is untouched. Where the refusal does fire, `--force` does
|
|
233
|
+
not reach it by design: force authorizes replacing a different document, never destroying a signature
|
|
234
|
+
only its holder could reproduce.
|
|
235
|
+
|
|
236
|
+
Editing a published playlist therefore has a required shape: `fetch` the stored document, change it,
|
|
237
|
+
re-sign with `sign --replace-signatures`, then `publish --replace`. `fetch` exists because every other
|
|
238
|
+
way of producing that starting file is wrong: `find` and `build` mint a new identity, and `verify` only
|
|
239
|
+
reports on a document rather than saving one. Signing appends by default, which is
|
|
240
|
+
correct only while the signed content is unchanged; an edit invalidates every entry the document
|
|
241
|
+
carries, including the feed's own co-signature, and the envelope check refuses to persist a mix of stale
|
|
242
|
+
and fresh. The fresh mode drops them all — the feed re-appends its signature after it verifies the
|
|
243
|
+
replacement.
|
|
182
244
|
|
|
183
245
|
### Device operations
|
|
184
246
|
|
package/docs/README.md
CHANGED
|
@@ -97,7 +97,11 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1
|
|
|
97
97
|
- `validate <file-or-url>` – Validate playlist structure only
|
|
98
98
|
- `verify <file-or-url>` – Validate structure and verify signatures. On failure, the CLI labels structure issues separately from signature verification. dp1-js uses `--public-key` (or a key derived from `playlist.privateKey` / `PLAYLIST_PRIVATE_KEY` when omitted) **only** for legacy flat `signature` verification; DP-1 v1.1.0 `signatures[]` envelopes are verified without relying on that argument. If deriving or normalizing key material fails, the CLI prints a warning on stderr and continues without it (legacy verification still requires a usable key when the playlist uses a flat `signature`). The derived key is emitted as PEM. Supported key forms: hex with optional `0x`, PEM, or 32-byte raw public key as hex or base64
|
|
99
99
|
- `sign <file>` – Sign playlist with a DP-1 v1.1.0 multi-signature envelope (private key string is forwarded to **`dp1-js`**; same hex or base64 PKCS#8 DER forms as `playlist.privateKey` in `./CONFIGURATION.md`). The command verifies the final envelope before writing output and refuses to persist tampered or otherwise unverifiable `signatures[]`.
|
|
100
|
-
- Options: `-k, --key <privateKey>`, `-r, --role <role>`, `-o, --output <file
|
|
100
|
+
- Options: `-k, --key <privateKey>`, `-r, --role <role>`, `-o, --output <file>`, `--replace-signatures`, `--force`
|
|
101
|
+
- **It overwrites only a document it has read.** Before writing, the destination is read back and compared with the document being signed; if it holds something else, the run is refused rather than destroying a file the command never inspected. `--force` allows that overwrite, and the report names the file it replaced. A destination holding the *same* document — a copy of the input — is treated as an in-place run, so it is refused only when it would discard a still-valid signature from another key, and `--force` does not override that one. The read and the overwrite are not atomic, so an editor writing in the same instant can still lose its change, as with any in-place tool — a fresh `--output` name is the write that cannot collide. A destination that already exists needs read permission
|
|
102
|
+
- Signing **appends** by default, so repeated runs accumulate endorsements. That is only correct while the signed content is unchanged: editing a signed playlist moves bytes the earlier signatures cover, and the envelope check then refuses to write the result. `--replace-signatures` discards every existing entry — including a feed's own co-signature — and signs the document as it stands. It is the required step before `publish --replace`
|
|
103
|
+
- **An in-place run refuses when it would discard a still-valid signature from another key**, since only its holder could make that signature again: write the result elsewhere with `-o, --output`, which leaves the original where it is. The refusal is about what cannot be recovered — an in-place run still proceeds when the only entries dropped are your own, or ones that no longer verify
|
|
104
|
+
- `--replace-signatures` names each discarded entry by role and the last 8 characters of its `kid`, and verifies it against the document as it now stands. Your own earlier signature is replaced by the same run. Another key's entry that **still verifies** — which is every entry when the content was not edited, since the signing payload excludes `signatures` — is reported as removed; keep the previous file if you want it back. An entry that does **not** verify is reported as exactly that and no more: the command has only the document as it stands, so it cannot tell an edit from an entry that was never valid, and it does not guess. Roles are carried through as found (`agent`, `institution` and `licensor` are as valid as `curator`), and a flat legacy `signature` is reported as not checkable, since it carries no key or role at all
|
|
101
105
|
- `play <source>` – Play a playlist file, playlist URL, or media URL on an FF1 device (runs `verify` before sending; only the CLI-synthesized media URL fallback is auto-signed when a signing key is configured; use `--skip-verify` to bypass the gate)
|
|
102
106
|
- Options: `-d, --device <name>`, `--skip-verify` (skip signature verification; not recommended)
|
|
103
107
|
- `find <input>` – Resolve a marketplace URL, raw `chain:contract:tokenId`, or wallet address into a playable DP-1 playlist
|
|
@@ -109,7 +113,12 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1
|
|
|
109
113
|
- DP-1 `evm` names a chain family rather than a network, so those items are skipped unless `--assume-ethereum` asserts which one they are. Getting that wrong attaches another artwork's metadata
|
|
110
114
|
- Options: `-o, --output <file>`, `--force` (replace existing manifests), `--assume-ethereum`, `-v, --verbose`
|
|
111
115
|
- `publish <file>` – Publish a playlist to a feed server (runs `verify` before upload and rejects unsigned or broken playlists)
|
|
112
|
-
- Options: `-s, --server <index>` (server index if multiple configured)
|
|
116
|
+
- Options: `-s, --server <index>` (server index if multiple configured), `--replace` (replace the playlist already stored under this document id instead of creating a new one), `-k, --key <privateKey>` (signs the replace authorization; `--replace` only)
|
|
117
|
+
- `--key` is refused on a plain `publish`, which signs nothing at request time — it uploads the `signatures[]` envelope the document already carries. Sign the file with `ff-cli sign --key` instead
|
|
118
|
+
- `fetch <id-or-url>` – Save a published playlist from a feed server. Accepts a playlist id, a slug, or a feed URL. The document goes to `-o` when given and to stdout otherwise (status lines always go to stderr, so `ff-cli fetch <id> > playlist.json` works). This is the starting point for `publish --replace`, which requires the stored `id`, `slug`, and `created`
|
|
119
|
+
- Options: `-s, --server <index>`, `-o, --output <file>`. There is no `--key`: a fetch is a plain read and signs nothing
|
|
120
|
+
- `unpublish <id-or-url>` – Delete a playlist from a feed server. Accepts a playlist id, a slug, or a feed URL. Requires the signing key to be an owner of the stored playlist, and confirms before deleting
|
|
121
|
+
- Options: `-s, --server <index>`, `-y, --yes` (skip the confirmation), `-k, --key <privateKey>` (signs the delete authorization)
|
|
113
122
|
- `ssh <enable|disable>` – Manage SSH access on an FF1 device
|
|
114
123
|
- Options: `-d, --device <name>`, `--pubkey <path>`, `--ttl <duration>`
|
|
115
124
|
- `device list` – List all configured FF1 devices
|
|
@@ -323,6 +332,58 @@ is configured. An `apiKey` left over in an existing config is ignored.
|
|
|
323
332
|
}
|
|
324
333
|
```
|
|
325
334
|
|
|
335
|
+
### Change or remove a published playlist
|
|
336
|
+
|
|
337
|
+
A feed's `PUT` and `DELETE` are owner-bound. Neither takes an API key, and neither is authorized by the
|
|
338
|
+
document alone: both carry a short-lived **intent** that ff-cli signs in the `curator` role — with the
|
|
339
|
+
configured `playlist.privateKey` by default, or with whatever `-k, --key` names for that one command. Only a key the *stored* playlist names in `curators[]` can authorize either one, which is
|
|
340
|
+
why `publish` refuses to create a playlist that carries no owner-role signature — such a document can be
|
|
341
|
+
neither replaced nor deleted, ever.
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
# Edit a published playlist: fetch it, change the file, re-sign fresh, then replace.
|
|
345
|
+
ff-cli fetch <id> -o playlist.json -s 0
|
|
346
|
+
# ...edit the title, items, or metadata...
|
|
347
|
+
ff-cli sign playlist.json -r curator --replace-signatures
|
|
348
|
+
ff-cli publish playlist.json --replace -s 0
|
|
349
|
+
|
|
350
|
+
# Delete a published playlist (id, slug, or feed URL all work).
|
|
351
|
+
ff-cli unpublish 97595a2f-a790-477c-aa42-b4f2ec9f1e3b -s 0
|
|
352
|
+
ff-cli unpublish https://feed.example.com/api/v1/playlists/97595a2f-a790-477c-aa42-b4f2ec9f1e3b
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`--replace-signatures` is not optional on that path. The document you fetched carries the curator's
|
|
356
|
+
signature and the feed's, both taken over the pre-edit content; a plain `ff-cli sign` appends to them and
|
|
357
|
+
the envelope check then refuses to write a file with stale entries in it. Signing fresh drops both — the
|
|
358
|
+
feed re-appends its own after it verifies the replacement.
|
|
359
|
+
|
|
360
|
+
`--replace` keeps identity fixed: `id`, `slug`, and `created` must equal the stored document's, and the
|
|
361
|
+
`curators[]` owner set may not change. Editing a published playlist therefore means editing the document
|
|
362
|
+
you published — re-running `find` or `build` mints a fresh id, slug, and `created`, which is a new
|
|
363
|
+
playlist rather than a replacement. A `publish` without `--replace` is never silently upgraded to a
|
|
364
|
+
replace; an id the feed already holds fails with a conflict, as before.
|
|
365
|
+
|
|
366
|
+
The configured `playlist.privateKey` is only the default. `-k, --key` overrides it on both commands —
|
|
367
|
+
for the intent signature and for the local ownership check alike — the way `ff-cli sign --key` and
|
|
368
|
+
`ff-cli status --key` already do. A `--key` that is
|
|
369
|
+
present but empty (what `--key "$SIGNING_KEY"` becomes when the variable is unset) is **rejected**, never
|
|
370
|
+
treated as absent: falling back to the configured key would authorize the write under an identity you did
|
|
371
|
+
not choose, and a delete cannot be taken back. `unpublish` derives the identity before it looks the
|
|
372
|
+
playlist up, so a bad credential fails before you are asked to confirm anything, and it prints
|
|
373
|
+
`Signing as:` so the identity is visible in the record either way. The configured key is one identity;
|
|
374
|
+
the key that owns a given playlist may be another, and editing `config.json` to delete something you own
|
|
375
|
+
is not a workflow.
|
|
376
|
+
|
|
377
|
+
Both verbs prove ownership locally before signing anything. A key counts as an owner only when the
|
|
378
|
+
**stored** playlist names it in `curators[]` **and** carries its valid `curator`-role signature — being
|
|
379
|
+
named is a claim, signing in the owner role is the proof. A playlist that declares a curator but was
|
|
380
|
+
signed under another role (the shape of anything published while the default role was `agent`) can never
|
|
381
|
+
be replaced or deleted by anyone, and the CLI says so rather than sending a request the feed will refuse.
|
|
382
|
+
|
|
383
|
+
`unpublish` asks for confirmation first, showing the title and the server. `-y` skips it for scripts.
|
|
384
|
+
Deletion is final in a way worth knowing before you run it: the feed **tombstones** the id, so the
|
|
385
|
+
playlist cannot be restored and a later publish naming that id is refused. Build a new playlist instead.
|
|
386
|
+
|
|
326
387
|
### FF1 device management
|
|
327
388
|
|
|
328
389
|
```bash
|