@feralfile/cli 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.json.example +1 -1
- package/dist/package.json +3 -3
- package/dist/src/commands/enrich.js +23 -4
- package/dist/src/commands/find.js +2 -35
- package/dist/src/commands/helpers/playlist-display.js +3 -36
- package/dist/src/commands/publish.js +2 -35
- package/dist/src/commands/ssh.js +1 -34
- package/dist/src/utilities/enrich-playlist.js +88 -79
- package/dist/src/utilities/feed-fetcher.js +1 -0
- package/dist/src/utilities/functions.js +4 -36
- package/dist/src/utilities/index.js +2 -34
- package/dist/src/utilities/nft-indexer.js +219 -40
- package/dist/src/utilities/playlist-builder.js +19 -1
- package/dist/src/utilities/playlist-cast.js +21 -35
- package/dist/src/utilities/playlist-publisher.js +74 -5
- package/dist/src/utilities/playlist-send.js +2 -35
- package/dist/src/utilities/playlist-signer.js +2 -34
- package/dist/src/utilities/playlist-signing-role.js +1 -0
- package/dist/src/utilities/playlist-verifier.js +4 -37
- package/docs/CONFIGURATION.md +29 -2
- package/docs/EXAMPLES.md +94 -9
- package/docs/PROJECT_SPEC.md +38 -15
- package/docs/README.md +27 -6
- package/package.json +3 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
"use strict";
|
|
1
2
|
/**
|
|
2
3
|
* NFT Indexer Client
|
|
3
4
|
* This module provides functions to interact with NFT indexing services
|
|
@@ -48,8 +49,15 @@ const JOB_STATUS_QUERY = `
|
|
|
48
49
|
/**
|
|
49
50
|
* buildTokensListQuery builds the `tokens { items { ... } }` request body used by queryTokens.
|
|
50
51
|
*
|
|
51
|
-
*
|
|
52
|
-
* into `display`; the client does not repeat that merge by fetching raw metadata fields.
|
|
52
|
+
* Presentation comes only from `display` and `media_assets`. The indexer merges metadata and
|
|
53
|
+
* enrichment into `display`; the client does not repeat that merge by fetching raw metadata fields.
|
|
54
|
+
*
|
|
55
|
+
* `chain` and `standard` are selected because the row is the only place either fact is known.
|
|
56
|
+
* `detectTokenStandard` has an address and a chain name and nothing else, so it answers `erc721` for
|
|
57
|
+
* every EVM contract — which wrote `erc721` into playlists for every ERC-1155 token `find` reached by
|
|
58
|
+
* owner or by contract. The indexer stores what the token actually is (`erc721`, `erc1155`, `fa2`)
|
|
59
|
+
* beside its CAIP-2 `chain` (`eip155:1`, `tezos:mainnet`), so the row answers both questions and
|
|
60
|
+
* detection drops to a fallback. Do not remove these two fields from the selection.
|
|
53
61
|
*
|
|
54
62
|
* @param {Object} [params]
|
|
55
63
|
* @param {Array<string>} [params.token_cids]
|
|
@@ -74,6 +82,8 @@ function buildTokensListQuery(params = {}) {
|
|
|
74
82
|
token_number
|
|
75
83
|
current_owner
|
|
76
84
|
burned
|
|
85
|
+
chain
|
|
86
|
+
standard
|
|
77
87
|
display {
|
|
78
88
|
name
|
|
79
89
|
description
|
|
@@ -106,10 +116,18 @@ function initializeIndexer(_config) {
|
|
|
106
116
|
logger.debug('[NFT Indexer] Using endpoint:', GRAPHQL_ENDPOINT);
|
|
107
117
|
}
|
|
108
118
|
/**
|
|
109
|
-
*
|
|
119
|
+
* detectTokenStandard guesses a token standard from a chain name and an address.
|
|
120
|
+
*
|
|
121
|
+
* This is the last-resort fallback, not the answer. It sees no token — only a
|
|
122
|
+
* chain name and an address format — so it cannot tell ERC-721 from ERC-1155
|
|
123
|
+
* and answers `erc721` for every EVM contract. It is still needed in two
|
|
124
|
+
* places: a lookup by coordinate has to build *some* CID before the indexer has
|
|
125
|
+
* been asked anything (see buildTokenCID), and a token row that arrived without
|
|
126
|
+
* a `standard` field has nothing better to offer.
|
|
110
127
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
128
|
+
* Prefer, in order: the indexer row's own `standard`, a caller's assertion, and
|
|
129
|
+
* only then this. On-chain detection is deliberately not attempted here — the
|
|
130
|
+
* indexer already stores the answer and selecting it costs nothing.
|
|
113
131
|
*
|
|
114
132
|
* @param {string} chain - Blockchain network
|
|
115
133
|
* @param {string} contractAddress - Contract address
|
|
@@ -121,13 +139,122 @@ function detectTokenStandard(chain, contractAddress) {
|
|
|
121
139
|
if (lowerChain === 'tezos' || contractAddress.startsWith('KT')) {
|
|
122
140
|
return 'fa2';
|
|
123
141
|
}
|
|
124
|
-
// Ethereum
|
|
125
|
-
//
|
|
142
|
+
// Ethereum: erc721 is a guess, not a reading. Only the indexer row or a
|
|
143
|
+
// caller's assertion can distinguish an ERC-1155 token from an ERC-721 one.
|
|
126
144
|
if (lowerChain === 'ethereum') {
|
|
127
145
|
return 'erc721';
|
|
128
146
|
}
|
|
129
147
|
return 'other';
|
|
130
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Token standards the indexer keys CIDs on. A standard outside this set —
|
|
151
|
+
* asserted by a caller or arriving on a row — says nothing the CID can carry,
|
|
152
|
+
* so detection decides instead.
|
|
153
|
+
*/
|
|
154
|
+
const CID_STANDARDS = new Set(['erc721', 'erc1155', 'fa2']);
|
|
155
|
+
/**
|
|
156
|
+
* normalizeCidStandard returns a standard the CID format can carry, or ''.
|
|
157
|
+
*
|
|
158
|
+
* Both the indexer row and a caller's assertion are untrusted strings from
|
|
159
|
+
* outside this module ('ERC1155', ' fa2 ', 'ERC-1155', undefined). Anything not
|
|
160
|
+
* in CID_STANDARDS is treated as absent rather than passed through, so a
|
|
161
|
+
* misspelled standard never reaches a CID or a DP-1 `provenance.contract`.
|
|
162
|
+
*
|
|
163
|
+
* @param {unknown} value - Candidate standard
|
|
164
|
+
* @returns {string} Normalized standard, or '' when it is not one we can use
|
|
165
|
+
*/
|
|
166
|
+
function normalizeCidStandard(value) {
|
|
167
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
168
|
+
return CID_STANDARDS.has(normalized) ? normalized : '';
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* resolveTokenStandard picks the standard a CID is built with.
|
|
172
|
+
*
|
|
173
|
+
* detectTokenStandard cannot tell ERC-721 from ERC-1155 — it has only a chain
|
|
174
|
+
* and an address, and answers erc721 for every EVM contract. The indexer keys
|
|
175
|
+
* the two differently (`eip155:1:erc721:…` and `eip155:1:erc1155:…`), so an
|
|
176
|
+
* ERC-1155 token looked up by detection alone is simply never found. When the
|
|
177
|
+
* caller knows the standard — a DP-1 playlist carries it in
|
|
178
|
+
* `provenance.contract.standard` — that assertion wins.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} chain - Blockchain network
|
|
181
|
+
* @param {string} contractAddress - Contract address
|
|
182
|
+
* @param {string} [asserted] - Standard the caller vouches for, if any
|
|
183
|
+
* @returns {string} Token standard for the CID
|
|
184
|
+
*/
|
|
185
|
+
function resolveTokenStandard(chain, contractAddress, asserted) {
|
|
186
|
+
return normalizeCidStandard(asserted) || detectTokenStandard(chain, contractAddress);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* CAIP-2 namespaces mapped to the chain values DP-1 `provenance.contract` allows.
|
|
190
|
+
*
|
|
191
|
+
* The indexer names a token's chain in CAIP-2 (`eip155:1`, `tezos:mainnet`),
|
|
192
|
+
* which is authoritative: it comes from the row rather than from the caller's
|
|
193
|
+
* guess at which network an address belongs to. Namespaces outside this map
|
|
194
|
+
* have no DP-1 equivalent, so they fall back to the caller's chain name.
|
|
195
|
+
*/
|
|
196
|
+
const CAIP2_NAMESPACE_TO_DP1_CHAIN = {
|
|
197
|
+
eip155: 'evm',
|
|
198
|
+
tezos: 'tezos',
|
|
199
|
+
bitmark: 'bitmark',
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* dp1ChainFromCaip2 derives a DP-1 chain from a CAIP-2 chain id.
|
|
203
|
+
*
|
|
204
|
+
* @param {unknown} caip2Chain - CAIP-2 chain id from an indexer row, e.g. `eip155:1`
|
|
205
|
+
* @returns {string} DP-1 chain (`evm`, `tezos`, `bitmark`), or '' when unknown or absent
|
|
206
|
+
*/
|
|
207
|
+
function dp1ChainFromCaip2(caip2Chain) {
|
|
208
|
+
if (typeof caip2Chain !== 'string') {
|
|
209
|
+
return '';
|
|
210
|
+
}
|
|
211
|
+
const namespace = caip2Chain.trim().toLowerCase().split(':')[0];
|
|
212
|
+
return CAIP2_NAMESPACE_TO_DP1_CHAIN[namespace] || '';
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Client chain names mapped to the chain values DP-1 `provenance.contract` allows.
|
|
216
|
+
*
|
|
217
|
+
* This is for DP-1 provenance output, NOT for indexer queries: the indexer uses
|
|
218
|
+
* `eth`/`tez`/`bmk` while DP-1 uses `evm`/`tezos`/`bitmark`. It answers only for
|
|
219
|
+
* tokens that reached us without an indexer row — a caller-supplied coordinate.
|
|
220
|
+
*/
|
|
221
|
+
const CHAIN_NAME_TO_DP1_CHAIN = {
|
|
222
|
+
ethereum: 'evm',
|
|
223
|
+
polygon: 'evm',
|
|
224
|
+
arbitrum: 'evm',
|
|
225
|
+
optimism: 'evm',
|
|
226
|
+
base: 'evm',
|
|
227
|
+
zora: 'evm',
|
|
228
|
+
tezos: 'tezos', // DP1 spec uses 'tezos', not 'tez'
|
|
229
|
+
bitmark: 'bitmark', // DP1 spec uses 'bitmark', not 'bmk'
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* resolveDp1Chain picks the DP-1 chain for a token's `provenance.contract`.
|
|
233
|
+
*
|
|
234
|
+
* The row's CAIP-2 chain and the caller's chain name are not two guesses to try
|
|
235
|
+
* in turn — they are different claims, and the row's is the network the indexer
|
|
236
|
+
* recorded. So a token carrying a row chain is answered from that chain alone:
|
|
237
|
+
* a namespace with no DP-1 equivalent becomes `other`, never the caller's name.
|
|
238
|
+
* Falling through would be actively wrong, because the owner and contract paths
|
|
239
|
+
* label every non-KT address `ethereum` before mapping — a Solana row would
|
|
240
|
+
* inherit that label and be published as an EVM contract. `other` is a true
|
|
241
|
+
* statement about a chain DP-1 cannot name; `evm` is a false one.
|
|
242
|
+
*
|
|
243
|
+
* The name map answers only when the token has no row chain at all, which is a
|
|
244
|
+
* token assembled from a caller-supplied coordinate rather than an indexer row.
|
|
245
|
+
*
|
|
246
|
+
* @param {Object} token - Token in the internal standard format
|
|
247
|
+
* @param {string} [token.caip2Chain] - CAIP-2 chain from the indexer row, when it came from one
|
|
248
|
+
* @param {string} [token.chain] - Client chain name the caller inferred
|
|
249
|
+
* @returns {string} DP-1 chain (`evm`, `tezos`, `bitmark`, or `other`)
|
|
250
|
+
*/
|
|
251
|
+
function resolveDp1Chain(token) {
|
|
252
|
+
const rowChain = typeof token.caip2Chain === 'string' ? token.caip2Chain.trim() : '';
|
|
253
|
+
if (rowChain) {
|
|
254
|
+
return dp1ChainFromCaip2(rowChain) || 'other';
|
|
255
|
+
}
|
|
256
|
+
return CHAIN_NAME_TO_DP1_CHAIN[String(token.chain || '').toLowerCase()] || 'other';
|
|
257
|
+
}
|
|
131
258
|
/**
|
|
132
259
|
* Build CAIP-2 token CID for indexer v2
|
|
133
260
|
*
|
|
@@ -137,15 +264,19 @@ function detectTokenStandard(chain, contractAddress) {
|
|
|
137
264
|
* @param {string} chain - Blockchain network (ethereum, polygon, tezos, etc)
|
|
138
265
|
* @param {string} contractAddress - Contract address
|
|
139
266
|
* @param {string} tokenId - Token ID
|
|
267
|
+
* @param {string} [standard] - Asserted token standard; detected when omitted
|
|
140
268
|
* @returns {string} Token CID in CAIP-2 format
|
|
141
269
|
* @example
|
|
142
270
|
* // Returns: eip155:1:erc721:0xabc123:456
|
|
143
271
|
* const cid = buildTokenCID('ethereum', '0xabc123', '456');
|
|
144
272
|
* @example
|
|
273
|
+
* // Returns: eip155:1:erc1155:0xabc123:456
|
|
274
|
+
* const cid = buildTokenCID('ethereum', '0xabc123', '456', 'erc1155');
|
|
275
|
+
* @example
|
|
145
276
|
* // Returns: tezos:mainnet:fa2:KT1abc:789
|
|
146
277
|
* const cid = buildTokenCID('tezos', 'KT1abc', '789');
|
|
147
278
|
*/
|
|
148
|
-
function buildTokenCID(chain, contractAddress, tokenId) {
|
|
279
|
+
function buildTokenCID(chain, contractAddress, tokenId, standard) {
|
|
149
280
|
// Map chain names to CAIP-2 format (supports only Ethereum and Tezos)
|
|
150
281
|
const caip2Map = {
|
|
151
282
|
ethereum: 'eip155:1',
|
|
@@ -157,8 +288,8 @@ function buildTokenCID(chain, contractAddress, tokenId) {
|
|
|
157
288
|
if (!caip2Chain) {
|
|
158
289
|
throw new Error(`Unsupported chain: ${chain}. Only ethereum and tezos are supported.`);
|
|
159
290
|
}
|
|
160
|
-
const
|
|
161
|
-
return `${caip2Chain}:${
|
|
291
|
+
const resolvedStandard = resolveTokenStandard(chain, contractAddress, standard);
|
|
292
|
+
return `${caip2Chain}:${resolvedStandard}:${contractAddress}:${tokenId}`;
|
|
162
293
|
}
|
|
163
294
|
/**
|
|
164
295
|
* Unified GraphQL query for tokens from indexer v2
|
|
@@ -317,11 +448,19 @@ function getBestMediaUrl(display = {}, mediaAssets = []) {
|
|
|
317
448
|
* is null or partial, we only use what is present (for example `Token #${token_number}` for name);
|
|
318
449
|
* we do not splice in raw `metadata` or enrichment because those are not selected from GraphQL.
|
|
319
450
|
*
|
|
451
|
+
* The row's own `standard` and `chain` outrank both the asserted `standard` and the `chain`
|
|
452
|
+
* argument, because they are the token's identity as the indexer recorded it while the arguments are
|
|
453
|
+
* the caller's inference — `find` by owner or by contract only knows "the address starts with 0x, so
|
|
454
|
+
* ethereum". Precedence for the standard is row → asserted → detection; the CAIP-2 `chain` is kept
|
|
455
|
+
* beside the caller's chain name (not in place of it) as `caip2Chain`, because the name still selects
|
|
456
|
+
* the CID prefix and the detection fallback while the CAIP-2 value answers DP-1's chain enum.
|
|
457
|
+
*
|
|
320
458
|
* @param {Object} indexerData - Token fields from indexer GraphQL
|
|
321
|
-
* @param {string} chain - Blockchain network
|
|
459
|
+
* @param {string} chain - Blockchain network the caller inferred
|
|
460
|
+
* @param {string} [standard] - Asserted token standard; used when the row carries none
|
|
322
461
|
* @returns {Object} Standardized token data
|
|
323
462
|
*/
|
|
324
|
-
function mapIndexerDataToStandardFormat(indexerData, chain) {
|
|
463
|
+
function mapIndexerDataToStandardFormat(indexerData, chain, standard) {
|
|
325
464
|
if (!indexerData) {
|
|
326
465
|
return {
|
|
327
466
|
success: false,
|
|
@@ -334,14 +473,17 @@ function mapIndexerDataToStandardFormat(indexerData, chain) {
|
|
|
334
473
|
const artistName = extractArtistName(display.artists);
|
|
335
474
|
const name = display.name || `Token #${indexerData.token_number}`;
|
|
336
475
|
const description = display.description || '';
|
|
337
|
-
const
|
|
476
|
+
const resolvedStandard = normalizeCidStandard(indexerData.standard) ||
|
|
477
|
+
resolveTokenStandard(chain, indexerData.contract_address, standard);
|
|
478
|
+
const caip2Chain = typeof indexerData.chain === 'string' ? indexerData.chain : '';
|
|
338
479
|
return {
|
|
339
480
|
success: true,
|
|
340
481
|
token: {
|
|
341
482
|
chain,
|
|
483
|
+
caip2Chain,
|
|
342
484
|
contractAddress: indexerData.contract_address,
|
|
343
485
|
tokenId: indexerData.token_number,
|
|
344
|
-
standard,
|
|
486
|
+
standard: resolvedStandard,
|
|
345
487
|
name,
|
|
346
488
|
description,
|
|
347
489
|
image: {
|
|
@@ -438,26 +580,13 @@ function convertToDP1Item(tokenData, duration) {
|
|
|
438
580
|
error: `Source URL too long (${sourceUrl.length} chars, max 1024)`,
|
|
439
581
|
};
|
|
440
582
|
}
|
|
441
|
-
// Map chain name to DP1 format (according to DP1 spec)
|
|
442
|
-
// NOTE: This is for DP1 provenance output, NOT for indexer queries
|
|
443
|
-
// The indexer uses 'eth'/'tez'/'bmk', but DP1 spec uses 'evm'/'tezos'/'bitmark'
|
|
444
|
-
const chainMap = {
|
|
445
|
-
ethereum: 'evm',
|
|
446
|
-
polygon: 'evm',
|
|
447
|
-
arbitrum: 'evm',
|
|
448
|
-
optimism: 'evm',
|
|
449
|
-
base: 'evm',
|
|
450
|
-
zora: 'evm',
|
|
451
|
-
tezos: 'tezos', // DP1 spec uses 'tezos', not 'tez'
|
|
452
|
-
bitmark: 'bitmark', // DP1 spec uses 'bitmark', not 'bmk'
|
|
453
|
-
};
|
|
454
583
|
// Build via dp1-js PlaylistItemBuilder so leaf blocks match DP-1 AJV schema.
|
|
455
584
|
const itemBuilder = new PlaylistItemBuilder()
|
|
456
585
|
.id(itemId)
|
|
457
586
|
.source(sourceUrl)
|
|
458
587
|
.license('open')
|
|
459
588
|
.provenance(new ProvenanceBuilder().type('onChain').contract(new ContractBuilder()
|
|
460
|
-
.chain(
|
|
589
|
+
.chain(resolveDp1Chain(token))
|
|
461
590
|
.standard(token.standard || detectTokenStandard(token.chain, token.contractAddress))
|
|
462
591
|
.address(token.contractAddress)
|
|
463
592
|
.tokenId(String(token.tokenId))));
|
|
@@ -492,8 +621,34 @@ function convertToDP1Item(tokenData, duration) {
|
|
|
492
621
|
return {
|
|
493
622
|
success: true,
|
|
494
623
|
item: dp1Item,
|
|
624
|
+
still: stillOf(token),
|
|
495
625
|
};
|
|
496
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* stillOf names the token's still image as the indexer identifies it, or ''.
|
|
629
|
+
*
|
|
630
|
+
* This is `display.image_url`, the field the indexer reserves for a still and
|
|
631
|
+
* the same one `buildInlineManifestForToken` writes into `thumbnails.default`.
|
|
632
|
+
* It is returned beside the DP-1 item rather than inside it because the item
|
|
633
|
+
* only carries the still when it differs from the item's own source, and a
|
|
634
|
+
* consumer building a manifest against a *different* source (see
|
|
635
|
+
* enrich-playlist.ts) needs the still regardless. Nothing about the URL's
|
|
636
|
+
* shape is inspected: the indexer's word that it is an image is the evidence,
|
|
637
|
+
* and a suffix check would reject the extensionless CDN URLs it commonly
|
|
638
|
+
* returns.
|
|
639
|
+
*
|
|
640
|
+
* @param {Object} token - Token in the internal standard format
|
|
641
|
+
* @returns {string} http(s) still URL, or '' when the indexer named none
|
|
642
|
+
*/
|
|
643
|
+
function stillOf(token) {
|
|
644
|
+
const image = token.image;
|
|
645
|
+
// Only `thumbnail` qualifies. `image.url` is the media getBestMediaUrl
|
|
646
|
+
// selected as the source, which is the live rendition whenever one exists —
|
|
647
|
+
// exactly what must never land in a thumbnail slot.
|
|
648
|
+
const candidate = typeof image === 'string' ? image : typeof image === 'object' && image ? image.thumbnail : '';
|
|
649
|
+
const trimmed = String(candidate || '').trim();
|
|
650
|
+
return trimmed.startsWith('http://') || trimmed.startsWith('https://') ? trimmed : '';
|
|
651
|
+
}
|
|
497
652
|
/**
|
|
498
653
|
* Get NFT token information from indexer and return as DP1 item (supports single or batch)
|
|
499
654
|
* @param {Object|Array} params - Token parameters (single object or array)
|
|
@@ -507,8 +662,8 @@ async function getNFTTokenInfo(params) {
|
|
|
507
662
|
return await getNFTTokenInfoBatch(params.tokens, duration);
|
|
508
663
|
}
|
|
509
664
|
// Handle single token
|
|
510
|
-
const { chain, contractAddress, tokenId } = params;
|
|
511
|
-
const result = await getNFTTokenInfoSingle({ chain, contractAddress, tokenId }, duration);
|
|
665
|
+
const { chain, contractAddress, tokenId, standard } = params;
|
|
666
|
+
const result = await getNFTTokenInfoSingle({ chain, contractAddress, tokenId, standard }, duration);
|
|
512
667
|
return result;
|
|
513
668
|
}
|
|
514
669
|
/**
|
|
@@ -530,7 +685,7 @@ async function getNFTTokenInfo(params) {
|
|
|
530
685
|
*/
|
|
531
686
|
async function getNFTTokenInfoSingle(params, duration, options = {}) {
|
|
532
687
|
let chain = params.chain;
|
|
533
|
-
const { contractAddress, tokenId } = params;
|
|
688
|
+
const { contractAddress, tokenId, standard } = params;
|
|
534
689
|
// DEFENSIVE: Auto-detect and correct chain based on contract address format
|
|
535
690
|
if (contractAddress.startsWith('KT') && chain !== 'tezos') {
|
|
536
691
|
logger.warn(`[NFT Indexer] Chain mismatch detected! Contract ${contractAddress} starts with KT but chain="${chain}". Auto-correcting to "tezos".`);
|
|
@@ -547,7 +702,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
|
|
|
547
702
|
});
|
|
548
703
|
try {
|
|
549
704
|
// Build token CID in CAIP-2 format
|
|
550
|
-
const tokenCID = buildTokenCID(chain, contractAddress, tokenId);
|
|
705
|
+
const tokenCID = buildTokenCID(chain, contractAddress, tokenId, standard);
|
|
551
706
|
logger.info(`[NFT Indexer] Built token CID: ${tokenCID}`);
|
|
552
707
|
// Query the indexer
|
|
553
708
|
logger.info(`[NFT Indexer] Querying indexer GraphQL for token...`);
|
|
@@ -556,7 +711,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
|
|
|
556
711
|
if (!indexerData) {
|
|
557
712
|
logger.info(`[NFT Indexer] Token not in database, triggering async indexing...`);
|
|
558
713
|
// Trigger background indexing workflow
|
|
559
|
-
const indexResult = await triggerIndexingAsync(chain, contractAddress, tokenId);
|
|
714
|
+
const indexResult = await triggerIndexingAsync(chain, contractAddress, tokenId, standard);
|
|
560
715
|
if (!indexResult.success) {
|
|
561
716
|
logger.debug(`[NFT Indexer] Failed to trigger indexing:`, indexResult.error);
|
|
562
717
|
return {
|
|
@@ -609,7 +764,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
|
|
|
609
764
|
}
|
|
610
765
|
}
|
|
611
766
|
// Map to standard format and convert to DP1
|
|
612
|
-
const tokenData = mapIndexerDataToStandardFormat(indexerData, chain);
|
|
767
|
+
const tokenData = mapIndexerDataToStandardFormat(indexerData, chain, standard);
|
|
613
768
|
return convertToDP1Item(tokenData, duration);
|
|
614
769
|
}
|
|
615
770
|
catch (error) {
|
|
@@ -621,15 +776,21 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
|
|
|
621
776
|
}
|
|
622
777
|
}
|
|
623
778
|
/**
|
|
624
|
-
*
|
|
779
|
+
* Resolve a batch of tokens and return every outcome (parallel processing)
|
|
625
780
|
*
|
|
626
781
|
* For missing tokens: triggers indexing per token, polls by job_id, then fetches again.
|
|
627
782
|
*
|
|
628
|
-
*
|
|
783
|
+
* Unlike getNFTTokenInfoBatch this keeps one result per input token, in input
|
|
784
|
+
* order: a resolved token as `{ success: true, item, still }`, a failed one as
|
|
785
|
+
* `{ success: false, error, token }`. Callers that must know which request an
|
|
786
|
+
* answer belongs to, or that need the still beside the item, use this.
|
|
787
|
+
*
|
|
788
|
+
* @param {Array} tokens - Array of token parameters (chain, contractAddress, tokenId, standard?)
|
|
629
789
|
* @param {number} duration - Display duration in seconds
|
|
630
|
-
* @
|
|
790
|
+
* @param {Function} [onProgress] - Called with (done, total) after each batch
|
|
791
|
+
* @returns {Promise<Array>} One result per input token
|
|
631
792
|
*/
|
|
632
|
-
async function
|
|
793
|
+
async function resolveTokenBatch(tokens, duration, onProgress) {
|
|
633
794
|
logger.info(`[NFT Indexer] 📦 Starting batch processing for ${tokens.length} token(s)...`);
|
|
634
795
|
logger.debug('[NFT Indexer] Batch tokens:', tokens);
|
|
635
796
|
const results = [];
|
|
@@ -669,6 +830,23 @@ async function getNFTTokenInfoBatch(tokens, duration, onProgress) {
|
|
|
669
830
|
const successCount = results.filter((r) => r.success && r.item).length;
|
|
670
831
|
const failedCount = results.length - successCount;
|
|
671
832
|
logger.info(`[NFT Indexer] Final: ${successCount} items with data, ${failedCount} without`);
|
|
833
|
+
return results;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Get NFT token information in batch and return as DP1 items (parallel processing)
|
|
837
|
+
*
|
|
838
|
+
* For missing tokens: triggers indexing per token, polls by job_id, then fetches again.
|
|
839
|
+
*
|
|
840
|
+
* Returns only the resolved items. Failures are dropped, not represented, so
|
|
841
|
+
* the result is neither aligned with nor as long as the input; a caller that
|
|
842
|
+
* needs the association uses resolveTokenBatch.
|
|
843
|
+
*
|
|
844
|
+
* @param {Array} tokens - Array of token parameters
|
|
845
|
+
* @param {number} duration - Display duration in seconds
|
|
846
|
+
* @returns {Promise<Array>} Array of DP1 items
|
|
847
|
+
*/
|
|
848
|
+
async function getNFTTokenInfoBatch(tokens, duration, onProgress) {
|
|
849
|
+
const results = await resolveTokenBatch(tokens, duration, onProgress);
|
|
672
850
|
// Return only items (not error objects)
|
|
673
851
|
const items = results.filter((r) => r.success && r.item).map((r) => r.item);
|
|
674
852
|
logger.info(`[NFT Indexer] Returning ${items.length} items`);
|
|
@@ -719,10 +897,10 @@ async function getCollectionInfo(params) {
|
|
|
719
897
|
* @returns {number} [returns.job_id] - Postgres queue job id; use with jobStatus / pollForJobCompletion
|
|
720
898
|
* @returns {string} [returns.error] - Error message if failed
|
|
721
899
|
*/
|
|
722
|
-
async function triggerIndexingAsync(chain, contractAddress, tokenId) {
|
|
900
|
+
async function triggerIndexingAsync(chain, contractAddress, tokenId, standard) {
|
|
723
901
|
try {
|
|
724
902
|
// Build token CID
|
|
725
|
-
const tokenCID = buildTokenCID(chain, contractAddress, tokenId);
|
|
903
|
+
const tokenCID = buildTokenCID(chain, contractAddress, tokenId, standard);
|
|
726
904
|
logger.debug('[NFT Indexer] Triggering token indexing job via GraphQL mutation:', {
|
|
727
905
|
tokenCID,
|
|
728
906
|
});
|
|
@@ -1050,6 +1228,7 @@ module.exports = {
|
|
|
1050
1228
|
getNFTTokenInfo,
|
|
1051
1229
|
// Batch processing
|
|
1052
1230
|
getNFTTokenInfoBatch,
|
|
1231
|
+
resolveTokenBatch,
|
|
1053
1232
|
// Single token processing
|
|
1054
1233
|
getNFTTokenInfoSingle,
|
|
1055
1234
|
// Additional functions
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
"use strict";
|
|
1
2
|
/**
|
|
2
3
|
* Playlist Builder Utilities
|
|
3
4
|
*
|
|
@@ -14,6 +15,14 @@ const { playlistSigningDidKey } = require('./signing-identity');
|
|
|
14
15
|
const { buildInlineManifestForToken } = require('./ref-manifest');
|
|
15
16
|
/** Default display background that passes DP-1 hex color validation. */
|
|
16
17
|
const DEFAULT_BACKGROUND = '#111111';
|
|
18
|
+
/**
|
|
19
|
+
* DP-1 signature role that carries ownership of a playlist.
|
|
20
|
+
*
|
|
21
|
+
* The feed derives a playlist's owners from `curators[]` and then requires one of those keys to have
|
|
22
|
+
* signed as `curator`: being named is a claim, signing in this role is the proof. Playlist groups use
|
|
23
|
+
* the same role; channels use `publisher`, which ff-cli does not publish.
|
|
24
|
+
*/
|
|
25
|
+
const PLAYLIST_OWNER_ROLE = 'curator';
|
|
17
26
|
/**
|
|
18
27
|
* isTimeBasedMedia reports whether an item's media has an intrinsic runtime
|
|
19
28
|
* (video or audio) per DP-1 §4.1 "time-based sources".
|
|
@@ -497,7 +506,16 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
|
|
|
497
506
|
if (!declared.some((curator) => curator && curator.key === key)) {
|
|
498
507
|
playlist.curators = [...declared, { name: playlistConfig.curatorName || 'ff-cli', key }];
|
|
499
508
|
}
|
|
500
|
-
|
|
509
|
+
// Sign in the `curator` role, not the configured one. Declaring the key in curators[] is the
|
|
510
|
+
// document claiming that key is an owner; the feed treats a declared key as an owner only when the
|
|
511
|
+
// same key also signed in the owner role, so any other role leaves the document declared, verified
|
|
512
|
+
// and unpublishable — the exact shape that made every ff-cli playlist fail before (#107). The role
|
|
513
|
+
// has to follow the claim the builder just wrote. `playlist.role` still governs `ff-cli sign`,
|
|
514
|
+
// where the user chooses what claim to make; to build under a different role, build without a
|
|
515
|
+
// configured key and sign the result explicitly.
|
|
516
|
+
playlist.signatures = [
|
|
517
|
+
await signPlaylist(playlist, playlistConfig.privateKey, PLAYLIST_OWNER_ROLE),
|
|
518
|
+
];
|
|
501
519
|
delete playlist.signature;
|
|
502
520
|
}
|
|
503
521
|
}
|
|
@@ -1,39 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
3
|
exports.castPlaylist = castPlaylist;
|
|
4
|
+
/**
|
|
5
|
+
* DP-1 signature role used when signing a playlist for playback.
|
|
6
|
+
*
|
|
7
|
+
* A cast never passes through a feed, so a document signed here carries no `feed` signature and its own
|
|
8
|
+
* entry is the only one a player could judge. DP-1 §7.1.1 rule 1 has players verify a `feed` or `curator`
|
|
9
|
+
* signature, so the configured role (shipped default `agent`) is the weakest choice available for a
|
|
10
|
+
* document this CLI is minting itself.
|
|
11
|
+
*
|
|
12
|
+
* Scope: this governs only the envelope the CLI *creates* — direct media, which arrives unsigned and must
|
|
13
|
+
* be signed before delivery. A playlist that already carries signatures is cast verbatim whatever its
|
|
14
|
+
* roles, because rewriting someone else's envelope is not this function's business. No player in this
|
|
15
|
+
* ecosystem enforces rule 1 today (ff-player performs no signature verification at all), so this is about
|
|
16
|
+
* not minting the weakest document we could, rather than about a failure observed on a device.
|
|
17
|
+
*
|
|
18
|
+
* This also has to match what the builder wrote: a wrapped media URL arrives here already declaring the
|
|
19
|
+
* signing key in `curators[]` and signed as `curator`, and signing REPLACES `signatures[]` rather than
|
|
20
|
+
* appending — so any other role here silently downgrades that envelope on its way to the device.
|
|
21
|
+
*/
|
|
22
|
+
const PLAYBACK_SIGNING_ROLE = 'curator';
|
|
37
23
|
// playlist-signer and ff1-device are still CommonJS; require keeps interop simple.
|
|
38
24
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
25
|
const { signPlaylist } = require('./playlist-signer');
|
|
@@ -68,7 +54,7 @@ async function castPlaylist(playlist, options = {}) {
|
|
|
68
54
|
};
|
|
69
55
|
}
|
|
70
56
|
try {
|
|
71
|
-
const signature = await signPlaylist(current, options.signingKey);
|
|
57
|
+
const signature = await signPlaylist(current, options.signingKey, PLAYBACK_SIGNING_ROLE);
|
|
72
58
|
current = {
|
|
73
59
|
...current,
|
|
74
60
|
signature: undefined,
|
|
@@ -87,7 +73,7 @@ async function castPlaylist(playlist, options = {}) {
|
|
|
87
73
|
};
|
|
88
74
|
}
|
|
89
75
|
}
|
|
90
|
-
const { verifyPlaylist } = await
|
|
76
|
+
const { verifyPlaylist } = await import('./playlist-verifier.js');
|
|
91
77
|
const verifyResult = await verifyPlaylist(current);
|
|
92
78
|
if (!verifyResult.valid) {
|
|
93
79
|
return {
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
108
|
-
`
|
|
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.
|