@feralfile/cli 2.4.0 → 2.6.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.
@@ -128,6 +128,33 @@ function detectTokenStandard(chain, contractAddress) {
128
128
  }
129
129
  return 'other';
130
130
  }
131
+ /**
132
+ * Token standards the indexer keys CIDs on. An asserted standard outside this
133
+ * set says nothing the CID can carry, so detection decides instead.
134
+ */
135
+ const CID_STANDARDS = new Set(['erc721', 'erc1155', 'fa2']);
136
+ /**
137
+ * resolveTokenStandard picks the standard a CID is built with.
138
+ *
139
+ * detectTokenStandard cannot tell ERC-721 from ERC-1155 — it has only a chain
140
+ * and an address, and answers erc721 for every EVM contract. The indexer keys
141
+ * the two differently (`eip155:1:erc721:…` and `eip155:1:erc1155:…`), so an
142
+ * ERC-1155 token looked up by detection alone is simply never found. When the
143
+ * caller knows the standard — a DP-1 playlist carries it in
144
+ * `provenance.contract.standard` — that assertion wins.
145
+ *
146
+ * @param {string} chain - Blockchain network
147
+ * @param {string} contractAddress - Contract address
148
+ * @param {string} [asserted] - Standard the caller vouches for, if any
149
+ * @returns {string} Token standard for the CID
150
+ */
151
+ function resolveTokenStandard(chain, contractAddress, asserted) {
152
+ const normalized = typeof asserted === 'string' ? asserted.trim().toLowerCase() : '';
153
+ if (CID_STANDARDS.has(normalized)) {
154
+ return normalized;
155
+ }
156
+ return detectTokenStandard(chain, contractAddress);
157
+ }
131
158
  /**
132
159
  * Build CAIP-2 token CID for indexer v2
133
160
  *
@@ -137,15 +164,19 @@ function detectTokenStandard(chain, contractAddress) {
137
164
  * @param {string} chain - Blockchain network (ethereum, polygon, tezos, etc)
138
165
  * @param {string} contractAddress - Contract address
139
166
  * @param {string} tokenId - Token ID
167
+ * @param {string} [standard] - Asserted token standard; detected when omitted
140
168
  * @returns {string} Token CID in CAIP-2 format
141
169
  * @example
142
170
  * // Returns: eip155:1:erc721:0xabc123:456
143
171
  * const cid = buildTokenCID('ethereum', '0xabc123', '456');
144
172
  * @example
173
+ * // Returns: eip155:1:erc1155:0xabc123:456
174
+ * const cid = buildTokenCID('ethereum', '0xabc123', '456', 'erc1155');
175
+ * @example
145
176
  * // Returns: tezos:mainnet:fa2:KT1abc:789
146
177
  * const cid = buildTokenCID('tezos', 'KT1abc', '789');
147
178
  */
