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