@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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.unpublishCommand = void 0;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const feed_server_1 = require("./helpers/feed-server");
|
|
10
|
+
const prompt_1 = require("./helpers/prompt");
|
|
11
|
+
/**
|
|
12
|
+
* `ff-cli unpublish <id-or-url>` — delete a playlist this key owns from a feed.
|
|
13
|
+
*
|
|
14
|
+
* The delete is irreversible in a way most deletes are not: the feed tombstones the id, so the playlist
|
|
15
|
+
* cannot be restored and the id can never be reused. Hence the confirmation, which shows the title and
|
|
16
|
+
* server first, and defaults to no. `-y` skips it for scripts; without a terminal and without `-y` the
|
|
17
|
+
* command refuses rather than assuming consent.
|
|
18
|
+
*/
|
|
19
|
+
exports.unpublishCommand = new commander_1.Command('unpublish')
|
|
20
|
+
.description('Delete a playlist from a feed server (signed delete; owner key required)')
|
|
21
|
+
.argument('<id-or-url>', 'Playlist id, slug, or feed URL')
|
|
22
|
+
.option('-s, --server <index>', 'Feed server index (use this if multiple servers configured)')
|
|
23
|
+
.option('-y, --yes', 'Skip the confirmation prompt')
|
|
24
|
+
.option('-k, --key <privateKey>', 'Ed25519 private key that signs the delete authorization (overrides config)')
|
|
25
|
+
.action(async (idOrUrl, options) => {
|
|
26
|
+
try {
|
|
27
|
+
console.log(chalk_1.default.blue('\nUnpublish playlist\n'));
|
|
28
|
+
const { getFeedConfig } = await import('../config.js');
|
|
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
|
+
}
|
|
61
|
+
const feedConfig = getFeedConfig();
|
|
62
|
+
const selection = await (0, feed_server_1.selectFeedServer)(feedConfig.baseURLs, {
|
|
63
|
+
serverArg: options.server,
|
|
64
|
+
nonInteractive: !!options.yes,
|
|
65
|
+
});
|
|
66
|
+
if (!selection.ok) {
|
|
67
|
+
console.error(chalk_1.default.red(`\n${selection.error}`));
|
|
68
|
+
if (selection.detail) {
|
|
69
|
+
console.log(chalk_1.default.yellow(selection.detail));
|
|
70
|
+
}
|
|
71
|
+
console.log();
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
if (!options.yes) {
|
|
75
|
+
if (!process.stdin.isTTY) {
|
|
76
|
+
console.error(chalk_1.default.red('\nRefusing to delete without confirmation'));
|
|
77
|
+
console.log(chalk_1.default.yellow(' This session has no terminal to confirm on. Pass -y to proceed.\n'));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
// Show what is about to go before asking. A bare id says nothing about which playlist it is, and
|
|
81
|
+
// the answer cannot be taken back. A lookup failure is not fatal here: the delete path repeats
|
|
82
|
+
// the fetch and reports it properly, so the prompt just falls back to the identifier.
|
|
83
|
+
let label = idOrUrl;
|
|
84
|
+
try {
|
|
85
|
+
const stored = await fetchPlaylistForUnpublish(idOrUrl, selection.url);
|
|
86
|
+
const title = typeof stored.title === 'string' ? stored.title : '';
|
|
87
|
+
label = title ? `"${title}" (${stored.id ?? idOrUrl})` : String(stored.id ?? idOrUrl);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
label = idOrUrl;
|
|
91
|
+
}
|
|
92
|
+
console.log(chalk_1.default.yellow(` ${label}`));
|
|
93
|
+
console.log(chalk_1.default.dim(` Server: ${selection.url}`));
|
|
94
|
+
console.log(chalk_1.default.dim(` Signing as: ${signerDidKey}`));
|
|
95
|
+
console.log(chalk_1.default.dim(' This cannot be undone; the id is tombstoned and cannot be reused.'));
|
|
96
|
+
console.log();
|
|
97
|
+
const prompt = (0, prompt_1.createPrompt)();
|
|
98
|
+
const confirmed = await (0, prompt_1.promptYesNo)(prompt.ask, 'Delete this playlist?', false);
|
|
99
|
+
prompt.close();
|
|
100
|
+
console.log();
|
|
101
|
+
if (!confirmed) {
|
|
102
|
+
console.log(chalk_1.default.dim('Cancelled.\n'));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
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
|
+
});
|
|
116
|
+
if (result.success) {
|
|
117
|
+
console.log(chalk_1.default.green('Unpublished'));
|
|
118
|
+
if (result.playlistId) {
|
|
119
|
+
console.log(chalk_1.default.dim(` Playlist ID: ${result.playlistId}`));
|
|
120
|
+
}
|
|
121
|
+
if (result.slug) {
|
|
122
|
+
console.log(chalk_1.default.dim(` Slug: ${result.slug}`));
|
|
123
|
+
}
|
|
124
|
+
console.log(chalk_1.default.dim(` Server: ${result.feedServer}`));
|
|
125
|
+
if (result.message) {
|
|
126
|
+
console.log(chalk_1.default.dim(` Status: ${result.message}`));
|
|
127
|
+
}
|
|
128
|
+
console.log();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.error(chalk_1.default.red('\nUnpublish failed'));
|
|
132
|
+
if (result.error) {
|
|
133
|
+
console.error(chalk_1.default.red(` ${result.error}`));
|
|
134
|
+
}
|
|
135
|
+
if (result.message) {
|
|
136
|
+
console.log(chalk_1.default.yellow(`\n${result.message}`));
|
|
137
|
+
}
|
|
138
|
+
console.log();
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
console.error(chalk_1.default.red('\nError:'), error.message);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared machinery for the feed's two owner-bound mutations: `PUT` (replace) and `DELETE`.
|
|
4
|
+
*
|
|
5
|
+
* The feed has no API key. Every mutating request is authorized by signatures the body carries, and for
|
|
6
|
+
* replace and delete those signatures must come from a key the *stored* resource already names as an
|
|
7
|
+
* owner (dp1-feed-v2 `docs/api_design.md`, "Authentication and authorization"). A create signs the
|
|
8
|
+
* document itself; a replace and a delete additionally sign a short-lived **intent** envelope, because an
|
|
9
|
+
* owner's document signatures are public via `GET` and could otherwise be replayed — the per-signature
|
|
10
|
+
* `ts` is not covered by the signature, so only a `created` inside a signed payload can bound replay.
|
|
11
|
+
*
|
|
12
|
+
* Both intents therefore share the same shape and the same failure modes, which is why they live here
|
|
13
|
+
* rather than in the publisher and unpublisher separately: the envelope, the freshness rule, the
|
|
14
|
+
* ownership rule and the error vocabulary are one contract with two verbs.
|
|
15
|
+
*/
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.OWNER_ROLE = void 0;
|
|
21
|
+
exports.intentTimestamp = intentTimestamp;
|
|
22
|
+
exports.buildDeleteIntent = buildDeleteIntent;
|
|
23
|
+
exports.buildReplaceIntent = buildReplaceIntent;
|
|
24
|
+
exports.documentPayloadHash = documentPayloadHash;
|
|
25
|
+
exports.signIntent = signIntent;
|
|
26
|
+
exports.resolvePlaylistIdentifier = resolvePlaylistIdentifier;
|
|
27
|
+
exports.fetchStoredPlaylist = fetchStoredPlaylist;
|
|
28
|
+
exports.resolveMutationSigningKey = resolveMutationSigningKey;
|
|
29
|
+
exports.mutationSignerIdentity = mutationSignerIdentity;
|
|
30
|
+
exports.storedOwnerKeys = storedOwnerKeys;
|
|
31
|
+
exports.storedOwnership = storedOwnership;
|
|
32
|
+
exports.ownershipPreflight = ownershipPreflight;
|
|
33
|
+
exports.describeFeedMutationError = describeFeedMutationError;
|
|
34
|
+
exports.asPlaylist = asPlaylist;
|
|
35
|
+
const axios_1 = __importDefault(require("axios"));
|
|
36
|
+
const config_1 = require("../config");
|
|
37
|
+
const signing_identity_1 = require("./signing-identity");
|
|
38
|
+
// playlist-signer is still CommonJS; require keeps the interop simple.
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
40
|
+
const { normalizeSigningKeyToBase64Pkcs8 } = require('./playlist-signer');
|
|
41
|
+
/**
|
|
42
|
+
* DP-1 signature role that carries ownership of a playlist.
|
|
43
|
+
*
|
|
44
|
+
* Mirrors the feed's owner role for playlists (channels use `publisher`, which ff-cli does not publish).
|
|
45
|
+
* Intents are always signed in this role: only an owner signature authorizes a replace or a delete, so
|
|
46
|
+
* signing an intent under any other role produces a body the feed answers with `403`.
|
|
47
|
+
*/
|
|
48
|
+
exports.OWNER_ROLE = 'curator';
|
|
49
|
+
/**
|
|
50
|
+
* Whole-second RFC 3339 timestamp for an intent's `created`.
|
|
51
|
+
*
|
|
52
|
+
* The feed compares this against its own clock and rejects anything outside `auth.intent_max_clock_skew`
|
|
53
|
+
* (default 5 minutes) with `400 invalid_timestamp`. Milliseconds are stripped to match the form the
|
|
54
|
+
* playlist signer already emits, so both timestamps in a request read alike.
|
|
55
|
+
*/
|
|
56
|
+
function intentTimestamp() {
|
|
57
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Build the DELETE body's unsigned half.
|
|
61
|
+
*
|
|
62
|
+
* `id` and `slug` must match the stored row exactly — the feed compares both and answers `400
|
|
63
|
+
* bad_request` on any disagreement — which is why callers read them from a `GET` rather than from
|
|
64
|
+
* whatever the user typed.
|
|
65
|
+
*/
|
|
66
|
+
function buildDeleteIntent(id, slug, created) {
|
|
67
|
+
return { action: 'delete', target: { type: 'playlist', id, slug }, created };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build the `authorization` half of a PUT body.
|
|
71
|
+
*
|
|
72
|
+
* `payloadHash` binds the intent to the exact document being installed, so a captured intent cannot be
|
|
73
|
+
* replayed to install different bytes. It is the DP-1 signing digest of the accompanying document.
|
|
74
|
+
*/
|
|
75
|
+
function buildReplaceIntent(id, slug, payloadHash, created) {
|
|
76
|
+
return { action: 'replace', target: { type: 'playlist', id, slug }, payloadHash, created };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* DP-1 signing digest of a document, in the `sha256:<hex>` form the feed's `payloadHash` expects.
|
|
80
|
+
*
|
|
81
|
+
* dp1-js strips `signature`/`signatures` and JCS-canonicalizes internally, so the full signed document
|
|
82
|
+
* is passed through unchanged; computing it any other way risks diverging from the digest the document's
|
|
83
|
+
* own signatures were taken over.
|
|
84
|
+
*/
|
|
85
|
+
async function documentPayloadHash(document) {
|
|
86
|
+
const dp1 = await import('dp1-js');
|
|
87
|
+
return dp1.PayloadHashString(Buffer.from(JSON.stringify(document)));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Sign an intent with the playlist signing key, in the owner role.
|
|
91
|
+
*
|
|
92
|
+
* The signature covers the intent bytes with `signatures` stripped and JCS-canonicalized — the same
|
|
93
|
+
* payload rule DP-1 uses for documents — so the unsigned intent object is passed here as-is and the
|
|
94
|
+
* caller attaches the result afterwards. `ts` is set to the intent's own `created` so the two timestamps
|
|
95
|
+
* a reviewer sees in a request body agree.
|
|
96
|
+
*/
|
|
97
|
+
async function signIntent(intent, privateKeyMaterial) {
|
|
98
|
+
const dp1 = await import('dp1-js');
|
|
99
|
+
const normalizedKey = normalizeSigningKeyToBase64Pkcs8(privateKeyMaterial);
|
|
100
|
+
return (await dp1.SignMultiEd25519(Buffer.from(JSON.stringify(intent)), normalizedKey, exports.OWNER_ROLE, intent.created));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Reduce user input to the path segment the feed resolves.
|
|
104
|
+
*
|
|
105
|
+
* Operators copy playlist URLs out of a browser far more often than they copy bare UUIDs, and the feed's
|
|
106
|
+
* `{id}` parameter accepts either an id or a slug. Anything that parses as an HTTP(S) URL contributes
|
|
107
|
+
* its last non-empty path segment; everything else is passed through trimmed.
|
|
108
|
+
*/
|
|
109
|
+
function resolvePlaylistIdentifier(input) {
|
|
110
|
+
const trimmed = input.trim();
|
|
111
|
+
if (!/^https?:\/\//i.test(trimmed)) {
|
|
112
|
+
return trimmed;
|
|
113
|
+
}
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = new URL(trimmed);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return trimmed;
|
|
120
|
+
}
|
|
121
|
+
const segments = parsed.pathname.split('/').filter((segment) => segment.length > 0);
|
|
122
|
+
return segments.length > 0 ? decodeURIComponent(segments[segments.length - 1]) : trimmed;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Fetch the stored playlist a mutation will target.
|
|
126
|
+
*
|
|
127
|
+
* Both mutations need the stored copy before they can be built at all: the delete intent must name the
|
|
128
|
+
* stored `slug`, and a replace must prove its identity fields still equal the stored ones. Reading the
|
|
129
|
+
* owners from here rather than from the local file is the point — a local document can claim any
|
|
130
|
+
* `curators[]`, but only the stored set decides who may write.
|
|
131
|
+
*/
|
|
132
|
+
async function fetchStoredPlaylist(feedServerUrl, identifier) {
|
|
133
|
+
const response = await axios_1.default.get(`${feedServerUrl}/playlists/${encodeURIComponent(identifier)}`, {
|
|
134
|
+
headers: { Accept: 'application/json' },
|
|
135
|
+
timeout: 30000,
|
|
136
|
+
});
|
|
137
|
+
const stored = response.data;
|
|
138
|
+
if (!stored || typeof stored !== 'object') {
|
|
139
|
+
throw new Error('Feed returned no playlist document');
|
|
140
|
+
}
|
|
141
|
+
return stored;
|
|
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
|
+
}
|
|
190
|
+
/** Owner `did:key`s the stored playlist declares, in declaration order. */
|
|
191
|
+
function storedOwnerKeys(stored) {
|
|
192
|
+
const curators = Array.isArray(stored.curators) ? stored.curators : [];
|
|
193
|
+
return curators
|
|
194
|
+
.map((curator) => (typeof curator?.key === 'string' ? curator.key.trim() : ''))
|
|
195
|
+
.filter((key) => key.length > 0);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Resolve who can actually authorize a mutation on a stored playlist.
|
|
199
|
+
*
|
|
200
|
+
* Each candidate signature is verified individually rather than through a whole-envelope check: a stored
|
|
201
|
+
* document legitimately carries entries this CLI does not depend on — the feed's own `feed` signature,
|
|
202
|
+
* and any co-curator's — and one unverifiable stranger among them must not invalidate a proof that is
|
|
203
|
+
* itself sound. Verification is over the stored bytes with `signatures` stripped and JCS-canonicalized,
|
|
204
|
+
* so re-serializing the parsed document here is safe: canonicalization removes key-order and whitespace
|
|
205
|
+
* differences before the digest is taken.
|
|
206
|
+
*
|
|
207
|
+
* @param stored - The playlist as the feed serves it
|
|
208
|
+
* @returns Declared and proven owner keys
|
|
209
|
+
*/
|
|
210
|
+
async function storedOwnership(stored) {
|
|
211
|
+
const declared = storedOwnerKeys(stored);
|
|
212
|
+
const signatures = Array.isArray(stored.signatures)
|
|
213
|
+
? (stored.signatures ?? []).filter(Boolean)
|
|
214
|
+
: [];
|
|
215
|
+
if (declared.length === 0 || signatures.length === 0) {
|
|
216
|
+
return { declared, proven: [] };
|
|
217
|
+
}
|
|
218
|
+
const dp1 = await import('dp1-js');
|
|
219
|
+
const raw = Buffer.from(JSON.stringify(stored));
|
|
220
|
+
const proven = [];
|
|
221
|
+
for (const signature of signatures) {
|
|
222
|
+
const kid = typeof signature?.kid === 'string' ? signature.kid.trim() : '';
|
|
223
|
+
if (signature?.role !== exports.OWNER_ROLE || !declared.includes(kid) || proven.includes(kid)) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
dp1.VerifyMultiSignature(raw, signature);
|
|
228
|
+
proven.push(kid);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// An entry that does not verify is not proof. It is also not this command's business to report:
|
|
232
|
+
// a stale or tampered signature on someone else's key changes nothing about whether the
|
|
233
|
+
// signing key can act, and the answer below is derived from the set that survived.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { declared, proven };
|
|
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
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Refuse locally when the signing key cannot authorize a mutation on the stored playlist.
|
|
255
|
+
*
|
|
256
|
+
* The feed's own answer to every case here is a bare `403 forbidden`, which says nothing about which
|
|
257
|
+
* identity was offered, which ones would have worked, or whether the problem is the declaration or the
|
|
258
|
+
* proof. Separating them costs no request beyond the `GET` the mutation already had to make, and the
|
|
259
|
+
* three answers point in genuinely different directions: switch keys, nothing can ever work, or the
|
|
260
|
+
* declaration is right and the signature that would back it was never made.
|
|
261
|
+
*
|
|
262
|
+
* @param stored - The playlist as the feed serves it
|
|
263
|
+
* @param signerDidKey - `did:key` the signing key will assert
|
|
264
|
+
* @param action - Which mutation is being authorized, for the wording
|
|
265
|
+
* @returns A failure to report, or `null` when the signing key is a proven owner.
|
|
266
|
+
*/
|
|
267
|
+
async function ownershipPreflight(stored, signerDidKey, action, keySource = 'configured') {
|
|
268
|
+
const { declared, proven } = await storedOwnership(stored);
|
|
269
|
+
if (proven.includes(signerDidKey)) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const verb = action === 'delete' ? 'delete' : 'replace';
|
|
273
|
+
// A playlist with no declared curators is not "owned by someone else" — it is owned by nobody, and
|
|
274
|
+
// nothing can ever authorize a write to it. Telling that operator to switch keys sends them looking
|
|
275
|
+
// for one that does not exist; this is the state a playlist published without any curator declaration
|
|
276
|
+
// is frozen in, and it is exactly what the publish-time curator check now prevents.
|
|
277
|
+
if (declared.length === 0) {
|
|
278
|
+
return {
|
|
279
|
+
error: `This playlist declares no owners, so no key can ${verb} it.`,
|
|
280
|
+
message: `The feed derives ownership from the stored document's curators[], and this one is empty — the\n` +
|
|
281
|
+
` shape a playlist published without an owner-role signature is frozen in. No signature can\n` +
|
|
282
|
+
` authorize a ${verb}, including this one (${signerDidKey}).\n` +
|
|
283
|
+
` Nothing can repair it: the owner set is immutable, and only an owner could change it. Publish\n` +
|
|
284
|
+
` a corrected playlist under a new id instead.`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
// Declared, but nobody proved it. Same terminal outcome as the case above, different cause and
|
|
288
|
+
// therefore a different message: curators[] is correct and the owner-role signature that would back
|
|
289
|
+
// it was never made. This is the state of everything published from 2.5.0 with the default `agent`
|
|
290
|
+
// role, so it is the case an operator is most likely to meet on an older playlist.
|
|
291
|
+
if (proven.length === 0) {
|
|
292
|
+
return {
|
|
293
|
+
error: `No key has proved ownership of this playlist, so none can ${verb} it.`,
|
|
294
|
+
message: `A key counts as an owner only when it is named in curators[] AND signed the document as\n` +
|
|
295
|
+
` "${exports.OWNER_ROLE}". This playlist names ${declared.length === 1 ? 'a curator' : `${declared.length} curators`} and carries no valid ${exports.OWNER_ROLE} signature\n` +
|
|
296
|
+
` from ${declared.length === 1 ? 'that key' : 'any of them'} — the shape of a playlist published while the default signing role was\n` +
|
|
297
|
+
` "agent".\n` +
|
|
298
|
+
` Declared:\n` +
|
|
299
|
+
`${declared.map((key) => ` ${key}`).join('\n')}\n` +
|
|
300
|
+
` Nothing can repair it, including holding one of those keys: adding the missing signature\n` +
|
|
301
|
+
` would be a replace, and a replace needs the very proof that is missing. Publish a corrected\n` +
|
|
302
|
+
` playlist under a new id instead.`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
// Declared, and someone proved it — but not this key. Distinct from the case above: an owner exists,
|
|
306
|
+
// it is simply not the one configured here.
|
|
307
|
+
if (declared.includes(signerDidKey)) {
|
|
308
|
+
return {
|
|
309
|
+
error: `This key is declared on this playlist but never signed it as ${exports.OWNER_ROLE}, so it cannot ${verb} it.`,
|
|
310
|
+
message: `The feed treats a declared key as an owner only once it has also signed the stored document\n` +
|
|
311
|
+
` as "${exports.OWNER_ROLE}". This one is named in curators[] but carries no such signature, so an\n` +
|
|
312
|
+
` intent signed with it would be refused.\n` +
|
|
313
|
+
` ${identityLabel(keySource)}\n` +
|
|
314
|
+
` ${signerDidKey}\n` +
|
|
315
|
+
` Keys that have proved ownership:\n` +
|
|
316
|
+
`${proven.map((key) => ` ${key}`).join('\n')}\n` +
|
|
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.`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const source = keySource === 'supplied' ? 'The key you passed with --key is' : 'The configured signing key is';
|
|
323
|
+
return {
|
|
324
|
+
error: `${source} not an owner of this playlist, so it cannot ${verb} it.`,
|
|
325
|
+
message: `Only a key the stored playlist names in curators[] AND that signed it as "${exports.OWNER_ROLE}" can\n` +
|
|
326
|
+
` authorize a ${verb}; the feed derives ownership from the stored document, not from a local copy.\n` +
|
|
327
|
+
` ${identityLabel(keySource)}\n` +
|
|
328
|
+
` ${signerDidKey}\n` +
|
|
329
|
+
` Keys that have proved ownership:\n` +
|
|
330
|
+
`${proven.map((key) => ` ${key}`).join('\n')}\n` +
|
|
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.`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/** Feed error code carried in an error response body, when there is one. */
|
|
337
|
+
function feedErrorCode(error) {
|
|
338
|
+
const data = error.response?.data;
|
|
339
|
+
const code = data?.error ?? data?.code;
|
|
340
|
+
return typeof code === 'string' ? code : '';
|
|
341
|
+
}
|
|
342
|
+
/** Human-readable detail the feed supplied, if any. */
|
|
343
|
+
function feedErrorDetail(error) {
|
|
344
|
+
const data = error.response?.data;
|
|
345
|
+
if (typeof data?.message === 'string' && data.message.trim().length > 0) {
|
|
346
|
+
return data.message.trim();
|
|
347
|
+
}
|
|
348
|
+
return '';
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Translate a feed mutation failure into the CLI's diagnosis-plus-remedy shape.
|
|
352
|
+
*
|
|
353
|
+
* The feed's status codes each mean something specific for an owner-bound write, and the raw JSON body
|
|
354
|
+
* does not say which of them the operator hit — `400` alone covers both a stale intent and a target
|
|
355
|
+
* mismatch. Mapping them here keeps `unpublish` and `publish --replace` answering in the same
|
|
356
|
+
* vocabulary, and keeps that vocabulary next to the contract it mirrors.
|
|
357
|
+
*
|
|
358
|
+
* The codes handled are exactly those dp1-feed-v2 documents for these routes; anything else falls
|
|
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
|
|
369
|
+
*/
|
|
370
|
+
function describeFeedMutationError(error, action, keySource = 'configured') {
|
|
371
|
+
const axiosError = error;
|
|
372
|
+
const status = axiosError.response?.status;
|
|
373
|
+
const code = feedErrorCode(axiosError);
|
|
374
|
+
const detail = feedErrorDetail(axiosError);
|
|
375
|
+
const verb = action === 'delete' ? 'Delete' : 'Replace';
|
|
376
|
+
const suffix = detail ? `\n Feed said: ${detail}` : '';
|
|
377
|
+
if (status === undefined) {
|
|
378
|
+
return { error: `${verb} failed: ${axiosError.message}` };
|
|
379
|
+
}
|
|
380
|
+
if (status === 401) {
|
|
381
|
+
return {
|
|
382
|
+
error: `${verb} refused: the feed saw no signatures on the request.`,
|
|
383
|
+
message: `Every mutating request is authorized by the signatures in its body — there is no API key.\n` +
|
|
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.'}`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
// A 403 that reaches here has already passed the local ownership proof: the key that signed was found
|
|
393
|
+
// in the stored curators[] with a valid curator-role signature over the stored bytes. Repeating "your
|
|
394
|
+
// key is not declared" would therefore be a lie, and it is the wrong place to send someone — the
|
|
395
|
+
// remaining causes are the feed disagreeing about the stored document, or a replace touching the
|
|
396
|
+
// owner set. Say that the feed refused, and that the local check disagreed.
|
|
397
|
+
if (status === 403) {
|
|
398
|
+
const which = keySource === 'supplied'
|
|
399
|
+
? 'the key you passed with --key is'
|
|
400
|
+
: 'the configured signing key is';
|
|
401
|
+
return {
|
|
402
|
+
error: `${verb} refused by the feed: it did not accept the signing key as an owner.`,
|
|
403
|
+
message: `The local check disagreed — ${which} named in the stored playlist's curators[]\n` +
|
|
404
|
+
` and carries a valid "${exports.OWNER_ROLE}" signature over the stored document — so this is the feed's\n` +
|
|
405
|
+
` own judgement, not a missing declaration.\n` +
|
|
406
|
+
(action === 'replace'
|
|
407
|
+
? ` For a replace the usual cause is the submitted document changing the owner set: curators[]\n` +
|
|
408
|
+
` is immutable, and altering it is refused as a forbidden write rather than as a bad field.\n`
|
|
409
|
+
: '') +
|
|
410
|
+
` Otherwise the stored document changed since it was read; run the command again, and report it\n` +
|
|
411
|
+
` if it persists.${suffix}`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
if (status === 400 && code === 'invalid_timestamp') {
|
|
415
|
+
return {
|
|
416
|
+
error: `${verb} refused: the signed intent was outside the feed's freshness window.`,
|
|
417
|
+
message: `An intent is valid for about five minutes, and the window is wall-clock, so a machine whose\n` +
|
|
418
|
+
` clock has drifted fails every attempt. Check the system clock, then run the command again — a\n` +
|
|
419
|
+
` new intent is signed each time.${suffix}`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
if (status === 400) {
|
|
423
|
+
return {
|
|
424
|
+
error: `${verb} refused: the request did not match the stored playlist.`,
|
|
425
|
+
message: action === 'delete'
|
|
426
|
+
? ` The delete intent must name the stored id and slug exactly. They are read from the feed\n` +
|
|
427
|
+
` immediately before the request, so this means the playlist changed in between: run the\n` +
|
|
428
|
+
` command again.${suffix}`
|
|
429
|
+
: ` A replace may not change identity: id, slug, and created must equal the stored document's,\n` +
|
|
430
|
+
` and every signature must verify. Re-read the stored copy and re-sign from it.${suffix}`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (status === 404) {
|
|
434
|
+
return {
|
|
435
|
+
error: `${verb} refused: the feed has no such playlist.`,
|
|
436
|
+
message: `Either the id or slug is wrong for this feed, or the playlist was already deleted. Deleted ids\n` +
|
|
437
|
+
` are tombstoned and never reused, so a deleted playlist cannot be restored under the same id.${suffix}`,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (status === 409) {
|
|
441
|
+
return {
|
|
442
|
+
error: `${verb} refused: the playlist changed between authorization and the write.`,
|
|
443
|
+
message: `Another write landed first. Re-read the playlist and run the command again.${suffix}`,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
const body = axiosError.response?.data
|
|
447
|
+
? JSON.stringify(axiosError.response.data)
|
|
448
|
+
: axiosError.message;
|
|
449
|
+
return { error: `${verb} failed with status ${status}: ${body}` };
|
|
450
|
+
}
|
|
451
|
+
/** Narrow a stored playlist to a `Playlist` for the shared verification paths. */
|
|
452
|
+
function asPlaylist(stored) {
|
|
453
|
+
return stored;
|
|
454
|
+
}
|