148
- function buildTokenCID(chain, contractAddress, tokenId) {
179
+ function buildTokenCID(chain, contractAddress, tokenId, standard) {
149
180
  // Map chain names to CAIP-2 format (supports only Ethereum and Tezos)
150
181
  const caip2Map = {
151
182
  ethereum: 'eip155:1',
@@ -157,8 +188,8 @@ function buildTokenCID(chain, contractAddress, tokenId) {
157
188
  if (!caip2Chain) {
158
189
  throw new Error(`Unsupported chain: ${chain}. Only ethereum and tezos are supported.`);
159
190
  }
160
- const standard = detectTokenStandard(chain, contractAddress);
161
- return `${caip2Chain}:${standard}:${contractAddress}:${tokenId}`;
191
+ const resolvedStandard = resolveTokenStandard(chain, contractAddress, standard);
192
+ return `${caip2Chain}:${resolvedStandard}:${contractAddress}:${tokenId}`;
162
193
  }
163
194
  /**
164
195
  * Unified GraphQL query for tokens from indexer v2
@@ -319,9 +350,10 @@ function getBestMediaUrl(display = {}, mediaAssets = []) {
319
350
  *
320
351
  * @param {Object} indexerData - Token fields from indexer GraphQL
321
352
  * @param {string} chain - Blockchain network
353
+ * @param {string} [standard] - Asserted token standard; detected when omitted
322
354
  * @returns {Object} Standardized token data
323
355
  */
324
- function mapIndexerDataToStandardFormat(indexerData, chain) {
356
+ function mapIndexerDataToStandardFormat(indexerData, chain, standard) {
325
357
  if (!indexerData) {
326
358
  return {
327
359
  success: false,
@@ -334,14 +366,14 @@ function mapIndexerDataToStandardFormat(indexerData, chain) {
334
366
  const artistName = extractArtistName(display.artists);
335
367
  const name = display.name || `Token #${indexerData.token_number}`;
336
368
  const description = display.description || '';
337
- const standard = detectTokenStandard(chain, indexerData.contract_address);
369
+ const resolvedStandard = resolveTokenStandard(chain, indexerData.contract_address, standard);
338
370
  return {
339
371
  success: true,
340
372
  token: {
341
373
  chain,
342
374
  contractAddress: indexerData.contract_address,
343
375
  tokenId: indexerData.token_number,
344
- standard,
376
+ standard: resolvedStandard,
345
377
  name,
346
378
  description,
347
379
  image: {
@@ -492,8 +524,34 @@ function convertToDP1Item(tokenData, duration) {
492
524
  return {
493
525
  success: true,
494
526
  item: dp1Item,
527
+ still: stillOf(token),
495
528
  };
496
529
  }
530
+ /**
531
+ * stillOf names the token's still image as the indexer identifies it, or ''.
532
+ *
533
+ * This is `display.image_url`, the field the indexer reserves for a still and
534
+ * the same one `buildInlineManifestForToken` writes into `thumbnails.default`.
535
+ * It is returned beside the DP-1 item rather than inside it because the item
536
+ * only carries the still when it differs from the item's own source, and a
537
+ * consumer building a manifest against a *different* source (see
538
+ * enrich-playlist.ts) needs the still regardless. Nothing about the URL's
539
+ * shape is inspected: the indexer's word that it is an image is the evidence,
540
+ * and a suffix check would reject the extensionless CDN URLs it commonly
541
+ * returns.
542
+ *
543
+ * @param {Object} token - Token in the internal standard format
544
+ * @returns {string} http(s) still URL, or '' when the indexer named none
545
+ */
546
+ function stillOf(token) {
547
+ const image = token.image;
548
+ // Only `thumbnail` qualifies. `image.url` is the media getBestMediaUrl
549
+ // selected as the source, which is the live rendition whenever one exists —
550
+ // exactly what must never land in a thumbnail slot.
551
+ const candidate = typeof image === 'string' ? image : typeof image === 'object' && image ? image.thumbnail : '';
552
+ const trimmed = String(candidate || '').trim();
553
+ return trimmed.startsWith('http://') || trimmed.startsWith('https://') ? trimmed : '';
554
+ }
497
555
  /**
498
556
  * Get NFT token information from indexer and return as DP1 item (supports single or batch)
499
557
  * @param {Object|Array} params - Token parameters (single object or array)
@@ -507,8 +565,8 @@ async function getNFTTokenInfo(params) {
507
565
  return await getNFTTokenInfoBatch(params.tokens, duration);
508
566
  }
509
567
  // Handle single token
510
- const { chain, contractAddress, tokenId } = params;
511
- const result = await getNFTTokenInfoSingle({ chain, contractAddress, tokenId }, duration);
568
+ const { chain, contractAddress, tokenId, standard } = params;
569
+ const result = await getNFTTokenInfoSingle({ chain, contractAddress, tokenId, standard }, duration);
512
570
  return result;
513
571
  }
514
572
  /**
@@ -530,7 +588,7 @@ async function getNFTTokenInfo(params) {
530
588
  */
531
589
  async function getNFTTokenInfoSingle(params, duration, options = {}) {
532
590
  let chain = params.chain;
533
- const { contractAddress, tokenId } = params;
591
+ const { contractAddress, tokenId, standard } = params;
534
592
  // DEFENSIVE: Auto-detect and correct chain based on contract address format
535
593
  if (contractAddress.startsWith('KT') && chain !== 'tezos') {
536
594
  logger.warn(`[NFT Indexer] Chain mismatch detected! Contract ${contractAddress} starts with KT but chain="${chain}". Auto-correcting to "tezos".`);
@@ -547,7 +605,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
547
605
  });
548
606
  try {
549
607
  // Build token CID in CAIP-2 format
550
- const tokenCID = buildTokenCID(chain, contractAddress, tokenId);
608
+ const tokenCID = buildTokenCID(chain, contractAddress, tokenId, standard);
551
609
  logger.info(`[NFT Indexer] Built token CID: ${tokenCID}`);
552
610
  // Query the indexer
553
611
  logger.info(`[NFT Indexer] Querying indexer GraphQL for token...`);
@@ -556,7 +614,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
556
614
  if (!indexerData) {
557
615
  logger.info(`[NFT Indexer] Token not in database, triggering async indexing...`);
558
616
  // Trigger background indexing workflow
559
- const indexResult = await triggerIndexingAsync(chain, contractAddress, tokenId);
617
+ const indexResult = await triggerIndexingAsync(chain, contractAddress, tokenId, standard);
560
618
  if (!indexResult.success) {
561
619
  logger.debug(`[NFT Indexer] Failed to trigger indexing:`, indexResult.error);
562
620
  return {
@@ -609,7 +667,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
609
667
  }
610
668
  }
611
669
  // Map to standard format and convert to DP1
612
- const tokenData = mapIndexerDataToStandardFormat(indexerData, chain);
670
+ const tokenData = mapIndexerDataToStandardFormat(indexerData, chain, standard);
613
671
  return convertToDP1Item(tokenData, duration);
614
672
  }
615
673
  catch (error) {
@@ -621,15 +679,21 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
621
679
  }
622
680
  }
623
681
  /**
624
- * Get NFT token information in batch and return as DP1 items (parallel processing)
682
+ * Resolve a batch of tokens and return every outcome (parallel processing)
625
683
  *
626
684
  * For missing tokens: triggers indexing per token, polls by job_id, then fetches again.
627
685
  *
628
- * @param {Array} tokens - Array of token parameters
686
+ * Unlike getNFTTokenInfoBatch this keeps one result per input token, in input
687
+ * order: a resolved token as `{ success: true, item, still }`, a failed one as
688
+ * `{ success: false, error, token }`. Callers that must know which request an
689
+ * answer belongs to, or that need the still beside the item, use this.
690
+ *
691
+ * @param {Array} tokens - Array of token parameters (chain, contractAddress, tokenId, standard?)
629
692
  * @param {number} duration - Display duration in seconds
630
- * @returns {Promise<Array>} Array of DP1 items
693
+ * @param {Function} [onProgress] - Called with (done, total) after each batch
694
+ * @returns {Promise<Array>} One result per input token
631
695
  */
632
- async function getNFTTokenInfoBatch(tokens, duration, onProgress) {
696
+ async function resolveTokenBatch(tokens, duration, onProgress) {
633
697
  logger.info(`[NFT Indexer] 📦 Starting batch processing for ${tokens.length} token(s)...`);
634
698
  logger.debug('[NFT Indexer] Batch tokens:', tokens);
635
699
  const results = [];
@@ -669,6 +733,23 @@ async function getNFTTokenInfoBatch(tokens, duration, onProgress) {
669
733
  const successCount = results.filter((r) => r.success && r.item).length;
670
734
  const failedCount = results.length - successCount;
671
735
  logger.info(`[NFT Indexer] Final: ${successCount} items with data, ${failedCount} without`);
736
+ return results;
737
+ }
738
+ /**
739
+ * Get NFT token information in batch and return as DP1 items (parallel processing)
740
+ *
741
+ * For missing tokens: triggers indexing per token, polls by job_id, then fetches again.
742
+ *
743
+ * Returns only the resolved items. Failures are dropped, not represented, so
744
+ * the result is neither aligned with nor as long as the input; a caller that
745
+ * needs the association uses resolveTokenBatch.
746
+ *
747
+ * @param {Array} tokens - Array of token parameters
748
+ * @param {number} duration - Display duration in seconds
749
+ * @returns {Promise<Array>} Array of DP1 items
750
+ */
751
+ async function getNFTTokenInfoBatch(tokens, duration, onProgress) {
752
+ const results = await resolveTokenBatch(tokens, duration, onProgress);
672
753
  // Return only items (not error objects)
673
754
  const items = results.filter((r) => r.success && r.item).map((r) => r.item);
674
755
  logger.info(`[NFT Indexer] Returning ${items.length} items`);
@@ -719,10 +800,10 @@ async function getCollectionInfo(params) {
719
800
  * @returns {number} [returns.job_id] - Postgres queue job id; use with jobStatus / pollForJobCompletion
720
801
  * @returns {string} [returns.error] - Error message if failed
721
802
  */
722
- async function triggerIndexingAsync(chain, contractAddress, tokenId) {
803
+ async function triggerIndexingAsync(chain, contractAddress, tokenId, standard) {
723
804
  try {
724
805
  // Build token CID
725
- const tokenCID = buildTokenCID(chain, contractAddress, tokenId);
806
+ const tokenCID = buildTokenCID(chain, contractAddress, tokenId, standard);
726
807
  logger.debug('[NFT Indexer] Triggering token indexing job via GraphQL mutation:', {
727
808
  tokenCID,
728
809
  });
@@ -1050,6 +1131,7 @@ module.exports = {
1050
1131
  getNFTTokenInfo,
1051
1132
  // Batch processing
1052
1133
  getNFTTokenInfoBatch,
1134
+ resolveTokenBatch,
1053
1135
  // Single token processing
1054
1136
  getNFTTokenInfoSingle,
1055
1137
  // Additional functions
@@ -10,9 +10,18 @@
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';
17
+ /**
18
+ * DP-1 signature role that carries ownership of a playlist.
19
+ *
20
+ * The feed derives a playlist's owners from `curators[]` and then requires one of those keys to have
21
+ * signed as `curator`: being named is a claim, signing in this role is the proof. Playlist groups use
22
+ * the same role; channels use `publisher`, which ff-cli does not publish.
23
+ */
24
+ const PLAYLIST_OWNER_ROLE = 'curator';
16
25
  /**
17
26
  * isTimeBasedMedia reports whether an item's media has an intrinsic runtime
18
27
  * (video or audio) per DP-1 §4.1 "time-based sources".
@@ -482,10 +491,30 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
482
491
  }
483
492
  const playlist = playlistBuilder.build();
484
493
  // Sign the playlist if private key is configured.
494
+ //
495
+ // The curator declaration has to be written BEFORE signing. The feed accepts a create only when a
496
+ // signature's kid matches a key in the document's own curators[], and a signature covers curators[],
497
+ // so declaring it afterwards would invalidate the signature we just produced. A playlist built without
498
+ // this entry is signed but unpublishable, which is how `find --publish` used to fail.
485
499
  try {
486
500
  const playlistConfig = getPlaylistConfig();
487
501
  if (playlistConfig.privateKey) {
488
- playlist.signatures = [await signPlaylist(playlist, playlistConfig.privateKey)];
502
+ const key = playlistSigningDidKey(playlistConfig.privateKey);
503
+ const declared = Array.isArray(playlist.curators) ? playlist.curators : [];
504
+ // Respect a curator the caller already declared for this key; only add what is missing.
505
+ if (!declared.some((curator) => curator && curator.key === key)) {
506
+ playlist.curators = [...declared, { name: playlistConfig.curatorName || 'ff-cli', key }];
507
+ }
508
+ // Sign in the `curator` role, not the configured one. Declaring the key in curators[] is the
509
+ // document claiming that key is an owner; the feed treats a declared key as an owner only when the
510
+ // same key also signed in the owner role, so any other role leaves the document declared, verified
511
+ // and unpublishable — the exact shape that made every ff-cli playlist fail before (#107). The role
512
+ // has to follow the claim the builder just wrote. `playlist.role` still governs `ff-cli sign`,
513
+ // where the user chooses what claim to make; to build under a different role, build without a
514
+ // configured key and sign the result explicitly.
515
+ playlist.signatures = [
516
+ await signPlaylist(playlist, playlistConfig.privateKey, PLAYLIST_OWNER_ROLE),
517
+ ];
489
518
  delete playlist.signature;
490
519
  }
491
520
  }
@@ -34,6 +34,25 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.castPlaylist = castPlaylist;
37
+ /**
38
+ * DP-1 signature role used when signing a playlist for playback.
39
+ *
40
+ * A cast never passes through a feed, so a document signed here carries no `feed` signature and its own
41
+ * entry is the only one a player could judge. DP-1 §7.1.1 rule 1 has players verify a `feed` or `curator`
42
+ * signature, so the configured role (shipped default `agent`) is the weakest choice available for a
43
+ * document this CLI is minting itself.
44
+ *
45
+ * Scope: this governs only the envelope the CLI *creates* — direct media, which arrives unsigned and must
46
+ * be signed before delivery. A playlist that already carries signatures is cast verbatim whatever its
47
+ * roles, because rewriting someone else's envelope is not this function's business. No player in this
48
+ * ecosystem enforces rule 1 today (ff-player performs no signature verification at all), so this is about
49
+ * not minting the weakest document we could, rather than about a failure observed on a device.
50
+ *
51
+ * This also has to match what the builder wrote: a wrapped media URL arrives here already declaring the
52
+ * signing key in `curators[]` and signed as `curator`, and signing REPLACES `signatures[]` rather than
53
+ * appending — so any other role here silently downgrades that envelope on its way to the device.
54
+ */
55
+ const PLAYBACK_SIGNING_ROLE = 'curator';
37
56
  // playlist-signer and ff1-device are still CommonJS; require keeps interop simple.
38
57
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
58
  const { signPlaylist } = require('./playlist-signer');
@@ -68,7 +87,7 @@ async function castPlaylist(playlist, options = {}) {
68
87
  };
69
88
  }
70
89
  try {
71
- const signature = await signPlaylist(current, options.signingKey);
90
+ const signature = await signPlaylist(current, options.signingKey, PLAYBACK_SIGNING_ROLE);
72
91
  current = {
73
92
  ...current,
74
93
  signature: undefined,
@@ -7,6 +7,12 @@ exports.publishPlaylist = publishPlaylist;
7
7
  const axios_1 = __importDefault(require("axios"));
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const playlist_verifier_1 = require("./playlist-verifier");
10
+ /**
11
+ * DP-1 signature role that carries ownership of a playlist.
12
+ *
13
+ * Mirrors the feed's owner role for playlists (channels use `publisher`, which ff-cli does not publish).
14
+ */
15
+ const OWNER_ROLE = 'curator';
10
16
  /**
11
17
  * Publish a verified playlist to a DP-1 feed server
12
18
  *
@@ -18,17 +24,16 @@ const playlist_verifier_1 = require("./playlist-verifier");
18
24
  *
19
25
  * @param {string} filePath - Path to playlist JSON file
20
26
  * @param {string} feedServerUrl - Feed server base URL
21
- * @param {string} [apiKey] - Optional API key for authentication
22
27
  * @returns {Promise<Object>} Result with success status, playlistId, or error
23
28
  * @example
24
- * const result = await publishPlaylist('playlist.json', 'http://localhost:8787/api/v1', 'api-key');
29
+ * const result = await publishPlaylist('playlist.json', 'http://localhost:8787/api/v1');
25
30
  * if (result.success) {
26
31
  * console.log(`Published with ID: ${result.playlistId}`);
27
32
  * } else {
28
33
  * console.error(`Failed: ${result.error}`);
29
34
  * }
30
35
  */
31
- async function publishPlaylist(filePath, feedServerUrl, apiKey) {
36
+ async function publishPlaylist(filePath, feedServerUrl) {
32
37
  try {
33
38
  // Step 1: Read and parse playlist file
34
39
  if (!fs_1.default.existsSync(filePath)) {
@@ -48,7 +53,33 @@ async function publishPlaylist(filePath, feedServerUrl, apiKey) {
48
53
  error: `Invalid JSON in playlist file: ${filePath}`,
49
54
  };
50
55
  }
51
- // Step 2: Verify signature integrity before publishing.
56
+ // Step 2: refuse legacy flat-signature documents outright, before verification.
57
+ //
58
+ // A flat `signature` string carries no kid, so the curator rule below has nothing to match and such
59
+ // a document would reach the feed only to be refused as unauthenticated — verified against a running
60
+ // feed. This runs ahead of verification deliberately: the document is unpublishable whether or not
61
+ // its legacy signature is valid, and 're-sign as a v1.1 envelope' is the remedy either way, which
62
+ // 'signature verification failed' would not convey.
63
+ const hasLegacyFlatSignature = typeof playlist.signature === 'string' &&
64
+ playlist.signature.trim().length > 0;
65
+ const hasEnvelope = Array.isArray(playlist.signatures)
66
+ ? (playlist.signatures ?? []).length > 0
67
+ : false;
68
+ if (!hasEnvelope && hasLegacyFlatSignature) {
69
+ return {
70
+ success: false,
71
+ error: 'Playlist carries a legacy flat signature, which the feed cannot authorize.',
72
+ message: `The feed authorizes a publish from a signatures[] envelope, matching a signature's kid\n` +
73
+ ` against the playlist's own curators[]. A flat "signature" string carries no kid.\n` +
74
+ ` Re-sign it as a DP-1 v1.1 envelope:\n` +
75
+ ` 1. remove the "signature" field\n` +
76
+ ` 2. declare your key: "curators": [{ "name": "Your name", "key": "<did:key from ff-cli status>" }]\n` +
77
+ ` 3. ff-cli sign <file> -r ${OWNER_ROLE}\n` +
78
+ ` The role matters: a declared key counts as an owner only when it also signed as "${OWNER_ROLE}",\n` +
79
+ ` and plain "ff-cli sign" uses playlist.role, which defaults to "agent".`,
80
+ };
81
+ }
82
+ // Step 3: Verify signature integrity before publishing.
52
83
  const deliveryResult = await (0, playlist_verifier_1.verifyPlaylist)(playlist);
53
84
  if (!deliveryResult.valid) {
54
85
  return {
@@ -57,17 +88,103 @@ async function publishPlaylist(filePath, feedServerUrl, apiKey) {
57
88
  message: deliveryResult.details?.map((d) => ` • ${d.path}: ${d.message}`).join('\n'),
58
89
  };
59
90
  }
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}`;
91
+ // Step 4: fail here, not at the feed, when the signer is not declared as a curator.
92
+ //
93
+ // The feed accepts a create only when a signature's `kid` matches a key the document declares in
94
+ // `curators[]`. Signing alone does not satisfy that, and the server's answer — "no valid curator
95
+ // signature found" — reads as a signing problem, which sends people to check their key instead of
96
+ // their document. Checking locally turns a confusing 400 into an instruction, and costs no request.
97
+ const curatorKeys = new Set((Array.isArray(playlist.curators)
98
+ ? (playlist.curators ?? [])
99
+ : [])
100
+ .map((c) => (typeof c?.key === 'string' ? c.key.trim() : ''))
101
+ .filter((k) => k.length > 0));
102
+ const signatures = Array.isArray(playlist.signatures)
103
+ ? (playlist
104
+ .signatures ?? [])
105
+ : [];
106
+ const signingKids = signatures
107
+ .map((sig) => (typeof sig?.kid === 'string' ? sig.kid.trim() : ''))
108
+ .filter((k) => k.length > 0);
109
+ const declaredSignatures = signatures.filter((sig) => curatorKeys.has(typeof sig?.kid === 'string' ? sig.kid.trim() : ''));
110
+ // Same predicate as `no declared signature`, expressed through the set the next gate also needs.
111
+ if (signingKids.length > 0 && declaredSignatures.length === 0) {
112
+ return {
113
+ success: false,
114
+ error: 'Playlist is signed, but the signing key is not declared as a curator.',
115
+ message: `The feed accepts a publish when a signature's kid appears in the playlist's own curators[].\n` +
116
+ ` Add this to the playlist before signing:\n` +
117
+ ` "curators": [{ "name": "Your name", "key": "${signingKids[0]}" }]\n` +
118
+ ` then sign again from the unsigned file, with the same key you just declared:\n` +
119
+ ` ff-cli sign <file> -r ${OWNER_ROLE} --key <private key for ${signingKids[0]}>\n` +
120
+ ` Drop --key if that key is your configured one; "sign" uses the configured key otherwise, and a\n` +
121
+ ` signature from an undeclared key would not satisfy the feed. The role is ff-cli's requirement\n` +
122
+ ` rather than every feed's: see the owner-role check for why publishing without it is refused.\n` +
123
+ ` Start from the unsigned file\n` +
124
+ ` because declaring curators[] changes the signed payload: signing appends, so the earlier\n` +
125
+ ` signature would be left covering a document that no longer exists.`,
126
+ };
127
+ }
128
+ // Step 4b: a declared key must also have signed AS curator.
129
+ //
130
+ // Being named in curators[] is a claim; signing in the owner role is the proof, and the feed requires
131
+ // both before it treats a key as an owner. A document that is declared but signed under another role
132
+ // (ff-cli's own default was `agent`) verifies cleanly and is still refused, and the server answers in
133
+ // terms of ownership — which reads as "fix curators[]", the one part that is already correct. Naming
134
+ // the role and the remedy here is the difference between a one-flag fix and a wrong-end search.
135
+ //
136
+ // This is deliberately stricter than a feed that ignores the role: such a feed accepts the document
137
+ // today and freezes it the moment role-aware ownership lands, because a replace and a delete both
138
+ // need an owner signature the document does not carry. Refusing now keeps that document from being
139
+ // created at all.
140
+ if (declaredSignatures.length > 0 &&
141
+ !declaredSignatures.some((sig) => sig.role === OWNER_ROLE)) {
142
+ const rolesUsed = [
143
+ ...new Set(declaredSignatures
144
+ .map((sig) => (typeof sig.role === 'string' ? sig.role.trim() : ''))
145
+ .filter((role) => role.length > 0)),
146
+ ];
147
+ const seen = rolesUsed.length > 0 ? rolesUsed.map((role) => `"${role}"`).join(', ') : 'no role';
148
+ // List every key eligible to repair this, not the one that happens to have signed.
149
+ //
150
+ // The gate above accepts an owner-role signature from ANY declared key, so on a playlist declaring
151
+ // several curators the holder of any one of them can append it. Naming only the key that signed
152
+ // under the wrong role describes an impossible fix to everyone else — they may not hold it — while
153
+ // a key they do hold would have worked. `sign` still defaults to the configured key, which need not
154
+ // be declared at all, so the eligible set has to be stated rather than assumed.
155
+ const eligibleKeys = [...curatorKeys];
156
+ const eligibleList = eligibleKeys.length > 0 ? eligibleKeys.join(', ') : 'a key declared in curators[]';
157
+ return {
158
+ success: false,
159
+ error: `Playlist is signed by a declared curator, but under a non-owner role (${seen}).`,
160
+ message: `This is ff-cli's check, not a feed's answer. Feeds are moving to role-aware ownership, where\n` +
161
+ ` a key in curators[] counts as an owner only if it also signed as "${OWNER_ROLE}". A feed that\n` +
162
+ ` does not check the role yet will accept this document, and then be unable to authorize a\n` +
163
+ ` replace or a delete for it: both need an owner signature it does not carry. Publishing it is\n` +
164
+ ` what makes that permanent, so ff-cli refuses here instead.\n` +
165
+ `\n` +
166
+ ` curators[] is already correct — only the role is missing, so add that signature to this file:\n` +
167
+ ` ff-cli sign <file> -r ${OWNER_ROLE} --key <private key for a declared curator>\n` +
168
+ ` Any key this playlist declares will do — you do not need the one that signed under the wrong\n` +
169
+ ` role. Declared: ${eligibleList}\n` +
170
+ ` It must be one of those: a ${OWNER_ROLE} signature from an undeclared key satisfies neither\n` +
171
+ ` this check nor role-aware ownership, since both read roles only from declared keys. "sign"\n` +
172
+ ` uses the configured key unless --key says otherwise; drop --key if a declared key is already\n` +
173
+ ` your configured one, and confirm which identity a key carries with\n` +
174
+ ` "ff-cli status --key <private key>".\n` +
175
+ ` Signing appends, and the payload hash excludes signatures, so the existing entry stays valid\n` +
176
+ ` and the document ends up carrying both. No unsigned copy is needed: you only have to start\n` +
177
+ ` from one when changing signed content such as curators[] itself.`,
178
+ };
68
179
  }
180
+ // Step 5: Send validated playlist to feed server.
181
+ //
182
+ // No auth header. The feed authorizes a create from the document's own signatures: it requires a
183
+ // signature whose kid matches a key declared in the playlist's `curators[]`. An API key is neither
184
+ // sent nor accepted -- the feed removed that path entirely -- so a playlist that is not self-signed
185
+ // by a declared curator is rejected no matter what credentials accompany it.
69
186
  const response = await axios_1.default.post(`${feedServerUrl}/playlists`, playlist, {
70
- headers,
187
+ headers: { 'Content-Type': 'application/json' },
71
188
  timeout: 30000,
72
189
  });
73
190
  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,7 +50,20 @@ 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.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`).
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
+
66
+ - `playlist.role` (string): DP-1 signing role used by `ff-cli sign`. 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`). It does **not** apply to `find` and `build`, which always sign as `curator` — see [Signing role and ownership](#signing-role-and-ownership).
54
67
 
55
68
  ### Generate an Ed25519 private key
56
69
 
@@ -75,6 +88,33 @@ Paste the value into `playlist.privateKey`. Any of these are accepted:
75
88
 
76
89
  If you need a different role, set `playlist.role` to one of the DP-1 signing roles such as `agent`, `feed`, `curator`, `institution`, or `licensor`. The CLI rejects any other string before it reaches `dp1-js`.
77
90
 
91
+ ### Signing role and ownership
92
+
93
+ A feed decides who owns a playlist from the document itself: the keys in `curators[]` are the owner set.
94
+ Under **role-aware ownership**, a key counts as an owner only when that same key *also signed in the
95
+ `curator` role* — being named is a claim, signing as `curator` is the proof.
96
+
97
+ `ff-cli publish` requires that role. **This is ff-cli's check, not every feed's answer today**: a feed that
98
+ does not yet enforce roles accepts an `agent`-signed document, so a manual upload of the same file can
99
+ still succeed. It is refused here because once accepted, such a document can never be replaced or deleted —
100
+ both need an owner signature it does not carry, and the only way to add one is to replace it. Publishing is
101
+ what makes that permanent.
102
+
103
+ The `kid`-to-`curators[]` match is different: feeds enforce that today, and a playlist failing it is
104
+ rejected by the server regardless of ff-cli.
105
+
106
+ Two consequences:
107
+
108
+ - `find` and `build` declare your key in `curators[]` and therefore always sign as `curator`, whatever
109
+ `playlist.role` says. To build under a different role, build without a configured signing key and sign the
110
+ result yourself with `ff-cli sign --role`.
111
+ - `ff-cli publish` refuses a playlist whose declared key signed under another role, and names the fix
112
+ (`ff-cli sign <file> -r curator`, from any key declared in `curators[]`). This runs before any request,
113
+ so it costs nothing — and deliberately does not defer to a feed that would accept the document today,
114
+ because acceptance is what makes it unfixable.
115
+
116
+ Channels use `publisher` as the owner role instead of `curator`; ff-cli does not publish channels.
117
+
78
118
  If you already have a base64 key and want hex, convert it:
79
119
 
80
120
  ```bash