@feralfile/cli 2.7.0 → 2.8.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/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 +19 -31
- package/dist/src/commands/sign.js +22 -1
- package/dist/src/commands/unpublish.js +103 -0
- package/dist/src/utilities/feed-mutation.js +371 -0
- package/dist/src/utilities/playlist-publisher.js +382 -148
- package/dist/src/utilities/playlist-signer.js +30 -4
- package/dist/src/utilities/playlist-unpublisher.js +159 -0
- package/docs/CONFIGURATION.md +19 -1
- package/docs/EXAMPLES.md +199 -0
- package/docs/PROJECT_SPEC.md +33 -1
- package/docs/README.md +48 -2
- package/package.json +1 -1
|
@@ -0,0 +1,371 @@
|
|
|
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.storedOwnerKeys = storedOwnerKeys;
|
|
29
|
+
exports.storedOwnership = storedOwnership;
|
|
30
|
+
exports.ownershipPreflight = ownershipPreflight;
|
|
31
|
+
exports.describeFeedMutationError = describeFeedMutationError;
|
|
32
|
+
exports.asPlaylist = asPlaylist;
|
|
33
|
+
const axios_1 = __importDefault(require("axios"));
|
|
34
|
+
// playlist-signer is still CommonJS; require keeps the interop simple.
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
|
+
const { normalizeSigningKeyToBase64Pkcs8 } = require('./playlist-signer');
|
|
37
|
+
/**
|
|
38
|
+
* DP-1 signature role that carries ownership of a playlist.
|
|
39
|
+
*
|
|
40
|
+
* Mirrors the feed's owner role for playlists (channels use `publisher`, which ff-cli does not publish).
|
|
41
|
+
* Intents are always signed in this role: only an owner signature authorizes a replace or a delete, so
|
|
42
|
+
* signing an intent under any other role produces a body the feed answers with `403`.
|
|
43
|
+
*/
|
|
44
|
+
exports.OWNER_ROLE = 'curator';
|
|
45
|
+
/**
|
|
46
|
+
* Whole-second RFC 3339 timestamp for an intent's `created`.
|
|
47
|
+
*
|
|
48
|
+
* The feed compares this against its own clock and rejects anything outside `auth.intent_max_clock_skew`
|
|
49
|
+
* (default 5 minutes) with `400 invalid_timestamp`. Milliseconds are stripped to match the form the
|
|
50
|
+
* playlist signer already emits, so both timestamps in a request read alike.
|
|
51
|
+
*/
|
|
52
|
+
function intentTimestamp() {
|
|
53
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build the DELETE body's unsigned half.
|
|
57
|
+
*
|
|
58
|
+
* `id` and `slug` must match the stored row exactly — the feed compares both and answers `400
|
|
59
|
+
* bad_request` on any disagreement — which is why callers read them from a `GET` rather than from
|
|
60
|
+
* whatever the user typed.
|
|
61
|
+
*/
|
|
62
|
+
function buildDeleteIntent(id, slug, created) {
|
|
63
|
+
return { action: 'delete', target: { type: 'playlist', id, slug }, created };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build the `authorization` half of a PUT body.
|
|
67
|
+
*
|
|
68
|
+
* `payloadHash` binds the intent to the exact document being installed, so a captured intent cannot be
|
|
69
|
+
* replayed to install different bytes. It is the DP-1 signing digest of the accompanying document.
|
|
70
|
+
*/
|
|
71
|
+
function buildReplaceIntent(id, slug, payloadHash, created) {
|
|
72
|
+
return { action: 'replace', target: { type: 'playlist', id, slug }, payloadHash, created };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* DP-1 signing digest of a document, in the `sha256:<hex>` form the feed's `payloadHash` expects.
|
|
76
|
+
*
|
|
77
|
+
* dp1-js strips `signature`/`signatures` and JCS-canonicalizes internally, so the full signed document
|
|
78
|
+
* is passed through unchanged; computing it any other way risks diverging from the digest the document's
|
|
79
|
+
* own signatures were taken over.
|
|
80
|
+
*/
|
|
81
|
+
async function documentPayloadHash(document) {
|
|
82
|
+
const dp1 = await import('dp1-js');
|
|
83
|
+
return dp1.PayloadHashString(Buffer.from(JSON.stringify(document)));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Sign an intent with the playlist signing key, in the owner role.
|
|
87
|
+
*
|
|
88
|
+
* The signature covers the intent bytes with `signatures` stripped and JCS-canonicalized — the same
|
|
89
|
+
* payload rule DP-1 uses for documents — so the unsigned intent object is passed here as-is and the
|
|
90
|
+
* caller attaches the result afterwards. `ts` is set to the intent's own `created` so the two timestamps
|
|
91
|
+
* a reviewer sees in a request body agree.
|
|
92
|
+
*/
|
|
93
|
+
async function signIntent(intent, privateKeyMaterial) {
|
|
94
|
+
const dp1 = await import('dp1-js');
|
|
95
|
+
const normalizedKey = normalizeSigningKeyToBase64Pkcs8(privateKeyMaterial);
|
|
96
|
+
return (await dp1.SignMultiEd25519(Buffer.from(JSON.stringify(intent)), normalizedKey, exports.OWNER_ROLE, intent.created));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Reduce user input to the path segment the feed resolves.
|
|
100
|
+
*
|
|
101
|
+
* Operators copy playlist URLs out of a browser far more often than they copy bare UUIDs, and the feed's
|
|
102
|
+
* `{id}` parameter accepts either an id or a slug. Anything that parses as an HTTP(S) URL contributes
|
|
103
|
+
* its last non-empty path segment; everything else is passed through trimmed.
|
|
104
|
+
*/
|
|
105
|
+
function resolvePlaylistIdentifier(input) {
|
|
106
|
+
const trimmed = input.trim();
|
|
107
|
+
if (!/^https?:\/\//i.test(trimmed)) {
|
|
108
|
+
return trimmed;
|
|
109
|
+
}
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = new URL(trimmed);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return trimmed;
|
|
116
|
+
}
|
|
117
|
+
const segments = parsed.pathname.split('/').filter((segment) => segment.length > 0);
|
|
118
|
+
return segments.length > 0 ? decodeURIComponent(segments[segments.length - 1]) : trimmed;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Fetch the stored playlist a mutation will target.
|
|
122
|
+
*
|
|
123
|
+
* Both mutations need the stored copy before they can be built at all: the delete intent must name the
|
|
124
|
+
* stored `slug`, and a replace must prove its identity fields still equal the stored ones. Reading the
|
|
125
|
+
* owners from here rather than from the local file is the point — a local document can claim any
|
|
126
|
+
* `curators[]`, but only the stored set decides who may write.
|
|
127
|
+
*/
|
|
128
|
+
async function fetchStoredPlaylist(feedServerUrl, identifier) {
|
|
129
|
+
const response = await axios_1.default.get(`${feedServerUrl}/playlists/${encodeURIComponent(identifier)}`, {
|
|
130
|
+
headers: { Accept: 'application/json' },
|
|
131
|
+
timeout: 30000,
|
|
132
|
+
});
|
|
133
|
+
const stored = response.data;
|
|
134
|
+
if (!stored || typeof stored !== 'object') {
|
|
135
|
+
throw new Error('Feed returned no playlist document');
|
|
136
|
+
}
|
|
137
|
+
return stored;
|
|
138
|
+
}
|
|
139
|
+
/** Owner `did:key`s the stored playlist declares, in declaration order. */
|
|
140
|
+
function storedOwnerKeys(stored) {
|
|
141
|
+
const curators = Array.isArray(stored.curators) ? stored.curators : [];
|
|
142
|
+
return curators
|
|
143
|
+
.map((curator) => (typeof curator?.key === 'string' ? curator.key.trim() : ''))
|
|
144
|
+
.filter((key) => key.length > 0);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Resolve who can actually authorize a mutation on a stored playlist.
|
|
148
|
+
*
|
|
149
|
+
* Each candidate signature is verified individually rather than through a whole-envelope check: a stored
|
|
150
|
+
* document legitimately carries entries this CLI does not depend on — the feed's own `feed` signature,
|
|
151
|
+
* and any co-curator's — and one unverifiable stranger among them must not invalidate a proof that is
|
|
152
|
+
* itself sound. Verification is over the stored bytes with `signatures` stripped and JCS-canonicalized,
|
|
153
|
+
* so re-serializing the parsed document here is safe: canonicalization removes key-order and whitespace
|
|
154
|
+
* differences before the digest is taken.
|
|
155
|
+
*
|
|
156
|
+
* @param stored - The playlist as the feed serves it
|
|
157
|
+
* @returns Declared and proven owner keys
|
|
158
|
+
*/
|
|
159
|
+
async function storedOwnership(stored) {
|
|
160
|
+
const declared = storedOwnerKeys(stored);
|
|
161
|
+
const signatures = Array.isArray(stored.signatures)
|
|
162
|
+
? (stored.signatures ?? []).filter(Boolean)
|
|
163
|
+
: [];
|
|
164
|
+
if (declared.length === 0 || signatures.length === 0) {
|
|
165
|
+
return { declared, proven: [] };
|
|
166
|
+
}
|
|
167
|
+
const dp1 = await import('dp1-js');
|
|
168
|
+
const raw = Buffer.from(JSON.stringify(stored));
|
|
169
|
+
const proven = [];
|
|
170
|
+
for (const signature of signatures) {
|
|
171
|
+
const kid = typeof signature?.kid === 'string' ? signature.kid.trim() : '';
|
|
172
|
+
if (signature?.role !== exports.OWNER_ROLE || !declared.includes(kid) || proven.includes(kid)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
dp1.VerifyMultiSignature(raw, signature);
|
|
177
|
+
proven.push(kid);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// An entry that does not verify is not proof. It is also not this command's business to report:
|
|
181
|
+
// a stale or tampered signature on someone else's key changes nothing about whether the
|
|
182
|
+
// configured key can act, and the answer below is derived from the set that survived.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { declared, proven };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Refuse locally when the configured key cannot authorize a mutation on the stored playlist.
|
|
189
|
+
*
|
|
190
|
+
* The feed's own answer to every case here is a bare `403 forbidden`, which says nothing about which
|
|
191
|
+
* identity was offered, which ones would have worked, or whether the problem is the declaration or the
|
|
192
|
+
* proof. Separating them costs no request beyond the `GET` the mutation already had to make, and the
|
|
193
|
+
* three answers point in genuinely different directions: switch keys, nothing can ever work, or the
|
|
194
|
+
* declaration is right and the signature that would back it was never made.
|
|
195
|
+
*
|
|
196
|
+
* @param stored - The playlist as the feed serves it
|
|
197
|
+
* @param signerDidKey - `did:key` the configured signing key will assert
|
|
198
|
+
* @param action - Which mutation is being authorized, for the wording
|
|
199
|
+
* @returns A failure to report, or `null` when the configured key is a proven owner.
|
|
200
|
+
*/
|
|
201
|
+
async function ownershipPreflight(stored, signerDidKey, action) {
|
|
202
|
+
const { declared, proven } = await storedOwnership(stored);
|
|
203
|
+
if (proven.includes(signerDidKey)) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const verb = action === 'delete' ? 'delete' : 'replace';
|
|
207
|
+
// A playlist with no declared curators is not "owned by someone else" — it is owned by nobody, and
|
|
208
|
+
// nothing can ever authorize a write to it. Telling that operator to switch keys sends them looking
|
|
209
|
+
// for one that does not exist; this is the state a playlist published without any curator declaration
|
|
210
|
+
// is frozen in, and it is exactly what the publish-time curator check now prevents.
|
|
211
|
+
if (declared.length === 0) {
|
|
212
|
+
return {
|
|
213
|
+
error: `This playlist declares no owners, so no key can ${verb} it.`,
|
|
214
|
+
message: `The feed derives ownership from the stored document's curators[], and this one is empty — the\n` +
|
|
215
|
+
` shape a playlist published without an owner-role signature is frozen in. No signature can\n` +
|
|
216
|
+
` authorize a ${verb}, including yours (${signerDidKey}).\n` +
|
|
217
|
+
` Nothing can repair it: the owner set is immutable, and only an owner could change it. Publish\n` +
|
|
218
|
+
` a corrected playlist under a new id instead.`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// Declared, but nobody proved it. Same terminal outcome as the case above, different cause and
|
|
222
|
+
// therefore a different message: curators[] is correct and the owner-role signature that would back
|
|
223
|
+
// it was never made. This is the state of everything published from 2.5.0 with the default `agent`
|
|
224
|
+
// role, so it is the case an operator is most likely to meet on an older playlist.
|
|
225
|
+
if (proven.length === 0) {
|
|
226
|
+
return {
|
|
227
|
+
error: `No key has proved ownership of this playlist, so none can ${verb} it.`,
|
|
228
|
+
message: `A key counts as an owner only when it is named in curators[] AND signed the document as\n` +
|
|
229
|
+
` "${exports.OWNER_ROLE}". This playlist names ${declared.length === 1 ? 'a curator' : `${declared.length} curators`} and carries no valid ${exports.OWNER_ROLE} signature\n` +
|
|
230
|
+
` from ${declared.length === 1 ? 'that key' : 'any of them'} — the shape of a playlist published while the default signing role was\n` +
|
|
231
|
+
` "agent".\n` +
|
|
232
|
+
` Declared:\n` +
|
|
233
|
+
`${declared.map((key) => ` ${key}`).join('\n')}\n` +
|
|
234
|
+
` Nothing can repair it, including holding one of those keys: adding the missing signature\n` +
|
|
235
|
+
` would be a replace, and a replace needs the very proof that is missing. Publish a corrected\n` +
|
|
236
|
+
` playlist under a new id instead.`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// Declared, and someone proved it — but not this key. Distinct from the case above: an owner exists,
|
|
240
|
+
// it is simply not the one configured here.
|
|
241
|
+
if (declared.includes(signerDidKey)) {
|
|
242
|
+
return {
|
|
243
|
+
error: `Your key is declared on this playlist but never signed it as ${exports.OWNER_ROLE}, so it cannot ${verb} it.`,
|
|
244
|
+
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}". Yours is named in curators[] but carries no such signature, so an intent\n` +
|
|
246
|
+
` signed with it would be refused.\n` +
|
|
247
|
+
` Your configured identity:\n` +
|
|
248
|
+
` ${signerDidKey}\n` +
|
|
249
|
+
` Keys that have proved ownership:\n` +
|
|
250
|
+
`${proven.map((key) => ` ${key}`).join('\n')}\n` +
|
|
251
|
+
` Point playlist.privateKey at one of those (confirm any key's identity with\n` +
|
|
252
|
+
` "ff-cli status --key <private key>"). Your own declaration cannot be upgraded after the fact:\n` +
|
|
253
|
+
` the proof would have to be a signature over the document as published.`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
error: `The configured signing key is not an owner of this playlist, so it cannot ${verb} it.`,
|
|
258
|
+
message: `Only a key the stored playlist names in curators[] AND that signed it as "${exports.OWNER_ROLE}" can\n` +
|
|
259
|
+
` authorize a ${verb}; the feed derives ownership from the stored document, not from a local copy.\n` +
|
|
260
|
+
` Your configured identity:\n` +
|
|
261
|
+
` ${signerDidKey}\n` +
|
|
262
|
+
` Keys that have proved ownership:\n` +
|
|
263
|
+
`${proven.map((key) => ` ${key}`).join('\n')}\n` +
|
|
264
|
+
` Point playlist.privateKey at a key listed above (confirm any key's identity with\n` +
|
|
265
|
+
` "ff-cli status --key <private key>"). Ownership cannot be granted after the fact: the owner set\n` +
|
|
266
|
+
` is immutable, so a playlist signed by the wrong key stays that way.`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/** Feed error code carried in an error response body, when there is one. */
|
|
270
|
+
function feedErrorCode(error) {
|
|
271
|
+
const data = error.response?.data;
|
|
272
|
+
const code = data?.error ?? data?.code;
|
|
273
|
+
return typeof code === 'string' ? code : '';
|
|
274
|
+
}
|
|
275
|
+
/** Human-readable detail the feed supplied, if any. */
|
|
276
|
+
function feedErrorDetail(error) {
|
|
277
|
+
const data = error.response?.data;
|
|
278
|
+
if (typeof data?.message === 'string' && data.message.trim().length > 0) {
|
|
279
|
+
return data.message.trim();
|
|
280
|
+
}
|
|
281
|
+
return '';
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Translate a feed mutation failure into the CLI's diagnosis-plus-remedy shape.
|
|
285
|
+
*
|
|
286
|
+
* The feed's status codes each mean something specific for an owner-bound write, and the raw JSON body
|
|
287
|
+
* does not say which of them the operator hit — `400` alone covers both a stale intent and a target
|
|
288
|
+
* mismatch. Mapping them here keeps `unpublish` and `publish --replace` answering in the same
|
|
289
|
+
* vocabulary, and keeps that vocabulary next to the contract it mirrors.
|
|
290
|
+
*
|
|
291
|
+
* The codes handled are exactly those dp1-feed-v2 documents for these routes; anything else falls
|
|
292
|
+
* through with the server's own words rather than being guessed at.
|
|
293
|
+
*/
|
|
294
|
+
function describeFeedMutationError(error, action) {
|
|
295
|
+
const axiosError = error;
|
|
296
|
+
const status = axiosError.response?.status;
|
|
297
|
+
const code = feedErrorCode(axiosError);
|
|
298
|
+
const detail = feedErrorDetail(axiosError);
|
|
299
|
+
const verb = action === 'delete' ? 'Delete' : 'Replace';
|
|
300
|
+
const suffix = detail ? `\n Feed said: ${detail}` : '';
|
|
301
|
+
if (status === undefined) {
|
|
302
|
+
return { error: `${verb} failed: ${axiosError.message}` };
|
|
303
|
+
}
|
|
304
|
+
if (status === 401) {
|
|
305
|
+
return {
|
|
306
|
+
error: `${verb} refused: the feed saw no signatures on the request.`,
|
|
307
|
+
message: `Every mutating request is authorized by the signatures in its body — there is no API key.\n` +
|
|
308
|
+
` This usually means no signing key is configured: run "ff-cli status" to check, and\n` +
|
|
309
|
+
` "ff-cli setup" to generate one.${suffix}`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// A 403 that reaches here has already passed the local ownership proof: the configured key was found
|
|
313
|
+
// in the stored curators[] with a valid curator-role signature over the stored bytes. Repeating "your
|
|
314
|
+
// key is not declared" would therefore be a lie, and it is the wrong place to send someone — the
|
|
315
|
+
// remaining causes are the feed disagreeing about the stored document, or a replace touching the
|
|
316
|
+
// owner set. Say that the feed refused, and that the local check disagreed.
|
|
317
|
+
if (status === 403) {
|
|
318
|
+
return {
|
|
319
|
+
error: `${verb} refused by the feed: it did not accept the signing key as an owner.`,
|
|
320
|
+
message: `The local check disagreed — the configured key is named in the stored playlist's curators[]\n` +
|
|
321
|
+
` and carries a valid "${exports.OWNER_ROLE}" signature over the stored document — so this is the feed's\n` +
|
|
322
|
+
` own judgement, not a missing declaration.\n` +
|
|
323
|
+
(action === 'replace'
|
|
324
|
+
? ` For a replace the usual cause is the submitted document changing the owner set: curators[]\n` +
|
|
325
|
+
` is immutable, and altering it is refused as a forbidden write rather than as a bad field.\n`
|
|
326
|
+
: '') +
|
|
327
|
+
` Otherwise the stored document changed since it was read; run the command again, and report it\n` +
|
|
328
|
+
` if it persists.${suffix}`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (status === 400 && code === 'invalid_timestamp') {
|
|
332
|
+
return {
|
|
333
|
+
error: `${verb} refused: the signed intent was outside the feed's freshness window.`,
|
|
334
|
+
message: `An intent is valid for about five minutes, and the window is wall-clock, so a machine whose\n` +
|
|
335
|
+
` clock has drifted fails every attempt. Check the system clock, then run the command again — a\n` +
|
|
336
|
+
` new intent is signed each time.${suffix}`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (status === 400) {
|
|
340
|
+
return {
|
|
341
|
+
error: `${verb} refused: the request did not match the stored playlist.`,
|
|
342
|
+
message: action === 'delete'
|
|
343
|
+
? ` The delete intent must name the stored id and slug exactly. They are read from the feed\n` +
|
|
344
|
+
` immediately before the request, so this means the playlist changed in between: run the\n` +
|
|
345
|
+
` command again.${suffix}`
|
|
346
|
+
: ` A replace may not change identity: id, slug, and created must equal the stored document's,\n` +
|
|
347
|
+
` and every signature must verify. Re-read the stored copy and re-sign from it.${suffix}`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (status === 404) {
|
|
351
|
+
return {
|
|
352
|
+
error: `${verb} refused: the feed has no such playlist.`,
|
|
353
|
+
message: `Either the id or slug is wrong for this feed, or the playlist was already deleted. Deleted ids\n` +
|
|
354
|
+
` are tombstoned and never reused, so a deleted playlist cannot be restored under the same id.${suffix}`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (status === 409) {
|
|
358
|
+
return {
|
|
359
|
+
error: `${verb} refused: the playlist changed between authorization and the write.`,
|
|
360
|
+
message: `Another write landed first. Re-read the playlist and run the command again.${suffix}`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const body = axiosError.response?.data
|
|
364
|
+
? JSON.stringify(axiosError.response.data)
|
|
365
|
+
: axiosError.message;
|
|
366
|
+
return { error: `${verb} failed with status ${status}: ${body}` };
|
|
367
|
+
}
|
|
368
|
+
/** Narrow a stored playlist to a `Playlist` for the shared verification paths. */
|
|
369
|
+
function asPlaylist(stored) {
|
|
370
|
+
return stored;
|
|
371
|
+
}
|