@feralfile/cli 2.6.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/config.json.example +1 -1
- 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 +15 -75
- package/dist/src/commands/helpers/feed-server.js +105 -0
- package/dist/src/commands/helpers/playlist-display.js +3 -36
- package/dist/src/commands/publish.js +20 -65
- package/dist/src/commands/sign.js +22 -1
- package/dist/src/commands/ssh.js +1 -34
- package/dist/src/commands/unpublish.js +103 -0
- package/dist/src/utilities/feed-fetcher.js +1 -0
- package/dist/src/utilities/feed-mutation.js +371 -0
- package/dist/src/utilities/functions.js +4 -36
- package/dist/src/utilities/index.js +2 -34
- package/dist/src/utilities/nft-indexer.js +127 -30
- package/dist/src/utilities/playlist-builder.js +1 -0
- package/dist/src/utilities/playlist-cast.js +1 -34
- package/dist/src/utilities/playlist-publisher.js +382 -148
- package/dist/src/utilities/playlist-send.js +2 -35
- package/dist/src/utilities/playlist-signer.js +32 -38
- package/dist/src/utilities/playlist-signing-role.js +1 -0
- package/dist/src/utilities/playlist-unpublisher.js +159 -0
- package/dist/src/utilities/playlist-verifier.js +4 -37
- package/docs/CONFIGURATION.md +21 -3
- package/docs/EXAMPLES.md +199 -0
- package/docs/PROJECT_SPEC.md +43 -1
- package/docs/README.md +48 -2
- package/package.json +1 -1
|
@@ -1,40 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
1
2
|
/**
|
|
2
3
|
* Playlist Signing Utility.
|
|
3
4
|
* Uses the DP-1 v1.1.0 signing contract via the `dp1-js` package.
|
|
4
5
|
*/
|
|
5
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
-
if (k2 === undefined) k2 = k;
|
|
7
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
-
}
|
|
11
|
-
Object.defineProperty(o, k2, desc);
|
|
12
|
-
}) : (function(o, m, k, k2) {
|
|
13
|
-
if (k2 === undefined) k2 = k;
|
|
14
|
-
o[k2] = m[k];
|
|
15
|
-
}));
|
|
16
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
-
}) : function(o, v) {
|
|
19
|
-
o["default"] = v;
|
|
20
|
-
});
|
|
21
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
-
var ownKeys = function(o) {
|
|
23
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
-
var ar = [];
|
|
25
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
-
return ar;
|
|
27
|
-
};
|
|
28
|
-
return ownKeys(o);
|
|
29
|
-
};
|
|
30
|
-
return function (mod) {
|
|
31
|
-
if (mod && mod.__esModule) return mod;
|
|
32
|
-
var result = {};
|
|
33
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
-
__setModuleDefault(result, mod);
|
|
35
|
-
return result;
|
|
36
|
-
};
|
|
37
|
-
})();
|
|
38
6
|
const { getPlaylistConfig } = require('../config');
|
|
39
7
|
const { isDp1PlaylistSigningRole } = require('./playlist-signing-role');
|
|
40
8
|
const { parsePlaylistPrivateKeyToKeyObject } = require('./ed25519-key-derive');
|
|
@@ -130,12 +98,16 @@ async function verifyPlaylist(playlist, publicKeyHex) {
|
|
|
130
98
|
* @param {string} playlistPath - Path to playlist JSON file
|
|
131
99
|
* @param {string} [privateKeyBase64] - Ed25519 private key in hex or base64 format (optional, uses config if not provided)
|
|
132
100
|
* @param {string} [outputPath] - Output path (optional, overwrites input if not provided)
|
|
101
|
+
* @param {string} [roleOverride] - DP-1 signing role override
|
|
102
|
+
* @param {Object} [options] - Signing options
|
|
103
|
+
* @param {boolean} [options.replaceSignatures=false] - Drop every existing signature and sign fresh
|
|
133
104
|
* @returns {Promise<Object>} Result with signed playlist
|
|
134
105
|
* @returns {boolean} returns.success - Whether signing succeeded
|
|
135
106
|
* @returns {Object} [returns.playlist] - Signed playlist object
|
|
107
|
+
* @returns {number} [returns.droppedSignatures] - How many stale entries were discarded
|
|
136
108
|
* @returns {string} [returns.error] - Error message if failed
|
|
137
109
|
*/
|
|
138
|
-
async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride) {
|
|
110
|
+
async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride, options) {
|
|
139
111
|
const fs = require('fs');
|
|
140
112
|
const path = require('path');
|
|
141
113
|
try {
|
|
@@ -156,7 +128,12 @@ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, role
|
|
|
156
128
|
if (!privateKey) {
|
|
157
129
|
throw new Error('Private key is required for signing');
|
|
158
130
|
}
|
|
159
|
-
const
|
|
131
|
+
const replaceSignatures = Boolean(options && options.replaceSignatures);
|
|
132
|
+
const droppedSignatures = replaceSignatures
|
|
133
|
+
? (Array.isArray(playlist.signatures) ? playlist.signatures.filter(Boolean).length : 0) +
|
|
134
|
+
(typeof playlist.signature === 'string' && playlist.signature.trim() ? 1 : 0)
|
|
135
|
+
: 0;
|
|
136
|
+
const signedPlaylist = await buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role, replaceSignatures);
|
|
160
137
|
const verification = await verifySignedPlaylistEnvelope(signedPlaylist, dp1);
|
|
161
138
|
if (!verification.valid) {
|
|
162
139
|
throw new Error(`Signed playlist verification failed: ${verification.error}`);
|
|
@@ -168,6 +145,7 @@ async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, role
|
|
|
168
145
|
return {
|
|
169
146
|
success: true,
|
|
170
147
|
playlist: signedPlaylist,
|
|
148
|
+
droppedSignatures,
|
|
171
149
|
};
|
|
172
150
|
}
|
|
173
151
|
catch (error) {
|
|
@@ -181,6 +159,10 @@ module.exports = {
|
|
|
181
159
|
signPlaylist,
|
|
182
160
|
verifyPlaylist,
|
|
183
161
|
signPlaylistFile,
|
|
162
|
+
// Exported for the feed-mutation intents: a delete/replace intent is signed with the same key
|
|
163
|
+
// material as a playlist, so it must accept the same encodings and raise the same guidance when the
|
|
164
|
+
// key is malformed. Duplicating the normalizer there would let the two paths drift.
|
|
165
|
+
normalizeSigningKeyToBase64Pkcs8,
|
|
184
166
|
};
|
|
185
167
|
function resolvePlaylistSigningRole(role) {
|
|
186
168
|
const candidate = typeof role === 'string' ? role.trim() : '';
|
|
@@ -208,13 +190,25 @@ async function validatePlaylistForSigning(playlist) {
|
|
|
208
190
|
* as dp1-js/dp1-go §7.1); prior `signatures[]` entries are kept on the returned
|
|
209
191
|
* object so repeated `sign` runs accumulate endorsements instead of replacing them.
|
|
210
192
|
*
|
|
193
|
+
* `replaceSignatures` discards them instead, and exists because appending is only
|
|
194
|
+
* correct while the signed content is unchanged. Editing a signed document moves
|
|
195
|
+
* bytes the earlier entries cover, so they become unverifiable — and this function's
|
|
196
|
+
* caller verifies the whole envelope before writing, which means an edited document
|
|
197
|
+
* cannot be re-signed at all without dropping them. That is the ordinary path for a
|
|
198
|
+
* feed replace: fetch the published playlist, change a field, sign fresh, PUT.
|
|
199
|
+
*
|
|
200
|
+
* The feed's own `feed` entry is dropped by the same rule rather than as a special
|
|
201
|
+
* case: it covers the pre-edit content too, and the feed appends a new one after it
|
|
202
|
+
* verifies the replacement, so carrying the old one forward can only fail.
|
|
203
|
+
*
|
|
211
204
|
* @param {Object} playlist - Parsed playlist (may already include `signatures[]`)
|
|
212
205
|
* @param {string} privateKey - Private key material forwarded to dp1-js
|
|
213
206
|
* @param {Object} dp1 - Loaded dp1-js module
|
|
214
207
|
* @param {string} role - DP-1 signing role
|
|
208
|
+
* @param {boolean} [replaceSignatures=false] - Discard existing signatures instead of appending
|
|
215
209
|
* @returns {Promise<Object>} Playlist with legacy `signature` cleared and merged `signatures[]`
|
|
216
210
|
*/
|
|
217
|
-
async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
|
|
211
|
+
async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role, replaceSignatures) {
|
|
218
212
|
const playlistToSign = { ...playlist };
|
|
219
213
|
delete playlistToSign.signature;
|
|
220
214
|
delete playlistToSign.signatures;
|
|
@@ -222,7 +216,7 @@ async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
|
|
|
222
216
|
// seed, PEM) works and a malformed key surfaces a clear error instead of
|
|
223
217
|
// dp1-js's cryptic OpenSSL ASN.1 failure ("header too long" / "wrong tag").
|
|
224
218
|
const normalizedKey = normalizeSigningKeyToBase64Pkcs8(privateKey);
|
|
225
|
-
const existingSignatures = Array.isArray(playlist.signatures)
|
|
219
|
+
const existingSignatures = !replaceSignatures && Array.isArray(playlist.signatures)
|
|
226
220
|
? playlist.signatures.filter((entry) => Boolean(entry))
|
|
227
221
|
: [];
|
|
228
222
|
if (typeof dp1.SignMultiEd25519 === 'function') {
|
|
@@ -261,7 +255,7 @@ async function verifySignedPlaylistEnvelope(signedPlaylist, dp1) {
|
|
|
261
255
|
* instead of leaving a runtime require that can't resolve.
|
|
262
256
|
*/
|
|
263
257
|
async function loadDp1() {
|
|
264
|
-
return
|
|
258
|
+
return import('dp1-js');
|
|
265
259
|
}
|
|
266
260
|
function currentTimestamp() {
|
|
267
261
|
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Deletes a playlist from a DP-1 feed.
|
|
4
|
+
*
|
|
5
|
+
* `curl -X DELETE` alone answers `401`: the feed has no API key, and a delete is authorized by a signed
|
|
6
|
+
* **delete-intent** in the request body (dp1-feed-v2 `docs/api_design.md`, "DELETE — owner-bound, signed
|
|
7
|
+
* delete-intent"). The intent must name the stored `id` and `slug` exactly, carry a `created` inside the
|
|
8
|
+
* server's freshness window, and be signed by a key the stored playlist names as an owner.
|
|
9
|
+
*
|
|
10
|
+
* The delete is permanent in a stronger sense than most: it tombstones the id, and a later create naming
|
|
11
|
+
* that id is refused. There is no undo, which is why the command layer confirms before calling this.
|
|
12
|
+
*/
|
|
13
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
14
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.storedOwnerKeys = void 0;
|
|
18
|
+
exports.fetchPlaylistForUnpublish = fetchPlaylistForUnpublish;
|
|
19
|
+
exports.unpublishPlaylist = unpublishPlaylist;
|
|
20
|
+
const axios_1 = __importDefault(require("axios"));
|
|
21
|
+
const feed_mutation_1 = require("./feed-mutation");
|
|
22
|
+
Object.defineProperty(exports, "storedOwnerKeys", { enumerable: true, get: function () { return feed_mutation_1.storedOwnerKeys; } });
|
|
23
|
+
const signing_identity_1 = require("./signing-identity");
|
|
24
|
+
const config_1 = require("../config");
|
|
25
|
+
/**
|
|
26
|
+
* Look up a playlist on a feed without deleting it.
|
|
27
|
+
*
|
|
28
|
+
* Exposed so the command layer can show the operator what they are about to destroy — and so the
|
|
29
|
+
* ownership refusal happens before any confirmation prompt, rather than after they have already said yes.
|
|
30
|
+
*/
|
|
31
|
+
async function fetchPlaylistForUnpublish(idOrUrl, feedServerUrl) {
|
|
32
|
+
return (0, feed_mutation_1.fetchStoredPlaylist)(feedServerUrl, (0, feed_mutation_1.resolvePlaylistIdentifier)(idOrUrl));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Delete a playlist from a feed server.
|
|
36
|
+
*
|
|
37
|
+
* Flow:
|
|
38
|
+
* 1. Resolve the id or URL the user supplied to a feed path segment.
|
|
39
|
+
* 2. `GET` the stored playlist — the intent must carry the stored id and slug, and only the stored
|
|
40
|
+
* document says who owns it.
|
|
41
|
+
* 3. Refuse locally when the configured key is not a stored owner.
|
|
42
|
+
* 4. Build and sign the delete-intent in the `curator` role.
|
|
43
|
+
* 5. `DELETE` with the intent as the body and report the outcome.
|
|
44
|
+
*
|
|
45
|
+
* @param idOrUrl - Playlist id, slug, or a feed URL ending in either
|
|
46
|
+
* @param feedServerUrl - Feed server base URL, including `/api/v1`
|
|
47
|
+
* @param options - Optional signing-key override
|
|
48
|
+
* @returns Result carrying the deleted playlist's identity, or a diagnosis and a remedy
|
|
49
|
+
* @example
|
|
50
|
+
* const result = await unpublishPlaylist('9f...c1', 'https://feed.example.com/api/v1');
|
|
51
|
+
* if (!result.success) console.error(result.error);
|
|
52
|
+
*/
|
|
53
|
+
async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
|
|
54
|
+
const identifier = (0, feed_mutation_1.resolvePlaylistIdentifier)(idOrUrl);
|
|
55
|
+
if (!identifier) {
|
|
56
|
+
return { success: false, error: 'No playlist id or URL was given' };
|
|
57
|
+
}
|
|
58
|
+
let privateKey;
|
|
59
|
+
try {
|
|
60
|
+
privateKey = resolveSigningKey(options.privateKey);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return { success: false, error: error.message };
|
|
64
|
+
}
|
|
65
|
+
let signerDidKey;
|
|
66
|
+
try {
|
|
67
|
+
signerDidKey = (0, signing_identity_1.playlistSigningDidKey)(privateKey);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return { success: false, error: error.message };
|
|
71
|
+
}
|
|
72
|
+
let stored;
|
|
73
|
+
try {
|
|
74
|
+
stored = await (0, feed_mutation_1.fetchStoredPlaylist)(feedServerUrl, identifier);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete'),
|
|
80
|
+
feedServer: feedServerUrl,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const id = typeof stored.id === 'string' ? stored.id : '';
|
|
84
|
+
const slug = typeof stored.slug === 'string' ? stored.slug : '';
|
|
85
|
+
if (!id || !slug) {
|
|
86
|
+
// Both are stored verbatim from the signed document, so a feed serving one without the other is
|
|
87
|
+
// serving something this envelope cannot target. Failing here beats sending a body the feed will
|
|
88
|
+
// reject as a target mismatch, which reads like the operator's mistake.
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
error: 'The stored playlist is missing an id or slug, so a delete intent cannot target it.',
|
|
92
|
+
feedServer: feedServerUrl,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const refusal = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'delete');
|
|
96
|
+
if (refusal) {
|
|
97
|
+
return { success: false, ...refusal, feedServer: feedServerUrl };
|
|
98
|
+
}
|
|
99
|
+
const created = (0, feed_mutation_1.intentTimestamp)();
|
|
100
|
+
const intent = (0, feed_mutation_1.buildDeleteIntent)(id, slug, created);
|
|
101
|
+
let body;
|
|
102
|
+
try {
|
|
103
|
+
const signature = await (0, feed_mutation_1.signIntent)(intent, privateKey);
|
|
104
|
+
body = { ...intent, signatures: [signature] };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return {
|
|
108
|
+
success: false,
|
|
109
|
+
error: `Failed to sign the delete intent: ${error.message}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
// axios sends a body on DELETE only when `data` is given explicitly; the feed requires one.
|
|
114
|
+
const response = await axios_1.default.delete(`${feedServerUrl}/playlists/${encodeURIComponent(id)}`, {
|
|
115
|
+
data: body,
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
timeout: 30000,
|
|
118
|
+
});
|
|
119
|
+
if (response.status !== 204 && response.status !== 200) {
|
|
120
|
+
return {
|
|
121
|
+
success: false,
|
|
122
|
+
error: `Unexpected response status: ${response.status}`,
|
|
123
|
+
feedServer: feedServerUrl,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
success: true,
|
|
128
|
+
playlistId: id,
|
|
129
|
+
slug,
|
|
130
|
+
title: typeof stored.title === 'string' ? stored.title : undefined,
|
|
131
|
+
message: 'Deleted from feed server (the id is now tombstoned and cannot be reused)',
|
|
132
|
+
feedServer: feedServerUrl,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return {
|
|
137
|
+
success: false,
|
|
138
|
+
...(0, feed_mutation_1.describeFeedMutationError)(error, 'delete'),
|
|
139
|
+
feedServer: feedServerUrl,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Resolve the signing key the same way every other signing path does.
|
|
145
|
+
*
|
|
146
|
+
* `getPlaylistConfig` already layers config.json over `PLAYLIST_PRIVATE_KEY` and screens out the sample
|
|
147
|
+
* placeholder, so going through it keeps `unpublish` signing as the identity `ff-cli status` reports.
|
|
148
|
+
*/
|
|
149
|
+
function resolveSigningKey(override) {
|
|
150
|
+
if (override && override.trim().length > 0) {
|
|
151
|
+
return override;
|
|
152
|
+
}
|
|
153
|
+
const configured = (0, config_1.getPlaylistConfig)().privateKey;
|
|
154
|
+
if (!configured) {
|
|
155
|
+
throw new Error('No playlist signing key is configured. A delete is authorized by a signature, so one is ' +
|
|
156
|
+
'required: run "ff-cli setup" or set playlist.privateKey in config.json.');
|
|
157
|
+
}
|
|
158
|
+
return configured;
|
|
159
|
+
}
|
|
@@ -4,39 +4,6 @@
|
|
|
4
4
|
* Delegates signature-shape handling to dp1-js and keeps the CLI as a thin
|
|
5
5
|
* orchestration layer.
|
|
6
6
|
*/
|
|
7
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
-
if (k2 === undefined) k2 = k;
|
|
9
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
-
}
|
|
13
|
-
Object.defineProperty(o, k2, desc);
|
|
14
|
-
}) : (function(o, m, k, k2) {
|
|
15
|
-
if (k2 === undefined) k2 = k;
|
|
16
|
-
o[k2] = m[k];
|
|
17
|
-
}));
|
|
18
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
-
}) : function(o, v) {
|
|
21
|
-
o["default"] = v;
|
|
22
|
-
});
|
|
23
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
-
var ownKeys = function(o) {
|
|
25
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
-
var ar = [];
|
|
27
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
-
return ar;
|
|
29
|
-
};
|
|
30
|
-
return ownKeys(o);
|
|
31
|
-
};
|
|
32
|
-
return function (mod) {
|
|
33
|
-
if (mod && mod.__esModule) return mod;
|
|
34
|
-
var result = {};
|
|
35
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
-
__setModuleDefault(result, mod);
|
|
37
|
-
return result;
|
|
38
|
-
};
|
|
39
|
-
})();
|
|
40
7
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
41
8
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
42
9
|
};
|
|
@@ -84,10 +51,10 @@ async function verifyPlaylist(playlist, publicKey) {
|
|
|
84
51
|
let key = publicKey?.trim() || undefined;
|
|
85
52
|
if (!key) {
|
|
86
53
|
try {
|
|
87
|
-
const { getPlaylistConfig } = await
|
|
54
|
+
const { getPlaylistConfig } = await import('../config.js');
|
|
88
55
|
const privateKeyMaterial = getPlaylistConfig().privateKey;
|
|
89
56
|
if (privateKeyMaterial) {
|
|
90
|
-
const { deriveEd25519PublicKeyForVerify } = await
|
|
57
|
+
const { deriveEd25519PublicKeyForVerify } = await import('./ed25519-key-derive.js');
|
|
91
58
|
key = deriveEd25519PublicKeyForVerify(privateKeyMaterial);
|
|
92
59
|
}
|
|
93
60
|
}
|
|
@@ -98,7 +65,7 @@ async function verifyPlaylist(playlist, publicKey) {
|
|
|
98
65
|
}
|
|
99
66
|
if (key) {
|
|
100
67
|
try {
|
|
101
|
-
const { normalizeVerifyPublicKeyToPem } = await
|
|
68
|
+
const { normalizeVerifyPublicKeyToPem } = await import('./ed25519-key-derive.js');
|
|
102
69
|
key = normalizeVerifyPublicKeyToPem(key);
|
|
103
70
|
}
|
|
104
71
|
catch (err) {
|
|
@@ -162,7 +129,7 @@ async function parseDp1Playlist(playlist) {
|
|
|
162
129
|
* stays lazy, so dp1-js's transitive deps don't load on unrelated commands.
|
|
163
130
|
*/
|
|
164
131
|
async function loadDp1() {
|
|
165
|
-
return (await
|
|
132
|
+
return (await import('dp1-js'));
|
|
166
133
|
}
|
|
167
134
|
/**
|
|
168
135
|
* Verify playlist file
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -63,7 +63,7 @@ Used for signing DP‑1 playlists.
|
|
|
63
63
|
`PLAYLIST_PRIVATE_KEY` even when no config file exists yet, and `ff-cli status --key <privateKey>`
|
|
64
64
|
reports it for an explicit key — the one `sign --key` would use.
|
|
65
65
|
|
|
66
|
-
- `playlist.role` (string): DP-1 signing role used by `ff-cli sign`.
|
|
66
|
+
- `playlist.role` (string): DP-1 signing role used by `ff-cli sign`. The shipped `config.json.example` sets `curator`, because a document signed as `agent` is ownerless to a role-aware feed and `ff-cli publish` refuses it. The code default when the key is omitted entirely is still `agent`. You can also set this via `PLAYLIST_ROLE` in `.env`. Guided `ff-cli setup`, `config validate`, and `sign --role` only accept the usual DP-1 signing roles (`agent`, `feed`, `curator`, `institution`, `licensor`). It does **not** apply to `find` and `build`, which always sign as `curator` — see [Signing role and ownership](#signing-role-and-ownership).
|
|
67
67
|
|
|
68
68
|
### Generate an Ed25519 private key
|
|
69
69
|
|
|
@@ -133,7 +133,25 @@ DP‑1 Feed API configuration.
|
|
|
133
133
|
Endpoints used by the CLI:
|
|
134
134
|
|
|
135
135
|
- `GET /api/v1/playlists` (supports `limit`, `offset`, and sorting)
|
|
136
|
-
- `GET /api/v1/playlists/{id}`
|
|
136
|
+
- `GET /api/v1/playlists/{id}` (`fetch`)
|
|
137
|
+
- `POST /api/v1/playlists` (`publish`)
|
|
138
|
+
- `PUT /api/v1/playlists/{id}` (`publish --replace`)
|
|
139
|
+
- `DELETE /api/v1/playlists/{id}` (`unpublish`)
|
|
140
|
+
|
|
141
|
+
No credential is configured for any of these. The feed has no API key: every write is authorized by
|
|
142
|
+
signatures in the request body, and an `apiKey` left in an existing config is ignored.
|
|
143
|
+
|
|
144
|
+
`playlist.privateKey` is needed only where the CLI itself has to sign at request time — `publish
|
|
145
|
+
--replace` and `unpublish`, which sign the owner-bound intent that authorizes the write. A plain
|
|
146
|
+
`publish` signs nothing: it uploads the `signatures[]` envelope the document already carries, so it
|
|
147
|
+
works with no key configured at all (the document must have been signed at some point, by
|
|
148
|
+
`ff-cli sign` or by whoever produced it, but not by this command).
|
|
149
|
+
|
|
150
|
+
**Choosing between several configured servers.** Commands that address a feed (`publish`, `unpublish`,
|
|
151
|
+
`fetch`, `find --publish`) take `-s, --server <index>`, a 0-based index into the list above. With one server
|
|
152
|
+
configured it is optional. With more than one and no `-s`, an interactive session asks; a session with no
|
|
153
|
+
terminal — a pipe, a cron job, a CI step — exits non-zero and prints the list rather than guessing, since
|
|
154
|
+
defaulting to index 0 would silently write to whichever feed happens to be first.
|
|
137
155
|
|
|
138
156
|
Environment variable alternative:
|
|
139
157
|
|
|
@@ -267,7 +285,7 @@ Minimal `config.json` example (selected fields):
|
|
|
267
285
|
"defaultDuration": 10,
|
|
268
286
|
"playlist": {
|
|
269
287
|
"privateKey": "your_ed25519_private_key_hex_or_base64_here",
|
|
270
|
-
"role": "
|
|
288
|
+
"role": "curator"
|
|
271
289
|
},
|
|
272
290
|
"feed": {
|
|
273
291
|
"baseURLs": ["https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"]
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -347,10 +347,34 @@ npm run dev -- publish playlist.json
|
|
|
347
347
|
# Direct: publish to specific server (server index 0)
|
|
348
348
|
npm run dev -- publish playlist.json -s 0
|
|
349
349
|
|
|
350
|
+
# Replace a playlist already stored under this document id
|
|
351
|
+
npm run dev -- publish playlist.json --replace -s 0
|
|
352
|
+
|
|
350
353
|
# Show help
|
|
351
354
|
npm run dev -- publish --help
|
|
352
355
|
```
|
|
353
356
|
|
|
357
|
+
A bare Enter at the interactive prompt selects nothing either, and fails with the list. The prompt has no
|
|
358
|
+
default by design: it is being asked precisely because the CLI cannot tell which feed was meant, and
|
|
359
|
+
`Number('')` is `0` — the first server, usually production.
|
|
360
|
+
|
|
361
|
+
**With more than one server configured and no `-s`, a non-interactive session fails rather than
|
|
362
|
+
choosing.** Under a pipe, a cron job, or a CI step there is no terminal to answer the prompt, and
|
|
363
|
+
defaulting to the first server would write to production for a script that meant the other one:
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
$ ff-cli publish playlist.json < /dev/null; echo "exit=$?"
|
|
367
|
+
|
|
368
|
+
Publish playlist
|
|
369
|
+
|
|
370
|
+
Multiple feed servers configured (2); pass --server <index>
|
|
371
|
+
0: https://feed.feralfile.com/api/v1
|
|
372
|
+
1: http://localhost:8787
|
|
373
|
+
exit=1
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The same rule applies to `unpublish`. With a single server configured, `-s` stays optional everywhere.
|
|
377
|
+
|
|
354
378
|
### Flow
|
|
355
379
|
|
|
356
380
|
1. **Verify** - Playlist structure and signatures checked; unsigned or broken playlists are rejected
|
|
@@ -519,6 +543,181 @@ only way to add one is to replace the document. Publishing is what makes that pe
|
|
|
519
543
|
`find` and `build` sign as `curator` automatically, since they declare the key themselves. This applies to
|
|
520
544
|
documents you sign by hand, where `playlist.role` (default `agent`) decides the role.
|
|
521
545
|
|
|
546
|
+
## Replace or Delete a Published Playlist
|
|
547
|
+
|
|
548
|
+
A feed's `PUT` and `DELETE` are **owner-bound**, and neither accepts an API key. Both carry a short-lived
|
|
549
|
+
signed **intent** — `ff-cli` builds it, signs it with the configured key in the `curator` role, and sends
|
|
550
|
+
it alongside (replace) or as (delete) the request body. Only a key the **stored** playlist names in
|
|
551
|
+
`curators[]` can authorize either. This is the whole reason `publish` refuses a document with no
|
|
552
|
+
owner-role signature: once such a playlist is created it can never be replaced or deleted.
|
|
553
|
+
|
|
554
|
+
### Replace
|
|
555
|
+
|
|
556
|
+
```bash
|
|
557
|
+
# 1. Start from the PUBLISHED document, not a rebuilt one.
|
|
558
|
+
ff-cli fetch <id> -o playlist.json -s 0
|
|
559
|
+
|
|
560
|
+
# 2. Edit it. `id`, `slug`, `created` and the curators[] owner set must all stay as published.
|
|
561
|
+
|
|
562
|
+
# 3. Re-sign FRESH. This flag is required here — see below.
|
|
563
|
+
ff-cli sign playlist.json -r curator --replace-signatures
|
|
564
|
+
|
|
565
|
+
# 4. Replace.
|
|
566
|
+
ff-cli publish playlist.json --replace -s 0
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
**Step 3 needs `--replace-signatures`.** The document you fetched carries the curator's signature and
|
|
570
|
+
the feed's own, both taken over the content as published. Editing it moves bytes those signatures cover,
|
|
571
|
+
and `sign` **appends** rather than replaces — so a plain re-sign produces an envelope holding two stale
|
|
572
|
+
entries and one good one, which `sign` then refuses to write:
|
|
573
|
+
|
|
574
|
+
```
|
|
575
|
+
$ ff-cli sign playlist.json -r curator
|
|
576
|
+
|
|
577
|
+
Sign playlist
|
|
578
|
+
|
|
579
|
+
Sign failed: Signed playlist verification failed: signed playlist is not verifiable
|
|
580
|
+
|
|
581
|
+
If you edited this playlist after it was signed, the existing signatures no longer
|
|
582
|
+
cover it, and signing again cannot repair them — signing appends. Sign fresh instead:
|
|
583
|
+
ff-cli sign playlist.json -r curator --replace-signatures
|
|
584
|
+
That discards every existing entry, including any feed signature, and signs the
|
|
585
|
+
document as it stands now. It is the path a feed replace expects.
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Signing fresh drops the feed's entry along with the curator's, which is correct: it covers the pre-edit
|
|
589
|
+
content too, and the feed appends a new one of its own after it verifies the replacement.
|
|
590
|
+
|
|
591
|
+
```
|
|
592
|
+
$ ff-cli sign playlist.json -r curator --replace-signatures
|
|
593
|
+
|
|
594
|
+
Sign playlist
|
|
595
|
+
|
|
596
|
+
✓ Playlist signed and saved to: /path/to/playlist.json
|
|
597
|
+
|
|
598
|
+
Playlist signed
|
|
599
|
+
Replaced 2 existing signatures
|
|
600
|
+
Signatures: 1
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
Appending stays the default, because it is right whenever the content has not changed — a second curator
|
|
604
|
+
co-signing an unedited playlist keeps the first endorsement, and the payload hash excludes `signatures`
|
|
605
|
+
so the earlier entry stays valid.
|
|
606
|
+
|
|
607
|
+
```
|
|
608
|
+
$ ff-cli publish playlist.json --replace -s 0
|
|
609
|
+
|
|
610
|
+
Replace playlist
|
|
611
|
+
|
|
612
|
+
Replaced
|
|
613
|
+
Playlist ID: 97595a2f-a790-477c-aa42-b4f2ec9f1e3b
|
|
614
|
+
Server: https://feed.feralfile.com/api/v1
|
|
615
|
+
Status: Replaced on feed server
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
`--replace` is never applied on your behalf. A plain `publish` of an id the feed already holds still
|
|
619
|
+
fails with a conflict, because creating and overwriting are different intentions and only one of them is
|
|
620
|
+
recoverable.
|
|
621
|
+
|
|
622
|
+
Re-running `find` or `build` does **not** produce a replacement: each run mints a new `id`, `slug`, and
|
|
623
|
+
`created`, and the feed compares all three against the stored row. `ff-cli` checks them locally and names
|
|
624
|
+
the fields that moved, because the feed's own answer is a bare `400`:
|
|
625
|
+
|
|
626
|
+
```
|
|
627
|
+
Replace failed
|
|
628
|
+
The document changes fields a replace may not change.
|
|
629
|
+
|
|
630
|
+
A replace keeps identity and ownership fixed: id, slug, and created must equal the stored
|
|
631
|
+
document's, and the curators[] owner set is immutable.
|
|
632
|
+
slug: stored "snowfro-send-receive", document "a-completely-new-slug"
|
|
633
|
+
created: stored "2026-09-07T23:32:39.660Z", document "2026-09-08T01:02:03.000Z"
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
### Delete
|
|
637
|
+
|
|
638
|
+
```bash
|
|
639
|
+
# id, slug, or a feed URL — all resolve to the same playlist.
|
|
640
|
+
ff-cli unpublish 97595a2f-a790-477c-aa42-b4f2ec9f1e3b -s 0
|
|
641
|
+
ff-cli unpublish https://feed.feralfile.com/api/v1/playlists/97595a2f-a790-477c-aa42-b4f2ec9f1e3b -s 0
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
```
|
|
645
|
+
$ ff-cli unpublish 97595a2f-a790-477c-aa42-b4f2ec9f1e3b -s 0 -y
|
|
646
|
+
|
|
647
|
+
Unpublish playlist
|
|
648
|
+
|
|
649
|
+
Unpublished
|
|
650
|
+
Playlist ID: 97595a2f-a790-477c-aa42-b4f2ec9f1e3b
|
|
651
|
+
Slug: snowfro-send-receive
|
|
652
|
+
Server: https://feed.feralfile.com/api/v1
|
|
653
|
+
Status: Deleted from feed server (the id is now tombstoned and cannot be reused)
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Without `-y`, `unpublish` shows the title and the server and asks, defaulting to **no**. The delete
|
|
657
|
+
tombstones the id: the playlist cannot be restored, and a later publish naming that id is refused. Build
|
|
658
|
+
a new playlist instead of trying to recreate it.
|
|
659
|
+
|
|
660
|
+
### When ownership cannot be proved
|
|
661
|
+
|
|
662
|
+
A key counts as an owner only when the **stored** playlist names it in `curators[]` **and** carries its
|
|
663
|
+
valid `curator`-role signature. Being named is a claim; signing in the owner role is the proof, and the
|
|
664
|
+
CLI checks both against the stored document before it signs an intent.
|
|
665
|
+
|
|
666
|
+
That distinction has one large real population: playlists published while the default signing role was
|
|
667
|
+
`agent`. They declare a curator and carry only that key's `agent` signature, so nothing can ever
|
|
668
|
+
authorize a write to them — not even the holder of the declared key, because adding the missing proof
|
|
669
|
+
would itself be a replace:
|
|
670
|
+
|
|
671
|
+
```
|
|
672
|
+
Unpublish failed
|
|
673
|
+
No key has proved ownership of this playlist, so none can delete it.
|
|
674
|
+
|
|
675
|
+
A key counts as an owner only when it is named in curators[] AND signed the document as
|
|
676
|
+
"curator". This playlist names a curator and carries no valid curator signature
|
|
677
|
+
from that key — the shape of a playlist published while the default signing role was
|
|
678
|
+
"agent".
|
|
679
|
+
Declared:
|
|
680
|
+
did:key:z6Mkv7qJ...
|
|
681
|
+
Nothing can repair it, including holding one of those keys: adding the missing signature
|
|
682
|
+
would be a replace, and a replace needs the very proof that is missing. Publish a corrected
|
|
683
|
+
playlist under a new id instead.
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
This is why `publish` refuses to create such a document in the first place: the refusal at publish time
|
|
687
|
+
is recoverable, and this one is not.
|
|
688
|
+
|
|
689
|
+
### When the key is not an owner
|
|
690
|
+
|
|
691
|
+
`ff-cli` reads the stored playlist before it signs anything, so this is refused locally and nothing is
|
|
692
|
+
sent. The feed's own answer would be a bare `403`, naming neither the identity you offered nor the ones
|
|
693
|
+
that would have worked:
|
|
694
|
+
|
|
695
|
+
```
|
|
696
|
+
$ ff-cli unpublish 885b2ea6-74e2-44fe-96d0-e8728f6bba9c -s 0 -y
|
|
697
|
+
|
|
698
|
+
Unpublish playlist
|
|
699
|
+
|
|
700
|
+
Unpublish failed
|
|
701
|
+
The configured signing key is not an owner of this playlist, so it cannot delete it.
|
|
702
|
+
|
|
703
|
+
Only a key the stored playlist names in curators[] can authorize a delete; the feed derives
|
|
704
|
+
ownership from the stored document, not from a local copy.
|
|
705
|
+
Your configured identity:
|
|
706
|
+
did:key:z6MkoX8i2dynyvLh4hUHZt8b42q9uAwwCWxM4NSX4YDfMtaC
|
|
707
|
+
Stored owners:
|
|
708
|
+
did:key:z6MkoDkq5YXsFGXPiD6HDUVfze5mvhU5QF4hTy59pVVPYg82
|
|
709
|
+
Point playlist.privateKey at a key listed above (confirm any key's identity with
|
|
710
|
+
"ff-cli status --key <private key>"). Ownership cannot be granted after the fact: the owner set
|
|
711
|
+
is immutable, so a playlist signed by the wrong key stays that way.
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
Ownership cannot be granted after the fact — the owner set is immutable, and only an owner could change
|
|
715
|
+
it — so the only fix is to hold a declared key. A playlist whose stored `curators[]` is **empty** is a
|
|
716
|
+
harder case with the same shape: nobody owns it, no signature can ever authorize a write, and `ff-cli`
|
|
717
|
+
says so rather than sending you after a key that does not exist. That is the state every playlist
|
|
718
|
+
published without an owner-role signature is frozen in, and it is what the `publish` owner-role gate
|
|
719
|
+
above exists to prevent.
|
|
720
|
+
|
|
522
721
|
## Complete Flow (build → validate → sign → play → publish)
|
|
523
722
|
|
|
524
723
|
```bash
|