@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 serverApiKey = feedConfig.servers?.[serverIndex]?.apiKey ?? feedConfig.apiKey;
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
- const privateKey = config.playlist?.privateKey || process.env.PLAYLIST_PRIVATE_KEY;
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, serverApiKey);
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
- .action(async () => {
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,
@@ -121,10 +121,38 @@ function getPlaylistConfig() {
121
121
  const config = getConfig();
122
122
  const playlistConfig = config.playlist || {};
123
123
  return {
124
- privateKey: playlistConfig.privateKey || process.env.PLAYLIST_PRIVATE_KEY || null,
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/apiKey) and new (feedServers array) formats.
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 {string} [returns.apiKey] - Optional API key for authentication (legacy)
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, playlistSettings.feedServer.apiKey);
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', 'api-key');
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, apiKey) {
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: Verify signature integrity before publishing.
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 3: Send validated playlist to feed server
61
- const headers = {
62
- 'Content-Type': 'application/json',
63
- };
64
- // Use provided apiKey, fallback to environment variable, or use empty string as last resort
65
- const authKey = apiKey !== undefined ? apiKey : process.env.FEED_API_KEY || '';
66
- if (authKey) {
67
- headers['Authorization'] = `Bearer ${authKey}`;
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
+ }
@@ -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
- **API error:**
384
+ Both of these are caught locally, before anything is uploaded.
385
+
386
+ **Not signed:**
387
387
 
388
388
  ```
389
- ❌ Failed to publish playlist
390
- Failed to publish: {"error":"unauthorized","message":"Invalid API key"}
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",