@feralfile/cli 2.5.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.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.5.0",
3
+ "version": "2.6.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",
@@ -60,7 +60,7 @@
60
60
  "dependencies": {
61
61
  "@feralfile/source-resolver": "^1.0.0",
62
62
  "@napi-rs/keyring": "1.3.0",
63
- "axios": "^1.6.2",
63
+ "axios": "^1.20.0",
64
64
  "bonjour-service": "^1.3.0",
65
65
  "chalk": "^4.1.2",
66
66
  "commander": "^11.1.0",
@@ -68,7 +68,7 @@
68
68
  "dp1-js": "2.3.1",
69
69
  "ff1": "^1.0.0",
70
70
  "fuzzysort": "^3.1.0",
71
- "viem": "^2.38.2"
71
+ "viem": "^2.56.3"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@types/node": "^24.7.1",
@@ -12,7 +12,7 @@ const path_1 = require("path");
12
12
  const enrich_playlist_1 = require("../utilities/enrich-playlist");
13
13
  const playlist_verifier_1 = require("../utilities/playlist-verifier");
14
14
  // eslint-disable-next-line @typescript-eslint/no-require-imports
15
- const { getNFTTokenInfoBatch } = require('../utilities/nft-indexer');
15
+ const { resolveTokenBatch } = require('../utilities/nft-indexer');
16
16
  /**
17
17
  * writePlaylistAtomically replaces a file only once the new bytes are safely on
18
18
  * disk.
@@ -63,7 +63,16 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
63
63
  // of it, or widen who can reach it. Carry the ownership across, and when
64
64
  // that is not permitted refuse the in-place replacement instead of
65
65
  // completing it with different access than the file had.
66
- if (current && (current.uid !== process.getuid?.() || current.gid !== process.getgid?.())) {
66
+ //
67
+ // Only where POSIX identity exists. Windows has no getuid/getgid and
68
+ // reports uid and gid as 0 on every stat, so the comparison there would
69
+ // always "differ" and chown a file whose ownership never moved. (libuv
70
+ // makes fchown a no-op on Windows, so nothing would break — but the
71
+ // branch would be asserting something it cannot know.)
72
+ const uid = process.getuid?.();
73
+ const gid = process.getgid?.();
74
+ const canCompareOwnership = uid !== undefined && gid !== undefined;
75
+ if (current && canCompareOwnership && (current.uid !== uid || current.gid !== gid)) {
67
76
  try {
68
77
  await handle.chown(current.uid, current.gid);
69
78
  }
@@ -230,12 +239,22 @@ exports.enrichCommand = new commander_1.Command('enrich')
230
239
  printedProgress = true;
231
240
  process.stdout.write(chalk_1.default.dim(`\r ${done}/${count} looked up...`));
232
241
  };
233
- // getNFTTokenInfoBatch is (tokens, duration, onProgress) — the second
242
+ // resolveTokenBatch is (tokens, duration, onProgress) — the second
234
243
  // positional is DP-1 display seconds, not the callback. Enrichment never
235
244
  // sets duration (the curator's timing is not ours to touch), so it is
236
245
  // passed undefined and the callback goes third. find.ts carries the same
237
246
  // warning; getting this wrong silently returns unusable results.
238
- const lookup = (tokens, onProgressCallback) => getNFTTokenInfoBatch(tokens, undefined, onProgressCallback);
247
+ //
248
+ // The raw results are used rather than getNFTTokenInfoBatch's items so
249
+ // the indexer's still rides beside each item: the item itself drops the
250
+ // still whenever it equals the indexer's chosen source, and enrichment
251
+ // needs it against the curator's source instead.
252
+ const lookup = async (tokens, onProgressCallback) => {
253
+ const results = await resolveTokenBatch(tokens, undefined, onProgressCallback);
254
+ return results
255
+ .filter((result) => result.success && result.item)
256
+ .map((result) => ({ ...result.item, still: result.still }));
257
+ };
239
258
  const result = await (0, enrich_playlist_1.enrichPlaylistManifests)(playlist, lookup, {
240
259
  force: options.force,
241
260
  assumeEthereum: options.assumeEthereum,
@@ -129,7 +129,43 @@ function coordinateKeyOf(provenance) {
129
129
  if (token.length === 0) {
130
130
  return null;
131
131
  }
132
- return `${canonicalChain(chain)}:${address.trim().toLowerCase()}:${token}`;
132
+ const canonical = canonicalChain(chain);
133
+ const standard = effectiveStandard(canonical, address, contract.standard);
134
+ return `${canonical}:${standard}:${address.trim().toLowerCase()}:${token}`;
135
+ }
136
+ /**
137
+ * Standards the indexer keys CIDs on. Mirrors CID_STANDARDS in nft-indexer.js.
138
+ */
139
+ const CID_STANDARDS = new Set(['erc721', 'erc1155', 'fa2']);
140
+ /**
141
+ * effectiveStandard names the standard a coordinate is looked up under, so the
142
+ * correlation key separates what the indexer separates.
143
+ *
144
+ * The indexer keys ERC-721 and ERC-1155 tokens at one address under different
145
+ * CIDs, so a hybrid contract can hold both standards at one token id. Two
146
+ * playlist items asserting different standards for one coordinate are two
147
+ * lookups, and each answer must reach only the item that asked for it.
148
+ *
149
+ * When the playlist asserts nothing the client detects, and this mirrors that
150
+ * detection (resolveTokenStandard in nft-indexer.js) so the request key and
151
+ * the key read off the response agree. The mirror is deliberate rather than
152
+ * an import: this module takes its lookup by injection and stays free of the
153
+ * indexer client. If the two ever drift, correlation fails closed — the item
154
+ * reports not-indexed — rather than attributing one token's metadata to
155
+ * another.
156
+ */
157
+ function effectiveStandard(canonicalChainName, address, asserted) {
158
+ const normalized = typeof asserted === 'string' ? asserted.trim().toLowerCase() : '';
159
+ if (CID_STANDARDS.has(normalized)) {
160
+ return normalized;
161
+ }
162
+ if (canonicalChainName === 'tezos' || address.trim().startsWith('KT')) {
163
+ return 'fa2';
164
+ }
165
+ if (canonicalChainName === 'evm') {
166
+ return 'erc721';
167
+ }
168
+ return 'other';
133
169
  }
134
170
  /**
135
171
  * describeItem names an item for operator output, preferring its title.
@@ -160,14 +196,22 @@ function coordinateFor(item) {
160
196
  return null;
161
197
  }
162
198
  const normalized = chain.trim().toLowerCase();
163
- return {
199
+ const coordinate = {
164
200
  chain: CHAIN_ALIASES[normalized] ?? normalized,
165
201
  contractAddress: address,
166
202
  tokenId: token,
167
203
  };
204
+ // Carried as written. The indexer client accepts the standards its CIDs
205
+ // are keyed on and falls back to detection for anything else, so an
206
+ // unexpected value costs nothing beyond the lookup it would have lost anyway.
207
+ const standard = contract.standard;
208
+ if (typeof standard === 'string' && standard.trim().length > 0) {
209
+ coordinate.standard = standard.trim().toLowerCase();
210
+ }
211
+ return coordinate;
168
212
  }
169
213
  /**
170
- * stillFromIndexerSource recovers a thumbnail the manifest builder suppressed.
214
+ * stillFor recovers a thumbnail the manifest builder suppressed.
171
215
  *
172
216
  * `resolveStillUri` blanks a still that equals the source it was given, on the
173
217
  * reasonable ground that pointing a thumbnail at the artwork itself adds
@@ -179,64 +223,26 @@ function coordinateFor(item) {
179
223
  * exists to repair: a live work the app cannot rasterize, whose grid tile
180
224
  * stays empty.
181
225
  *
182
- * Recovering it is the inverse of the suppression. When the indexer's source
183
- * differs from the item's and is an http(s) URL, that source IS the still that
184
- * was elided, so it becomes the thumbnail relative to the item's own source.
226
+ * Recovery is the same rule applied against the curator's source instead of
227
+ * the indexer's: the still the indexer names for the token (`resolved.still`,
228
+ * its `display.image_url`) is used whenever it differs from the item's own
229
+ * source. It is the same field `find` and `build` write into
230
+ * `thumbnails.default`, and it is taken on the indexer's word, as they take
231
+ * it. No guess is made from a URL's shape — the indexer's source is often a
232
+ * live rendition and its stills are often extensionless CDN URLs, so a
233
+ * suffix check gets both cases wrong — and a lookup that names no still
234
+ * leaves the item without one.
185
235
  */
186
- function stillFromIndexerSource(item, resolved) {
187
- const indexerSource = typeof resolved.source === 'string' ? resolved.source.trim() : '';
236
+ function stillFor(item, resolved) {
237
+ const still = typeof resolved.still === 'string' ? resolved.still.trim() : '';
188
238
  const itemSource = typeof item.source === 'string' ? item.source.trim() : '';
189
- if (!indexerSource || indexerSource === itemSource) {
239
+ if (!still || still === itemSource) {
190
240
  return '';
191
241
  }
192
- if (!indexerSource.startsWith('http://') && !indexerSource.startsWith('https://')) {
242
+ if (!still.startsWith('http://') && !still.startsWith('https://')) {
193
243
  return '';
194
244
  }
195
- // The indexer's source is not necessarily a still. getBestMediaUrl prefers
196
- // display.animation_url and media assets over display.image_url, so it is
197
- // frequently a live HTML rendition — and writing one into the thumbnail slot
198
- // is worse than leaving the slot empty: the grid still cannot rasterize it,
199
- // and the item now claims a still it does not have.
200
- //
201
- // Only a URL that is demonstrably an image is used. The indexer exposes no
202
- // separately identified still on the item this command receives, so the file
203
- // extension is the evidence available; carrying a real image field through
204
- // the batch result would be the better fix and belongs upstream.
205
- return looksLikeImage(indexerSource) ? indexerSource : '';
206
- }
207
- /**
208
- * Media the app can draw a grid tile from, matching the types
209
- * playlist-builder.js already recognizes. SVG and video belong here: the app
210
- * rasterizes both, and excluding them left works with the empty tile this
211
- * command exists to remove. HTML is deliberately absent — an unrasterizable
212
- * page in the thumbnail slot is worse than an empty slot, because the item
213
- * then claims a still it does not have.
214
- */
215
- const RASTERIZABLE_EXTENSIONS = [
216
- '.png',
217
- '.jpg',
218
- '.jpeg',
219
- '.gif',
220
- '.webp',
221
- '.avif',
222
- '.bmp',
223
- '.svg',
224
- '.mp4',
225
- '.webm',
226
- ];
227
- /**
228
- * looksLikeImage tests a URL's path extension, ignoring query and fragment.
229
- */
230
- function looksLikeImage(url) {
231
- let pathname = url;
232
- try {
233
- pathname = new URL(url).pathname;
234
- }
235
- catch {
236
- pathname = url.split('?')[0].split('#')[0];
237
- }
238
- const lower = pathname.toLowerCase();
239
- return RASTERIZABLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
245
+ return still;
240
246
  }
241
247
  /**
242
248
  * hasThumbnail reports whether a manifest already carries a default still.
@@ -258,9 +264,10 @@ function hasThumbnail(manifest) {
258
264
  * thumbnails) is what enrichment exists to fetch and is taken as given.
259
265
  */
260
266
  function manifestFor(item, resolved) {
261
- const still = stillFromIndexerSource(item, resolved);
267
+ const still = stillFor(item, resolved);
268
+ const curatorTitle = typeof item.title === 'string' ? item.title.trim() : '';
269
+ const resolvedTitle = typeof resolved.title === 'string' ? resolved.title.trim() : '';
262
270
  let manifest = resolved.inlineManifest;
263
- let rekey = false;
264
271
  if (manifest && still && !hasThumbnail(manifest)) {
265
272
  manifest = {
266
273
  ...manifest,
@@ -269,30 +276,36 @@ function manifestFor(item, resolved) {
269
276
  thumbnails: { default: { uri: still } },
270
277
  },
271
278
  };
272
- rekey = true;
273
279
  }
274
- else if (!manifest && still) {
275
- // Nothing was emitted because the still was the only content the token
276
- // had, and it had been suppressed. A thumbnail-only manifest is worth its
277
- // payload in a way a title-only one is not: it is the difference between
278
- // a grid tile and an empty square.
279
- const synthesizedTitle = typeof item.title === 'string' ? item.title.trim() : '';
280
- manifest = {
281
- refVersion: '1.1.0',
282
- id: derivedManifestId('', `${synthesizedTitle}\n${still}`),
283
- created: MANIFEST_CREATED,
284
- locale: 'en',
285
- metadata: {
286
- ...(synthesizedTitle ? { title: synthesizedTitle } : {}),
287
- thumbnails: { default: { uri: still } },
288
- },
289
- };
280
+ else if (!manifest) {
281
+ // Nothing was emitted because the token had only a title, or only a still
282
+ // that was suppressed against the indexer's source. Either can still be
283
+ // worth a manifest here, for a reason `find` and `build` never meet: their
284
+ // items always carry the token's title, so a title-only manifest would
285
+ // repeat what the item already says. A curator's item may carry no title
286
+ // at all, and the tombstone then shows nothing — so a substantiated title
287
+ // is written when the item lacks one, and a still is written whenever
288
+ // there is one, because it is the difference between a grid tile and an
289
+ // empty square.
290
+ const synthesizedTitle = curatorTitle || resolvedTitle;
291
+ const titleWorthCarrying = !curatorTitle && resolvedTitle.length > 0;
292
+ if (still || titleWorthCarrying) {
293
+ manifest = {
294
+ refVersion: '1.1.0',
295
+ id: derivedManifestId('', `${synthesizedTitle}\n${still}`),
296
+ created: MANIFEST_CREATED,
297
+ locale: 'en',
298
+ metadata: {
299
+ ...(synthesizedTitle ? { title: synthesizedTitle } : {}),
300
+ ...(still ? { thumbnails: { default: { uri: still } } } : {}),
301
+ },
302
+ };
303
+ }
290
304
  }
291
305
  if (!manifest) {
292
306
  return undefined;
293
307
  }
294
308
  const metadata = manifest.metadata ?? {};
295
- const curatorTitle = typeof item.title === 'string' ? item.title.trim() : '';
296
309
  const overrideTitle = curatorTitle.length > 0 && metadata.title !== curatorTitle;
297
310
  // The id is derived from the finished payload every time, not only when this
298
311
  // command changed something. The indexer keys its id on the token
@@ -304,10 +317,6 @@ function manifestFor(item, resolved) {
304
317
  //
305
318
  // The original id is folded in, so the result stays anchored to the token
306
319
  // rather than floating free of it, and stays deterministic across runs.
307
- //
308
- // `rekey` is read only to record that a thumbnail was recovered; the
309
- // derivation below covers it either way.
310
- void rekey;
311
320
  const finalMetadata = overrideTitle ? { ...metadata, title: curatorTitle } : metadata;
312
321
  return {
313
322
  ...manifest,
@@ -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
@@ -14,6 +14,14 @@ const { playlistSigningDidKey } = require('./signing-identity');
14
14
  const { buildInlineManifestForToken } = require('./ref-manifest');
15
15
  /** Default display background that passes DP-1 hex color validation. */
16
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';
17
25
  /**
18
26
  * isTimeBasedMedia reports whether an item's media has an intrinsic runtime
19
27
  * (video or audio) per DP-1 §4.1 "time-based sources".
@@ -497,7 +505,16 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
497
505
  if (!declared.some((curator) => curator && curator.key === key)) {
498
506
  playlist.curators = [...declared, { name: playlistConfig.curatorName || 'ff-cli', key }];
499
507
  }
500
- playlist.signatures = [await signPlaylist(playlist, playlistConfig.privateKey)];
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
+ ];
501
518
  delete playlist.signature;
502
519
  }
503
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
  *
@@ -68,7 +74,9 @@ async function publishPlaylist(filePath, feedServerUrl) {
68
74
  ` Re-sign it as a DP-1 v1.1 envelope:\n` +
69
75
  ` 1. remove the "signature" field\n` +
70
76
  ` 2. declare your key: "curators": [{ "name": "Your name", "key": "<did:key from ff-cli status>" }]\n` +
71
- ` 3. ff-cli sign <file>`,
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".`,
72
80
  };
73
81
  }
74
82
  // Step 3: Verify signature integrity before publishing.
@@ -92,20 +100,81 @@ async function publishPlaylist(filePath, feedServerUrl) {
92
100
  .map((c) => (typeof c?.key === 'string' ? c.key.trim() : ''))
93
101
  .filter((k) => k.length > 0));
94
102
  const signatures = Array.isArray(playlist.signatures)
95
- ? (playlist.signatures ?? [])
103
+ ? (playlist
104
+ .signatures ?? [])
96
105
  : [];
97
106
  const signingKids = signatures
98
107
  .map((sig) => (typeof sig?.kid === 'string' ? sig.kid.trim() : ''))
99
108
  .filter((k) => k.length > 0);
100
- if (signingKids.length > 0 && !signingKids.some((kid) => curatorKeys.has(kid))) {
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) {
101
112
  return {
102
113
  success: false,
103
114
  error: 'Playlist is signed, but the signing key is not declared as a curator.',
104
115
  message: `The feed accepts a publish when a signature's kid appears in the playlist's own curators[].\n` +
105
116
  ` Add this to the playlist before signing:\n` +
106
117
  ` "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.`,
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.`,
109
178
  };
110
179
  }
111
180
  // Step 5: Send validated playlist to feed server.
@@ -63,7 +63,7 @@ Used for signing DP‑1 playlists.
63
63
  `PLAYLIST_PRIVATE_KEY` even when no config file exists yet, and `ff-cli status --key <privateKey>`
64
64
  reports it for an explicit key — the one `sign --key` would use.
65
65
 
66
- - `playlist.role` (string): DP-1 signing role 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`).
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).
67
67
 
68
68
  ### Generate an Ed25519 private key
69
69
 
@@ -88,6 +88,33 @@ Paste the value into `playlist.privateKey`. Any of these are accepted:
88
88
 
89
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`.
90
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
+
91
118
  If you already have a base64 key and want hex, convert it:
92
119
 
93
120
  ```bash
package/docs/EXAMPLES.md CHANGED
@@ -239,7 +239,7 @@ title-only on the FF1 tombstone, and shows empty tiles in the app for every
239
239
  live HTML work. `enrich` repairs it in place.
240
240
 
241
241
  ```bash
242
- ff-cli enrich playlist.json
242
+ ff-cli enrich playlist.json --assume-ethereum
243
243
  ```
244
244
 
245
245
  ```text
@@ -251,25 +251,37 @@ Enrich playlist
251
251
  Output: playlist.json
252
252
  ```
253
253
 
254
+ `--assume-ethereum` is the operator's assertion that the playlist's `evm`
255
+ coordinates are Ethereum. DP-1 records every EVM network as `chain: "evm"`,
256
+ and `find` writes that for Ethereum works too, so an Ethereum playlist needs
257
+ the flag; without it those items are skipped rather than guessed at, because
258
+ the wrong network returns a different artwork's metadata. A Tezos playlist
259
+ needs no flag.
260
+
254
261
  Items the indexer cannot resolve are listed rather than guessed at:
255
262
 
256
263
  ```text
257
264
  Nothing to enrich
258
265
 
259
- Skipped 2:
266
+ Skipped 3:
260
267
  Kim Asendorf — PXL NET — no provenance.contract chain/address/tokenId to look up
261
268
  Untitled — the indexer returned nothing for it
269
+ Pre-Process — chain "evm" names a family; pass --assume-ethereum to assert the network
262
270
  ```
263
271
 
264
272
  Enrichment changes the document, so a signed playlist loses its envelope and
265
273
  has to be re-signed:
266
274
 
267
275
  ```bash
268
- ff-cli enrich playlist.json
276
+ ff-cli enrich playlist.json --assume-ethereum
269
277
  ff-cli sign playlist.json
270
278
  ff-cli play playlist.json -d "living room"
271
279
  ```
272
280
 
281
+ Casting does not care about the signing role. Add `-r curator` to that `sign` if the playlist is also
282
+ going to a feed: `ff-cli publish` requires the owner role, for the reasons in
283
+ [Publish to Feed Server](#publish-to-feed-server).
284
+
273
285
  ## Validate / Sign / Play
274
286
 
275
287
  ```bash
@@ -390,7 +402,17 @@ Publish failed
390
402
  Playlist verification failed: Playlist signature verification failed
391
403
  ```
392
404
 
393
- Run `ff-cli sign <file>` before publishing. (Posting the same document straight to the feed answers
405
+ Declare your key in `curators[]`, then sign it as the owner before publishing:
406
+
407
+ ```bash
408
+ ff-cli status # the did:key to declare
409
+ ff-cli sign <file> -r curator
410
+ ```
411
+
412
+ Plain `ff-cli sign` would use `playlist.role` (default `agent`), producing a document the next two checks
413
+ refuse — under role-aware ownership a declared key counts as an owner only when it signed as `curator`, and
414
+ `ff-cli publish` requires that whether or not the target feed enforces it yet. (Posting the unsigned
415
+ document straight to the feed answers
394
416
  `{"error":"unauthorized","message":"missing authentication: request body must carry signatures"}`.)
395
417
 
396
418
  **Signed, but the signer is not declared as a curator:**
@@ -402,8 +424,12 @@ Publish failed
402
424
  The feed accepts a publish when a signature's kid appears in the playlist's own curators[].
403
425
  Add this to the playlist before signing:
404
426
  "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.
427
+ then sign again from the unsigned file, with the same key you just declared:
428
+ ff-cli sign <file> -r curator --key <private key for did:key:z6Mkv7qJ...>
429
+ Drop --key if that key is your configured one; "sign" uses the configured key otherwise, and a
430
+ signature from an undeclared key would not satisfy the feed. Start from the unsigned file
431
+ because declaring curators[] changes the signed payload: signing appends, so the earlier
432
+ signature would be left covering a document that no longer exists.
407
433
  ```
408
434
 
409
435
  This is the most common publish failure, and it is not about credentials. The feed accepts a create when
@@ -425,8 +451,8 @@ ff-cli status # → Signing identity (did:key) did:key:z6Mk...
425
451
  # "curators": [{ "name": "Your Name", "key": "did:key:z6Mk..." }]
426
452
  # DP-1 requires `name` alongside `key`.
427
453
 
428
- # 3. Sign once, then publish.
429
- ff-cli sign playlist.json
454
+ # 3. Sign once as curator, then publish. The role must match the claim in curators[].
455
+ ff-cli sign playlist.json -r curator
430
456
  ff-cli publish playlist.json
431
457
  ```
432
458
 
@@ -436,6 +462,63 @@ rather than re-signing: remove `signatures`, declare the curator, then sign.
436
462
  `ff-cli publish` checks this before uploading, so a missing declaration fails immediately with the key to
437
463
  add rather than as a server error. No API key is involved: the feed does not accept one.
438
464
 
465
+ **Declared, but signed under a non-owner role:**
466
+
467
+ ```
468
+ Publish failed
469
+ Playlist is signed by a declared curator, but under a non-owner role ("agent").
470
+
471
+ This is ff-cli's check, not a feed's answer. Feeds are moving to role-aware ownership, where
472
+ a key in curators[] counts as an owner only if it also signed as "curator". A feed that
473
+ does not check the role yet will accept this document, and then be unable to authorize a
474
+ replace or a delete for it: both need an owner signature it does not carry. Publishing it is
475
+ what makes that permanent, so ff-cli refuses here instead.
476
+
477
+ curators[] is already correct — only the role is missing, so add that signature to this file:
478
+ ff-cli sign <file> -r curator --key <private key for a declared curator>
479
+ Any key this playlist declares will do — you do not need the one that signed under the wrong
480
+ role. Declared: did:key:z6Mkv7qJ...
481
+ It must be one of those: a curator signature from an undeclared key satisfies neither
482
+ this check nor role-aware ownership, since both read roles only from declared keys. "sign"
483
+ uses the configured key unless --key says otherwise; drop --key if a declared key is already
484
+ your configured one, and confirm which identity a key carries with
485
+ "ff-cli status --key <private key>".
486
+ Signing appends, and the payload hash excludes signatures, so the existing entry stays valid
487
+ and the document ends up carrying both. No unsigned copy is needed: you only have to start
488
+ from one when changing signed content such as curators[] itself.
489
+ ```
490
+
491
+ Declaring a key in `curators[]` is a claim that it owns the playlist; signing as `curator` is the proof.
492
+ This failure means the document is right and the signature is not — the fix is the role, not `curators[]`.
493
+
494
+ **This one is repairable in place**, unlike the two above. Nothing signed needs to change, so appending is
495
+ enough — the payload hash covers the document with `signature`/`signatures` stripped, which leaves the
496
+ earlier entry valid over bytes that did not move:
497
+
498
+ ```bash
499
+ # --key only when the declared curator is not your configured key; `ff-cli status --key <k>`
500
+ # reports which identity a key carries.
501
+ ff-cli sign playlist.json -r curator --key <declared curator's key> # ["agent"] -> ["agent","curator"]
502
+ ff-cli publish playlist.json
503
+ ```
504
+
505
+ The signature has to come from a key the playlist already declares — **any** of them, not specifically the
506
+ one that signed under the wrong role. On a playlist with several curators, whoever holds one of the other
507
+ declared keys can repair it. `sign` uses the configured key unless `--key` says otherwise, so on a machine
508
+ whose configured key is not declared, the plain command appends a `curator` signature the feed ignores and
509
+ the publish fails identically.
510
+
511
+ Start again from an unsigned document only when the fix changes signed content — adding `curators[]`, for
512
+ instance. There the earlier signature would cover a document that no longer exists.
513
+
514
+ **This is a CLI preflight, not a report of what a feed said.** A feed that does not yet check the role
515
+ accepts such a document, so uploading it by hand can still succeed. It is refused here because once
516
+ accepted it can never be replaced **or** deleted: both need an owner signature it does not carry, and the
517
+ only way to add one is to replace the document. Publishing is what makes that permanent.
518
+
519
+ `find` and `build` sign as `curator` automatically, since they declare the key themselves. This applies to
520
+ documents you sign by hand, where `playlist.role` (default `agent`) decides the role.
521
+
439
522
  ## Complete Flow (build → validate → sign → play → publish)
440
523
 
441
524
  ```bash
@@ -448,7 +531,9 @@ npm run dev -- validate playlist.json
448
531
  # 3. Sign it. Declare curators[] BEFORE this step: the signature covers it, and the feed only
449
532
  # accepts a publish when a signature's kid matches a declared curator key.
450
533
  # Run `ff-cli status` for the kid (add -k <key> if you sign with `sign --key`).
451
- npm run dev -- sign playlist.json -o signed.json
534
+ # -r curator: ff-cli publish requires the owner role (role-aware ownership); the kid match is
535
+ # what feeds enforce today.
536
+ npm run dev -- sign playlist.json -r curator -o signed.json
452
537
 
453
538
  # 4. Play it on a device
454
539
  npm run dev -- play signed.json -d "Living Room Display"
@@ -237,28 +237,41 @@ and nothing else. `enrich` is the repair path for those.
237
237
  changing the title would produce two documents claiming to be the same cached
238
238
  manifest. The replacement is derived from the original id and the title, so
239
239
  it is deterministic across runs.
240
+ - The token standard the playlist asserts (`provenance.contract.standard`)
241
+ rides the lookup. The indexer keys ERC-721 and ERC-1155 tokens under
242
+ different CIDs (`eip155:1:erc721:…`, `eip155:1:erc1155:…`) and the client's
243
+ detection, having only an address, answers erc721 for every EVM contract —
244
+ so without the assertion an ERC-1155 work is queried as ERC-721 and never
245
+ found. A value outside the standards the CID can carry falls back to
246
+ detection.
240
247
  - The indexer resolving a token and having something worth attaching are
241
248
  different outcomes: `buildInlineManifestForToken` returns nothing when it has
242
- only a title, since a title-only manifest adds payload without adding
243
- information. Enrichment reports those separately from tokens it could not
244
- resolve at all.
249
+ only a title, since on the items `find` and `build` create a title-only
250
+ manifest repeats what the item already says. A curator's item may carry no
251
+ title at all, and the tombstone then shows nothing, so enrichment does
252
+ synthesize a title-only manifest in that one case — the indexer's title is
253
+ substantiated, and `item.title` itself is still never written. An item that
254
+ has a title and resolves to nothing more is reported as resolved-without-
255
+ metadata, separately from tokens the indexer could not resolve at all.
245
256
  - A still the manifest builder suppressed is recovered against the item's own
246
257
  source. `resolveStillUri` blanks a thumbnail equal to the source it was
247
258
  handed, which is the source the *indexer* chose; for a static work that is
248
259
  often the still itself. When the curator kept a live HTML source instead, the
249
260
  item would otherwise end up with no thumbnail — the empty grid tile this
250
- command exists to remove. When the indexer's source differs from the item's
251
- and is http(s) **and is media the app can rasterize**, it is that suppressed
252
- still
253
- and becomes the thumbnail. The type check matters: `getBestMediaUrl` prefers
254
- `display.animation_url` and media assets over `display.image_url`, so the
255
- indexer's source is frequently a live HTML rendition, and putting one in the
256
- thumbnail slot is worse than leaving it empty — the grid still cannot
257
- rasterize it and the item now claims a still it does not have. The predicate
258
- matches the media types `playlist-builder.js` already recognizes, images plus
259
- SVG and video, and rejects HTML. A
260
- thumbnail-only manifest is emitted where none was, unlike a title-only one:
261
- it is the difference between a tile and an empty square.
261
+ command exists to remove. The lookup therefore carries the still the indexer
262
+ names for the token (`display.image_url`, returned beside the DP-1 item by
263
+ `resolveTokenBatch`), and enrichment applies the same rule against the
264
+ curator's source: when that still differs from the item's source it becomes
265
+ the thumbnail. It is the same field `find` and `build` write into
266
+ `thumbnails.default`, taken on the indexer's word as they take it. Nothing is
267
+ inferred from the indexer's *source*: `getBestMediaUrl` prefers
268
+ `display.animation_url` and media assets over `display.image_url`, so that
269
+ source is frequently a live HTML rendition, and a file-suffix check would
270
+ reject the extensionless CDN URLs the indexer commonly returns as stills
271
+ while `display.mime_type` describes the primary rendition rather than the
272
+ still (a live work reports `text/html` beside a PNG `image_url`). A
273
+ thumbnail-only manifest is emitted where none was: it is the difference
274
+ between a tile and an empty square.
262
275
  - `evm` names a family, and enrichment refuses to guess which member. DP-1 §6
263
276
  carries no network identity, and the indexer maps Ethereum, Polygon,
264
277
  Arbitrum, Optimism, Base, and Zora all back to `evm`, so neither the request
package/docs/README.md CHANGED
@@ -193,8 +193,8 @@ image, video, or SVG source, but not from a live HTML work.
193
193
 
194
194
  ```bash
195
195
  ff-cli enrich playlist.json --assume-ethereum
196
- ff-cli enrich playlist.json -o labelled.json
197
- ff-cli enrich playlist.json --force # replace existing manifests
196
+ ff-cli enrich playlist.json --assume-ethereum -o labelled.json
197
+ ff-cli enrich playlist.json --assume-ethereum --force # replace existing manifests
198
198
  ```
199
199
 
200
200
  `--assume-ethereum` is needed for most playlists. DP-1 records an EVM
@@ -212,9 +212,12 @@ changes the document, so the signature is removed and the playlist must be
212
212
  re-signed before playing:
213
213
 
214
214
  ```bash
215
- ff-cli enrich playlist.json && ff-cli sign playlist.json
215
+ ff-cli enrich playlist.json --assume-ethereum && ff-cli sign playlist.json
216
216
  ```
217
217
 
218
+ That role is fine for casting. Use `ff-cli sign playlist.json -r curator` if the enriched playlist is
219
+ also going to a feed.
220
+
218
221
  ### Validate, sign, and play
219
222
 
220
223
  ```bash
@@ -282,12 +285,30 @@ Configure feed servers in `config.json`:
282
285
  Publishing needs no credentials. The feed authorizes a create from the signatures inside the playlist:
283
286
  it requires a signature whose `kid` matches a key the document declares in `curators[]`.
284
287
 
288
+ `ff-cli publish` additionally requires that signature to carry the **`curator` role**. That is ff-cli's
289
+ check, not every feed's answer today: feeds are moving to role-aware ownership, where being named in
290
+ `curators[]` is a claim and signing as `curator` is the proof. A feed that does not check the role yet
291
+ accepts an `agent`-signed document — and can then authorize neither a replace nor a delete for it, since
292
+ both need an owner signature it does not carry. Publishing is what makes that permanent, so the CLI
293
+ refuses first.
294
+
285
295
  Declare it before signing, not after: a signature covers `curators[]`, so adding the entry to an
286
296
  already-signed playlist invalidates it, and signing again appends rather than replaces. Run
287
297
  `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.
298
+ once **with the owner role**:
299
+
300
+ ```bash
301
+ ff-cli sign playlist.json -r curator
302
+ ```
303
+
304
+ The flag is needed because plain `ff-cli sign` uses `playlist.role`, which defaults to `agent` — a
305
+ document `ff-cli publish` then refuses. Set `"role": "curator"` under `playlist` in `config.json` to make
306
+ that the default for hand-signed playlists.
307
+
308
+ If you sign with `sign --key <privateKey>`, read that key's identity with
309
+ `ff-cli status --key <privateKey>` — the configured identity would be the wrong one to declare. `find`
310
+ and `build` need none of this: they declare the key and sign it as `curator` for you when a signing key
311
+ is configured. An `apiKey` left over in an existing config is ignored.
291
312
 
292
313
  ```json
293
314
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.5.0",
3
+ "version": "2.6.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",
@@ -60,7 +60,7 @@
60
60
  "dependencies": {
61
61
  "@feralfile/source-resolver": "^1.0.0",
62
62
  "@napi-rs/keyring": "1.3.0",
63
- "axios": "^1.6.2",
63
+ "axios": "^1.20.0",
64
64
  "bonjour-service": "^1.3.0",
65
65
  "chalk": "^4.1.2",
66
66
  "commander": "^11.1.0",
@@ -68,7 +68,7 @@
68
68
  "dp1-js": "2.3.1",
69
69
  "ff1": "^1.0.0",
70
70
  "fuzzysort": "^3.1.0",
71
- "viem": "^2.38.2"
71
+ "viem": "^2.56.3"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@types/node": "^24.7.1",