@feralfile/cli 2.4.0 → 2.5.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 +4 -5
- package/dist/package.json +1 -1
- package/dist/src/commands/find.js +1 -2
- package/dist/src/commands/play.js +5 -2
- package/dist/src/commands/publish.js +1 -8
- package/dist/src/commands/status.js +51 -1
- package/dist/src/config.js +45 -5
- package/dist/src/utilities/index.js +1 -1
- package/dist/src/utilities/playlist-builder.js +12 -0
- package/dist/src/utilities/playlist-publisher.js +61 -13
- package/dist/src/utilities/signing-identity.js +34 -0
- package/docs/CONFIGURATION.md +13 -0
- package/docs/EXAMPLES.md +68 -8
- package/docs/README.md +14 -5
- package/package.json +1 -1
package/config.json.example
CHANGED
|
@@ -7,17 +7,16 @@
|
|
|
7
7
|
},
|
|
8
8
|
"feedServers": [
|
|
9
9
|
{
|
|
10
|
-
"baseUrl": "https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"
|
|
11
|
-
"apiKey": "YOUR_FEED_SERVER_API_KEY_OPTIONAL"
|
|
10
|
+
"baseUrl": "https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"
|
|
12
11
|
},
|
|
13
12
|
{
|
|
14
|
-
"baseUrl": "http://localhost:8787"
|
|
15
|
-
"apiKey": "YOUR_FEED_SERVER_API_KEY_OPTIONAL"
|
|
13
|
+
"baseUrl": "http://localhost:8787"
|
|
16
14
|
}
|
|
17
15
|
],
|
|
18
16
|
"playlist": {
|
|
19
17
|
"privateKey": "YOUR_ED25519_PRIVATE_KEY__base64_PKCS8_DER_recommended__or_32byte_raw_seed_as_hex_or_base64__run_ff-cli_setup_to_generate",
|
|
20
|
-
"role": "agent"
|
|
18
|
+
"role": "agent",
|
|
19
|
+
"curatorName": "YOUR_CURATOR_NAME"
|
|
21
20
|
},
|
|
22
21
|
"ff1Relayer": {
|
|
23
22
|
"baseUrl": "https://tv-cast-coordination.autonomy-system.workers.dev"
|
package/dist/package.json
CHANGED
|
@@ -641,8 +641,7 @@ async function doPublish(savedPath, serverArg, nonInteractive) {
|
|
|
641
641
|
serverIndex = n;
|
|
642
642
|
}
|
|
643
643
|
const serverUrl = feedConfig.baseURLs[serverIndex];
|
|
644
|
-
const
|
|
645
|
-
const result = await publishPlaylist(savedPath, serverUrl, serverApiKey);
|
|
644
|
+
const result = await publishPlaylist(savedPath, serverUrl);
|
|
646
645
|
if (result.success) {
|
|
647
646
|
console.log(chalk_1.default.green('✓ Published'));
|
|
648
647
|
if (result.playlistId) {
|
|
@@ -128,7 +128,6 @@ exports.playCommand = new commander_1.Command('play')
|
|
|
128
128
|
console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --play${deviceFlag}`));
|
|
129
129
|
process.exit(1);
|
|
130
130
|
}
|
|
131
|
-
const config = (0, config_1.getConfig)();
|
|
132
131
|
// No explicit duration: a direct video/audio URL plays its natural
|
|
133
132
|
// length (DP-1 §4.1); static media uses config.defaultDuration.
|
|
134
133
|
const resolved = probed ?? (await (0, playlist_source_1.resolvePlaySource)(source));
|
|
@@ -143,7 +142,11 @@ exports.playCommand = new commander_1.Command('play')
|
|
|
143
142
|
// A direct media/web URL is wrapped in an unsigned single-item playlist,
|
|
144
143
|
// so it must be signed before the verify gate; a loaded playlist is sent
|
|
145
144
|
// as-is. castPlaylist owns the sign → verify → send sequence.
|
|
146
|
-
|
|
145
|
+
// Resolve through getPlaylistConfig rather than reading config.playlist directly: it is the one
|
|
146
|
+
// place that knows a config value may still be the config.json.example placeholder, in which case a
|
|
147
|
+
// valid PLAYLIST_PRIVATE_KEY must win. Duplicating the `config || env` precedence here is what made
|
|
148
|
+
// this path sign with the template literal while every other signing path used the real key.
|
|
149
|
+
const privateKey = (0, config_1.getPlaylistConfig)().privateKey || undefined;
|
|
147
150
|
const result = await (0, playlist_cast_1.castPlaylist)(resolved.playlist, {
|
|
148
151
|
deviceName: options.device,
|
|
149
152
|
skipVerify: options.skipVerify,
|
|
@@ -56,7 +56,6 @@ exports.publishCommand = new commander_1.Command('publish')
|
|
|
56
56
|
process.exit(1);
|
|
57
57
|
}
|
|
58
58
|
let serverUrl = feedConfig.baseURLs[0];
|
|
59
|
-
let serverApiKey = feedConfig.apiKey;
|
|
60
59
|
if (feedConfig.baseURLs.length > 1) {
|
|
61
60
|
if (!options.server) {
|
|
62
61
|
console.log(chalk_1.default.yellow('Multiple feed servers found. Select one:'));
|
|
@@ -77,14 +76,8 @@ exports.publishCommand = new commander_1.Command('publish')
|
|
|
77
76
|
process.exit(1);
|
|
78
77
|
}
|
|
79
78
|
serverUrl = feedConfig.baseURLs[serverIndex];
|
|
80
|
-
if (feedConfig.servers && feedConfig.servers[serverIndex]) {
|
|
81
|
-
serverApiKey = feedConfig.servers[serverIndex].apiKey;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
else if (feedConfig.servers && feedConfig.servers[0]) {
|
|
85
|
-
serverApiKey = feedConfig.servers[0].apiKey;
|
|
86
79
|
}
|
|
87
|
-
const result = await publishPlaylist(file, serverUrl
|
|
80
|
+
const result = await publishPlaylist(file, serverUrl);
|
|
88
81
|
if (result.success) {
|
|
89
82
|
console.log(chalk_1.default.green('Published'));
|
|
90
83
|
if (result.playlistId) {
|
|
@@ -10,15 +10,47 @@ const config_files_1 = require("./helpers/config-files");
|
|
|
10
10
|
const config_1 = require("../config");
|
|
11
11
|
const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
|
|
12
12
|
const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
|
|
13
|
+
const signing_identity_1 = require("../utilities/signing-identity");
|
|
13
14
|
const config_placeholders_1 = require("../utilities/config-placeholders");
|
|
14
15
|
exports.statusCommand = new commander_1.Command('status')
|
|
15
16
|
.description('Show configuration status')
|
|
16
|
-
.
|
|
17
|
+
// Identity discovery must cover every key `sign` accepts, not just the configured one. `sign --key`
|
|
18
|
+
// signs with a key the config never mentions, so reporting only the configured identity would hand the
|
|
19
|
+
// user the wrong did:key to declare in curators[] — and a wrong declaration fails exactly like a
|
|
20
|
+
// missing one.
|
|
21
|
+
.option('-k, --key <privateKey>', 'Report the signing identity for this key instead of the configured one')
|
|
22
|
+
.action(async (options) => {
|
|
17
23
|
try {
|
|
24
|
+
const overrideKey = options.key?.trim();
|
|
25
|
+
if (overrideKey) {
|
|
26
|
+
try {
|
|
27
|
+
console.log(chalk_1.default.green(`Signing identity (did:key) ${(0, signing_identity_1.playlistSigningDidKey)(overrideKey)}`));
|
|
28
|
+
console.log(chalk_1.default.dim(" declare this in the playlist's curators[] before signing"));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
console.log(chalk_1.default.red(`--key unusable: ${error.message}`));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
18
36
|
const configPath = await (0, config_files_1.resolveExistingConfigPath)();
|
|
19
37
|
if (!configPath) {
|
|
20
38
|
console.log(chalk_1.default.red('config.json not found'));
|
|
21
39
|
console.log(chalk_1.default.dim('Run: ff-cli setup'));
|
|
40
|
+
// PLAYLIST_PRIVATE_KEY is a supported signing source on its own, and the did:key it implies is
|
|
41
|
+
// the one value a user must know before they can write a publishable playlist — it has to be
|
|
42
|
+
// declared in curators[] before signing. Exiting silently here would leave an environment-only
|
|
43
|
+
// user unable to obtain it from anywhere. The exit status still reports "not configured".
|
|
44
|
+
const envKey = process.env.PLAYLIST_PRIVATE_KEY?.trim();
|
|
45
|
+
if (envKey) {
|
|
46
|
+
try {
|
|
47
|
+
console.log(chalk_1.default.green(`\nSigning identity (did:key) ${(0, signing_identity_1.playlistSigningDidKey)(envKey)}`));
|
|
48
|
+
console.log(chalk_1.default.dim(" from PLAYLIST_PRIVATE_KEY; declare it in the playlist's curators[]"));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
console.log(chalk_1.default.red(`\nPLAYLIST_PRIVATE_KEY unusable: ${error.message}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
22
54
|
process.exit(1);
|
|
23
55
|
}
|
|
24
56
|
const config = await (0, config_files_1.readConfigFile)(configPath);
|
|
@@ -38,6 +70,17 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
38
70
|
playlistRoleError = playlistRoleMaterial;
|
|
39
71
|
}
|
|
40
72
|
}
|
|
73
|
+
// The feed matches a signature's kid against the document's own curators[], so this value is not
|
|
74
|
+
// decoration: without it a user cannot write a publishable playlist, and it appears nowhere else.
|
|
75
|
+
let signingDidKey;
|
|
76
|
+
if (hasPlaylistSigningKey) {
|
|
77
|
+
try {
|
|
78
|
+
signingDidKey = (0, signing_identity_1.playlistSigningDidKey)(playlistKeyMaterial);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
signingDidKey = undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
41
84
|
const statuses = [
|
|
42
85
|
{
|
|
43
86
|
label: 'Config file',
|
|
@@ -55,6 +98,13 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
55
98
|
: undefined,
|
|
56
99
|
hint: ' (needed for signing and legacy verification)',
|
|
57
100
|
},
|
|
101
|
+
{
|
|
102
|
+
label: 'Signing identity (did:key)',
|
|
103
|
+
ok: Boolean(signingDidKey),
|
|
104
|
+
optional: false,
|
|
105
|
+
detail: signingDidKey,
|
|
106
|
+
hint: " (declare this in the playlist's curators[] so the feed accepts a publish)",
|
|
107
|
+
},
|
|
58
108
|
{
|
|
59
109
|
label: 'Playlist signing role',
|
|
60
110
|
ok: hasValidPlaylistRole,
|
package/dist/src/config.js
CHANGED
|
@@ -121,10 +121,38 @@ function getPlaylistConfig() {
|
|
|
121
121
|
const config = getConfig();
|
|
122
122
|
const playlistConfig = config.playlist || {};
|
|
123
123
|
return {
|
|
124
|
-
|
|
124
|
+
// `config init` writes the sample file through verbatim, so an untouched config carries the
|
|
125
|
+
// YOUR_ED25519_PRIVATE_KEY... literal. It is truthy, so without this it would beat a perfectly good
|
|
126
|
+
// PLAYLIST_PRIVATE_KEY and every signing path would fail on a key the user never chose.
|
|
127
|
+
privateKey: (0, config_placeholders_1.isMissingConfigValue)(playlistConfig.privateKey)
|
|
128
|
+
? process.env.PLAYLIST_PRIVATE_KEY || null
|
|
129
|
+
: playlistConfig.privateKey || null,
|
|
125
130
|
role: playlistConfig.role || process.env.PLAYLIST_ROLE || null,
|
|
131
|
+
curatorName: resolveCuratorName(playlistConfig.curatorName),
|
|
126
132
|
};
|
|
127
133
|
}
|
|
134
|
+
/** The literal shipped in config.json.example, and the only curator name treated as unset. */
|
|
135
|
+
const CURATOR_NAME_TEMPLATE = 'YOUR_CURATOR_NAME';
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the curator name recorded in a built playlist's signed `curators[]`.
|
|
138
|
+
*
|
|
139
|
+
* Two hazards meet here, and they pull in opposite directions. The sample value must not win over the
|
|
140
|
+
* environment, or a freshly initialized config publishes under a placeholder. But the generic
|
|
141
|
+
* YOUR_/your_ placeholder test is too broad for a human name — a real studio called "YOUR_STUDIO" would
|
|
142
|
+
* be silently replaced, and the wrong attribution would then be sealed inside a signature. So only the
|
|
143
|
+
* exact template literal counts as unset.
|
|
144
|
+
*
|
|
145
|
+
* A non-string value is treated as unset rather than passed through: config.json is arbitrary runtime
|
|
146
|
+
* JSON, and this value is assigned into the document *after* the builder has validated it, so a number
|
|
147
|
+
* here would be signed into a malformed playlist instead of being rejected.
|
|
148
|
+
*/
|
|
149
|
+
function resolveCuratorName(configured) {
|
|
150
|
+
const fromConfig = typeof configured === 'string' ? configured.trim() : '';
|
|
151
|
+
if (fromConfig && fromConfig !== CURATOR_NAME_TEMPLATE) {
|
|
152
|
+
return fromConfig;
|
|
153
|
+
}
|
|
154
|
+
return process.env.PLAYLIST_CURATOR_NAME?.trim() || null;
|
|
155
|
+
}
|
|
128
156
|
/**
|
|
129
157
|
* Resolve the effective playlist signing role used by runtime paths.
|
|
130
158
|
*
|
|
@@ -146,12 +174,15 @@ function resolveEffectivePlaylistRole(config) {
|
|
|
146
174
|
/**
|
|
147
175
|
* Get feed configuration for DP1 feed API
|
|
148
176
|
*
|
|
149
|
-
* Supports both legacy (feed.baseURLs
|
|
177
|
+
* Supports both legacy (feed.baseURLs) and new (feedServers array) formats.
|
|
178
|
+
*
|
|
179
|
+
* No API key is returned. The feed authorizes writes from the signatures inside the document, so a key
|
|
180
|
+
* is neither sent nor accepted; an `apiKey` left in an existing config file is simply ignored rather
|
|
181
|
+
* than being an error, so old configs keep working.
|
|
150
182
|
*
|
|
151
183
|
* @returns {Object} Feed configuration
|
|
152
184
|
* @returns {string[]} returns.baseURLs - Array of base URLs for feed APIs
|
|
153
|
-
* @returns {
|
|
154
|
-
* @returns {Array<Object>} [returns.servers] - Array of feed servers with individual API keys (new)
|
|
185
|
+
* @returns {Array<Object>} [returns.servers] - Array of configured feed servers (new format)
|
|
155
186
|
*/
|
|
156
187
|
function getFeedConfig() {
|
|
157
188
|
const config = getConfig();
|
|
@@ -179,7 +210,6 @@ function getFeedConfig() {
|
|
|
179
210
|
}
|
|
180
211
|
return {
|
|
181
212
|
baseURLs: urls,
|
|
182
|
-
apiKey: feedConfig.apiKey,
|
|
183
213
|
};
|
|
184
214
|
}
|
|
185
215
|
/**
|
|
@@ -281,6 +311,16 @@ function validateConfig() {
|
|
|
281
311
|
}
|
|
282
312
|
}
|
|
283
313
|
}
|
|
314
|
+
// curatorName is signed into curators[] as a public name, and it is written into the document after
|
|
315
|
+
// the builder has validated it — so a malformed value is not caught anywhere downstream. Reporting a
|
|
316
|
+
// clean config while `build` emits an invalid signed playlist is the failure worth preventing here.
|
|
317
|
+
const curatorNameValue = config.playlist
|
|
318
|
+
?.curatorName;
|
|
319
|
+
if (curatorNameValue !== undefined && curatorNameValue !== null) {
|
|
320
|
+
if (typeof curatorNameValue !== 'string' || curatorNameValue.trim().length === 0) {
|
|
321
|
+
errors.push('playlist.curatorName must be a non-empty string');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
284
324
|
if (config.playlist?.role !== undefined || process.env.PLAYLIST_ROLE !== undefined) {
|
|
285
325
|
try {
|
|
286
326
|
resolveEffectivePlaylistRole(config);
|
|
@@ -505,7 +505,7 @@ async function buildPlaylistDirect(params, options = {}) {
|
|
|
505
505
|
console.log(chalk.cyan('\nPublishing to feed server...'));
|
|
506
506
|
try {
|
|
507
507
|
const { publishPlaylist } = require('./playlist-publisher');
|
|
508
|
-
publishResult = await publishPlaylist(savedPath, playlistSettings.feedServer.baseUrl
|
|
508
|
+
publishResult = await publishPlaylist(savedPath, playlistSettings.feedServer.baseUrl);
|
|
509
509
|
if (publishResult.success) {
|
|
510
510
|
console.log(chalk.green(`✓ Published to feed server`));
|
|
511
511
|
if (publishResult.playlistId) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const { PlaylistBuilder, PlaylistItemBuilder, DisplayPrefsBuilder, ProvenanceBuilder, ContractBuilder, ValidatePlaylist, slugify: dp1Slugify, generateId, } = require('dp1-js');
|
|
11
11
|
const { getPlaylistConfig, getConfig } = require('../config');
|
|
12
12
|
const { signPlaylist } = require('./playlist-signer');
|
|
13
|
+
const { playlistSigningDidKey } = require('./signing-identity');
|
|
13
14
|
const { buildInlineManifestForToken } = require('./ref-manifest');
|
|
14
15
|
/** Default display background that passes DP-1 hex color validation. */
|
|
15
16
|
const DEFAULT_BACKGROUND = '#111111';
|
|
@@ -482,9 +483,20 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
|
|
|
482
483
|
}
|
|
483
484
|
const playlist = playlistBuilder.build();
|
|
484
485
|
// Sign the playlist if private key is configured.
|
|
486
|
+
//
|
|
487
|
+
// The curator declaration has to be written BEFORE signing. The feed accepts a create only when a
|
|
488
|
+
// signature's kid matches a key in the document's own curators[], and a signature covers curators[],
|
|
489
|
+
// so declaring it afterwards would invalidate the signature we just produced. A playlist built without
|
|
490
|
+
// this entry is signed but unpublishable, which is how `find --publish` used to fail.
|
|
485
491
|
try {
|
|
486
492
|
const playlistConfig = getPlaylistConfig();
|
|
487
493
|
if (playlistConfig.privateKey) {
|
|
494
|
+
const key = playlistSigningDidKey(playlistConfig.privateKey);
|
|
495
|
+
const declared = Array.isArray(playlist.curators) ? playlist.curators : [];
|
|
496
|
+
// Respect a curator the caller already declared for this key; only add what is missing.
|
|
497
|
+
if (!declared.some((curator) => curator && curator.key === key)) {
|
|
498
|
+
playlist.curators = [...declared, { name: playlistConfig.curatorName || 'ff-cli', key }];
|
|
499
|
+
}
|
|
488
500
|
playlist.signatures = [await signPlaylist(playlist, playlistConfig.privateKey)];
|
|
489
501
|
delete playlist.signature;
|
|
490
502
|
}
|
|
@@ -18,17 +18,16 @@ const playlist_verifier_1 = require("./playlist-verifier");
|
|
|
18
18
|
*
|
|
19
19
|
* @param {string} filePath - Path to playlist JSON file
|
|
20
20
|
* @param {string} feedServerUrl - Feed server base URL
|
|
21
|
-
* @param {string} [apiKey] - Optional API key for authentication
|
|
22
21
|
* @returns {Promise<Object>} Result with success status, playlistId, or error
|
|
23
22
|
* @example
|
|
24
|
-
* const result = await publishPlaylist('playlist.json', 'http://localhost:8787/api/v1'
|
|
23
|
+
* const result = await publishPlaylist('playlist.json', 'http://localhost:8787/api/v1');
|
|
25
24
|
* if (result.success) {
|
|
26
25
|
* console.log(`Published with ID: ${result.playlistId}`);
|
|
27
26
|
* } else {
|
|
28
27
|
* console.error(`Failed: ${result.error}`);
|
|
29
28
|
* }
|
|
30
29
|
*/
|
|
31
|
-
async function publishPlaylist(filePath, feedServerUrl
|
|
30
|
+
async function publishPlaylist(filePath, feedServerUrl) {
|
|
32
31
|
try {
|
|
33
32
|
// Step 1: Read and parse playlist file
|
|
34
33
|
if (!fs_1.default.existsSync(filePath)) {
|
|
@@ -48,7 +47,31 @@ async function publishPlaylist(filePath, feedServerUrl, apiKey) {
|
|
|
48
47
|
error: `Invalid JSON in playlist file: ${filePath}`,
|
|
49
48
|
};
|
|
50
49
|
}
|
|
51
|
-
// Step 2:
|
|
50
|
+
// Step 2: refuse legacy flat-signature documents outright, before verification.
|
|
51
|
+
//
|
|
52
|
+
// A flat `signature` string carries no kid, so the curator rule below has nothing to match and such
|
|
53
|
+
// a document would reach the feed only to be refused as unauthenticated — verified against a running
|
|
54
|
+
// feed. This runs ahead of verification deliberately: the document is unpublishable whether or not
|
|
55
|
+
// its legacy signature is valid, and 're-sign as a v1.1 envelope' is the remedy either way, which
|
|
56
|
+
// 'signature verification failed' would not convey.
|
|
57
|
+
const hasLegacyFlatSignature = typeof playlist.signature === 'string' &&
|
|
58
|
+
playlist.signature.trim().length > 0;
|
|
59
|
+
const hasEnvelope = Array.isArray(playlist.signatures)
|
|
60
|
+
? (playlist.signatures ?? []).length > 0
|
|
61
|
+
: false;
|
|
62
|
+
if (!hasEnvelope && hasLegacyFlatSignature) {
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
error: 'Playlist carries a legacy flat signature, which the feed cannot authorize.',
|
|
66
|
+
message: `The feed authorizes a publish from a signatures[] envelope, matching a signature's kid\n` +
|
|
67
|
+
` against the playlist's own curators[]. A flat "signature" string carries no kid.\n` +
|
|
68
|
+
` Re-sign it as a DP-1 v1.1 envelope:\n` +
|
|
69
|
+
` 1. remove the "signature" field\n` +
|
|
70
|
+
` 2. declare your key: "curators": [{ "name": "Your name", "key": "<did:key from ff-cli status>" }]\n` +
|
|
71
|
+
` 3. ff-cli sign <file>`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// Step 3: Verify signature integrity before publishing.
|
|
52
75
|
const deliveryResult = await (0, playlist_verifier_1.verifyPlaylist)(playlist);
|
|
53
76
|
if (!deliveryResult.valid) {
|
|
54
77
|
return {
|
|
@@ -57,17 +80,42 @@ async function publishPlaylist(filePath, feedServerUrl, apiKey) {
|
|
|
57
80
|
message: deliveryResult.details?.map((d) => ` • ${d.path}: ${d.message}`).join('\n'),
|
|
58
81
|
};
|
|
59
82
|
}
|
|
60
|
-
// Step
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
83
|
+
// Step 4: fail here, not at the feed, when the signer is not declared as a curator.
|
|
84
|
+
//
|
|
85
|
+
// The feed accepts a create only when a signature's `kid` matches a key the document declares in
|
|
86
|
+
// `curators[]`. Signing alone does not satisfy that, and the server's answer — "no valid curator
|
|
87
|
+
// signature found" — reads as a signing problem, which sends people to check their key instead of
|
|
88
|
+
// their document. Checking locally turns a confusing 400 into an instruction, and costs no request.
|
|
89
|
+
const curatorKeys = new Set((Array.isArray(playlist.curators)
|
|
90
|
+
? (playlist.curators ?? [])
|
|
91
|
+
: [])
|
|
92
|
+
.map((c) => (typeof c?.key === 'string' ? c.key.trim() : ''))
|
|
93
|
+
.filter((k) => k.length > 0));
|
|
94
|
+
const signatures = Array.isArray(playlist.signatures)
|
|
95
|
+
? (playlist.signatures ?? [])
|
|
96
|
+
: [];
|
|
97
|
+
const signingKids = signatures
|
|
98
|
+
.map((sig) => (typeof sig?.kid === 'string' ? sig.kid.trim() : ''))
|
|
99
|
+
.filter((k) => k.length > 0);
|
|
100
|
+
if (signingKids.length > 0 && !signingKids.some((kid) => curatorKeys.has(kid))) {
|
|
101
|
+
return {
|
|
102
|
+
success: false,
|
|
103
|
+
error: 'Playlist is signed, but the signing key is not declared as a curator.',
|
|
104
|
+
message: `The feed accepts a publish when a signature's kid appears in the playlist's own curators[].\n` +
|
|
105
|
+
` Add this to the playlist before signing:\n` +
|
|
106
|
+
` "curators": [{ "name": "Your name", "key": "${signingKids[0]}" }]\n` +
|
|
107
|
+
` then sign again from the unsigned file — signing appends, so re-signing an already-signed\n` +
|
|
108
|
+
` playlist leaves the earlier signature covering a document that no longer exists.`,
|
|
109
|
+
};
|
|
68
110
|
}
|
|
111
|
+
// Step 5: Send validated playlist to feed server.
|
|
112
|
+
//
|
|
113
|
+
// No auth header. The feed authorizes a create from the document's own signatures: it requires a
|
|
114
|
+
// signature whose kid matches a key declared in the playlist's `curators[]`. An API key is neither
|
|
115
|
+
// sent nor accepted -- the feed removed that path entirely -- so a playlist that is not self-signed
|
|
116
|
+
// by a declared curator is rejected no matter what credentials accompany it.
|
|
69
117
|
const response = await axios_1.default.post(`${feedServerUrl}/playlists`, playlist, {
|
|
70
|
-
headers,
|
|
118
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
119
|
timeout: 30000,
|
|
72
120
|
});
|
|
73
121
|
const playlistId = response.data?.id || response.data?.uuid;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The `did:key` a signature from the configured playlist key will carry as its `kid`.
|
|
4
|
+
*
|
|
5
|
+
* The feed authorizes a create by matching a signature's `kid` against a key the document declares in
|
|
6
|
+
* `curators[]`. Until now that value existed nowhere a user could see: it appeared for the first time
|
|
7
|
+
* *inside* a signature, so learning it meant signing something first. That made the only correct
|
|
8
|
+
* publishing sequence — declare the curator, then sign — impossible to follow, and the obvious
|
|
9
|
+
* workaround is broken: signing appends to `signatures[]`, so signing to discover the kid, adding
|
|
10
|
+
* `curators[]`, then signing again leaves the first signature covering a document that no longer exists,
|
|
11
|
+
* and verification fails before upload.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.playlistSigningDidKey = playlistSigningDidKey;
|
|
15
|
+
const node_crypto_1 = require("node:crypto");
|
|
16
|
+
const ed25519_key_derive_1 = require("./ed25519-key-derive");
|
|
17
|
+
/**
|
|
18
|
+
* Derive the `did:key` for playlist signing key material.
|
|
19
|
+
*
|
|
20
|
+
* Accepts everything the signer accepts (PKCS#8 base64, raw seed hex/base64, PEM), because a user who
|
|
21
|
+
* can sign with a key must be able to see the identity that signing will assert.
|
|
22
|
+
*/
|
|
23
|
+
function playlistSigningDidKey(privateKeyMaterial) {
|
|
24
|
+
const privateKey = (0, ed25519_key_derive_1.parsePlaylistPrivateKeyToKeyObject)(privateKeyMaterial);
|
|
25
|
+
const raw = (0, node_crypto_1.createPublicKey)(privateKey).export({ format: 'jwk' });
|
|
26
|
+
if (!raw.x) {
|
|
27
|
+
throw new Error('Could not derive a public key from the configured playlist signing key');
|
|
28
|
+
}
|
|
29
|
+
// JWK `x` for Ed25519 is the 32-byte public key, base64url.
|
|
30
|
+
const publicKey = Buffer.from(raw.x, 'base64url');
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
32
|
+
const { Ed25519DIDKey } = require('dp1-js');
|
|
33
|
+
return Ed25519DIDKey(publicKey);
|
|
34
|
+
}
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -50,6 +50,19 @@ Used for signing DP‑1 playlists.
|
|
|
50
50
|
|
|
51
51
|
ff-cli normalizes whichever form you supply to base64 PKCS#8 DER before handing it to **`dp1-js`** (`SignMultiEd25519`). A malformed key fails with an actionable message (e.g. _"Invalid Ed25519 signing key … run `ff-cli setup` to generate one"_) instead of dp1-js's cryptic OpenSSL ASN.1 error (`header too long` / `wrong tag`).
|
|
52
52
|
|
|
53
|
+
- `playlist.curatorName` (string): Display name recorded alongside your signing key in the `curators[]`
|
|
54
|
+
of playlists built by `find` and `build`. Defaults to `ff-cli`; you can also set it via
|
|
55
|
+
`PLAYLIST_CURATOR_NAME`. The feed authorizes a create by matching a signature's `kid` against a key
|
|
56
|
+
declared in the document's own `curators[]`, so those commands write that declaration **before**
|
|
57
|
+
signing — a name is required next to the key by DP-1, and the value is public and covered by the
|
|
58
|
+
signature. The sample `YOUR_CURATOR_NAME` in `config.json.example` is treated as unset, so an untouched
|
|
59
|
+
config falls through to `PLAYLIST_CURATOR_NAME` or the default rather than publishing under a
|
|
60
|
+
placeholder.
|
|
61
|
+
|
|
62
|
+
To read the `did:key` your signatures will carry, run `ff-cli status`. It reports the identity from
|
|
63
|
+
`PLAYLIST_PRIVATE_KEY` even when no config file exists yet, and `ff-cli status --key <privateKey>`
|
|
64
|
+
reports it for an explicit key — the one `sign --key` would use.
|
|
65
|
+
|
|
53
66
|
- `playlist.role` (string): DP-1 signing role that travels with the private key. Defaults to `agent` if omitted. 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`).
|
|
54
67
|
|
|
55
68
|
### Generate an Ed25519 private key
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -317,12 +317,10 @@ Add feed servers to `config.json`:
|
|
|
317
317
|
{
|
|
318
318
|
"feedServers": [
|
|
319
319
|
{
|
|
320
|
-
"baseUrl": "http://localhost:8787/api/v1"
|
|
321
|
-
"apiKey": "your-api-key"
|
|
320
|
+
"baseUrl": "http://localhost:8787/api/v1"
|
|
322
321
|
},
|
|
323
322
|
{
|
|
324
|
-
"baseUrl": "https://feed.example.com/api/v1"
|
|
325
|
-
"apiKey": "your-api-key"
|
|
323
|
+
"baseUrl": "https://feed.example.com/api/v1"
|
|
326
324
|
}
|
|
327
325
|
]
|
|
328
326
|
}
|
|
@@ -383,12 +381,60 @@ Select server (0-based index): 0
|
|
|
383
381
|
Playlist file not found: /path/to/playlist.json
|
|
384
382
|
```
|
|
385
383
|
|
|
386
|
-
|
|
384
|
+
Both of these are caught locally, before anything is uploaded.
|
|
385
|
+
|
|
386
|
+
**Not signed:**
|
|
387
387
|
|
|
388
388
|
```
|
|
389
|
-
|
|
390
|
-
|
|
389
|
+
Publish failed
|
|
390
|
+
Playlist verification failed: Playlist signature verification failed
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Run `ff-cli sign <file>` before publishing. (Posting the same document straight to the feed answers
|
|
394
|
+
`{"error":"unauthorized","message":"missing authentication: request body must carry signatures"}`.)
|
|
395
|
+
|
|
396
|
+
**Signed, but the signer is not declared as a curator:**
|
|
397
|
+
|
|
391
398
|
```
|
|
399
|
+
Publish failed
|
|
400
|
+
Playlist is signed, but the signing key is not declared as a curator.
|
|
401
|
+
|
|
402
|
+
The feed accepts a publish when a signature's kid appears in the playlist's own curators[].
|
|
403
|
+
Add this to the playlist before signing:
|
|
404
|
+
"curators": [{ "name": "Your name", "key": "did:key:z6Mkv7qJ..." }]
|
|
405
|
+
then sign again from the unsigned file — signing appends, so re-signing an already-signed
|
|
406
|
+
playlist leaves the earlier signature covering a document that no longer exists.
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
This is the most common publish failure, and it is not about credentials. The feed accepts a create when
|
|
410
|
+
the document carries a signature whose `kid` matches a key declared in its own `curators[]`; posting such
|
|
411
|
+
a document directly returns
|
|
412
|
+
`{"error":"signature_verification_failed","message":"curator signature verification: no valid curator signature found"}`,
|
|
413
|
+
which reads as a signing fault, so `ff-cli` diagnoses it locally instead.
|
|
414
|
+
|
|
415
|
+
**Order matters.** A signature covers every field except `signature`/`signatures`, so `curators[]` has to
|
|
416
|
+
be declared *before* signing. Adding it afterwards invalidates the signature, and signing again does not
|
|
417
|
+
repair it — signing **appends**, leaving the earlier entry covering a document that no longer exists, so
|
|
418
|
+
verification fails before upload.
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
# 1. Read your signing identity — this is the kid your signatures will carry.
|
|
422
|
+
ff-cli status # → Signing identity (did:key) did:key:z6Mk...
|
|
423
|
+
|
|
424
|
+
# 2. Declare it in the UNSIGNED playlist.
|
|
425
|
+
# "curators": [{ "name": "Your Name", "key": "did:key:z6Mk..." }]
|
|
426
|
+
# DP-1 requires `name` alongside `key`.
|
|
427
|
+
|
|
428
|
+
# 3. Sign once, then publish.
|
|
429
|
+
ff-cli sign playlist.json
|
|
430
|
+
ff-cli publish playlist.json
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
If a playlist is already signed and you need to add `curators[]`, start again from the unsigned file
|
|
434
|
+
rather than re-signing: remove `signatures`, declare the curator, then sign.
|
|
435
|
+
|
|
436
|
+
`ff-cli publish` checks this before uploading, so a missing declaration fails immediately with the key to
|
|
437
|
+
add rather than as a server error. No API key is involved: the feed does not accept one.
|
|
392
438
|
|
|
393
439
|
## Complete Flow (build → validate → sign → play → publish)
|
|
394
440
|
|
|
@@ -399,7 +445,9 @@ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 -o playlist.json
|
|
|
399
445
|
# 2. Validate it
|
|
400
446
|
npm run dev -- validate playlist.json
|
|
401
447
|
|
|
402
|
-
# 3. Sign it
|
|
448
|
+
# 3. Sign it. Declare curators[] BEFORE this step: the signature covers it, and the feed only
|
|
449
|
+
# accepts a publish when a signature's kid matches a declared curator key.
|
|
450
|
+
# Run `ff-cli status` for the kid (add -k <key> if you sign with `sign --key`).
|
|
403
451
|
npm run dev -- sign playlist.json -o signed.json
|
|
404
452
|
|
|
405
453
|
# 4. Play it on a device
|
|
@@ -409,6 +457,18 @@ npm run dev -- play signed.json -d "Living Room Display"
|
|
|
409
457
|
npm run dev -- publish signed.json -s 0
|
|
410
458
|
```
|
|
411
459
|
|
|
460
|
+
With a signing key configured, `find` and `build` already produce a signed `playlist.json` with your
|
|
461
|
+
key declared in `curators[]`. Skip step 3 and use `playlist.json` in steps 4 and 5 — there is no
|
|
462
|
+
`signed.json` on that path:
|
|
463
|
+
|
|
464
|
+
```bash
|
|
465
|
+
npm run dev -- play playlist.json -d "Living Room Display"
|
|
466
|
+
npm run dev -- publish playlist.json -s 0
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Set `playlist.curatorName` in the config to control the name recorded in `curators[]`; it defaults
|
|
470
|
+
to `ff-cli`.
|
|
471
|
+
|
|
412
472
|
`ff-cli find` can collapse build + play + publish into one command:
|
|
413
473
|
|
|
414
474
|
```bash
|
package/docs/README.md
CHANGED
|
@@ -36,7 +36,8 @@ ff-cli config validate
|
|
|
36
36
|
{
|
|
37
37
|
"defaultDuration": 10,
|
|
38
38
|
"playlist": {
|
|
39
|
-
"privateKey": "your_ed25519_private_key_hex_or_base64_here"
|
|
39
|
+
"privateKey": "your_ed25519_private_key_hex_or_base64_here",
|
|
40
|
+
"curatorName": "YOUR_CURATOR_NAME"
|
|
40
41
|
},
|
|
41
42
|
"feed": { "baseURLs": ["https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"] },
|
|
42
43
|
"ff1Devices": {
|
|
@@ -278,16 +279,24 @@ The `publish` command:
|
|
|
278
279
|
|
|
279
280
|
Configure feed servers in `config.json`:
|
|
280
281
|
|
|
282
|
+
Publishing needs no credentials. The feed authorizes a create from the signatures inside the playlist:
|
|
283
|
+
it requires a signature whose `kid` matches a key the document declares in `curators[]`.
|
|
284
|
+
|
|
285
|
+
Declare it before signing, not after: a signature covers `curators[]`, so adding the entry to an
|
|
286
|
+
already-signed playlist invalidates it, and signing again appends rather than replaces. Run
|
|
287
|
+
`ff-cli status` to read the `did:key` your signatures will carry, put it in `curators[]`, then sign
|
|
288
|
+
once. If you sign with `sign --key <privateKey>`, read that key's identity with
|
|
289
|
+
`ff-cli status --key <privateKey>` — the configured identity would be the wrong one to declare. `find` and `build` do this for you when a signing key is configured. An `apiKey` left over in
|
|
290
|
+
an existing config is ignored.
|
|
291
|
+
|
|
281
292
|
```json
|
|
282
293
|
{
|
|
283
294
|
"feedServers": [
|
|
284
295
|
{
|
|
285
|
-
"baseUrl": "http://localhost:8787/api/v1"
|
|
286
|
-
"apiKey": "your-api-key-optional"
|
|
296
|
+
"baseUrl": "http://localhost:8787/api/v1"
|
|
287
297
|
},
|
|
288
298
|
{
|
|
289
|
-
"baseUrl": "https://feed.example.com/api/v1"
|
|
290
|
-
"apiKey": "your-api-key-optional"
|
|
299
|
+
"baseUrl": "https://feed.example.com/api/v1"
|
|
291
300
|
}
|
|
292
301
|
]
|
|
293
302
|
}
|