@feralfile/cli 2.2.3 → 2.4.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.
@@ -0,0 +1,459 @@
1
+ "use strict";
2
+ /**
3
+ * Fills in missing inline Ref Manifests on an existing DP-1 playlist.
4
+ *
5
+ * `find` and `build` attach an `inlineManifest` to every item they create, so
6
+ * playlists this CLI authors end to end are already labelled. Playlists that
7
+ * arrive any other way are not: items copied out of another playlist, items
8
+ * hand-written against a known contract, items inherited from a document
9
+ * predating §3.6. Those carry a `source` and a `provenance` block and nothing
10
+ * else, and there was no way to repair them short of editing JSON by hand.
11
+ *
12
+ * What the gap costs is visible on a device. The FF1 tombstone resolves its
13
+ * label through `ref` -> `inlineManifest` -> `item.metadata` and falls back to
14
+ * the item title, so an unlabelled item shows a title with no artist line. The
15
+ * app's grid is worse: it can rasterize a thumbnail from a direct image, video,
16
+ * or SVG source, but not from a live HTML work, so every generative item in an
17
+ * unlabelled playlist renders as an empty tile. A playlist of computational art
18
+ * is mostly live HTML, which is exactly the case that looks broken.
19
+ *
20
+ * Enrichment is additive and narrow on purpose. It writes `inlineManifest` and
21
+ * nothing else: `source`, `duration`, `id`, and `display` are the curator's
22
+ * decisions and are left exactly as found, even when the indexer disagrees. An
23
+ * item the indexer cannot resolve is reported, never guessed at — a fabricated
24
+ * artist line in a signed document is worse than a missing one.
25
+ */
26
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ var desc = Object.getOwnPropertyDescriptor(m, k);
29
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
30
+ desc = { enumerable: true, get: function() { return m[k]; } };
31
+ }
32
+ Object.defineProperty(o, k2, desc);
33
+ }) : (function(o, m, k, k2) {
34
+ if (k2 === undefined) k2 = k;
35
+ o[k2] = m[k];
36
+ }));
37
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
38
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
39
+ }) : function(o, v) {
40
+ o["default"] = v;
41
+ });
42
+ var __importStar = (this && this.__importStar) || (function () {
43
+ var ownKeys = function(o) {
44
+ ownKeys = Object.getOwnPropertyNames || function (o) {
45
+ var ar = [];
46
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
47
+ return ar;
48
+ };
49
+ return ownKeys(o);
50
+ };
51
+ return function (mod) {
52
+ if (mod && mod.__esModule) return mod;
53
+ var result = {};
54
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
55
+ __setModuleDefault(result, mod);
56
+ return result;
57
+ };
58
+ })();
59
+ Object.defineProperty(exports, "__esModule", { value: true });
60
+ exports.enrichPlaylistManifests = enrichPlaylistManifests;
61
+ const node_crypto_1 = require("node:crypto");
62
+ const logger = __importStar(require("../logger"));
63
+ /**
64
+ * CHAIN_ALIASES translates a DP-1 chain name into the indexer's, outbound.
65
+ *
66
+ * DP-1 names the EVM family `evm` (core spec §6: "evm" | "tezos" | "bitmark" |
67
+ * "other"), because the protocol describes a class of chains. The FF indexer
68
+ * names the same thing `ethereum`, because it queries a specific one. Without
69
+ * this every EVM item comes back unresolved, and the failure looks like a
70
+ * missing artwork rather than a vocabulary mismatch.
71
+ *
72
+ * Unlisted values pass through so the indexer, not this table, decides what it
73
+ * supports.
74
+ */
75
+ const CHAIN_ALIASES = {
76
+ evm: 'ethereum',
77
+ };
78
+ /**
79
+ * `evm` names a family, and this maps it to one member of that family.
80
+ *
81
+ * DP-1 core §6 offers "evm" | "tezos" | "bitmark" | "other" and carries no
82
+ * network identity, while the indexer maps Ethereum, Polygon, Arbitrum,
83
+ * Optimism, Base, and Zora all back to `evm`. So a DP-1 coordinate cannot say
84
+ * which EVM network it means, and neither can the response.
85
+ *
86
+ * Ethereum is chosen because it is the only EVM network this client can reach:
87
+ * buildTokenCID maps ethereum to eip155:1 and tezos to tezos:mainnet, and
88
+ * throws for everything else. A --chain flag would not widen that — it would
89
+ * only move the throw. The residual risk is narrow and real: an L2 work whose
90
+ * address and token id also exist on Ethereum would be enriched with the
91
+ * Ethereum work's metadata, and nothing in the response could reveal it.
92
+ *
93
+ * The count is surfaced so the assumption is stated at the point of use rather
94
+ * than buried here. Widening it belongs upstream, in the indexer client.
95
+ */
96
+ const AMBIGUOUS_CHAIN = 'evm';
97
+ /**
98
+ * One CLI invocation stamps one manifest timestamp, matching ref-manifest.ts:
99
+ * a run that synthesizes several manifests should not write several clocks.
100
+ */
101
+ const MANIFEST_CREATED = new Date().toISOString();
102
+ /**
103
+ * canonicalChain collapses both vocabularies onto one name so a coordinate
104
+ * sent to the indexer and a coordinate read back off its response compare
105
+ * equal. The direction is deliberately the reverse of CHAIN_ALIASES: this one
106
+ * is for matching, that one is for querying.
107
+ */
108
+ function canonicalChain(chain) {
109
+ const lower = chain.trim().toLowerCase();
110
+ return lower === 'ethereum' ? 'evm' : lower;
111
+ }
112
+ /**
113
+ * coordinateKeyOf builds the correlation key for a provenance block, or null
114
+ * when the block cannot identify a single work.
115
+ *
116
+ * The address is lowercased because the indexer echoes it checksummed while
117
+ * callers typically write it lowercase, and the two must still match.
118
+ */
119
+ function coordinateKeyOf(provenance) {
120
+ const contract = provenance?.contract;
121
+ if (!contract) {
122
+ return null;
123
+ }
124
+ const { chain, address, tokenId } = contract;
125
+ if (!chain || !address || tokenId === undefined || tokenId === null) {
126
+ return null;
127
+ }
128
+ const token = String(tokenId).trim();
129
+ if (token.length === 0) {
130
+ return null;
131
+ }
132
+ return `${canonicalChain(chain)}:${address.trim().toLowerCase()}:${token}`;
133
+ }
134
+ /**
135
+ * describeItem names an item for operator output, preferring its title.
136
+ */
137
+ function describeItem(item, index) {
138
+ const title = typeof item.title === 'string' ? item.title.trim() : '';
139
+ return title.length > 0 ? title : `item ${index + 1}`;
140
+ }
141
+ /**
142
+ * coordinateFor extracts a lookup key from an item, or null when the item
143
+ * carries no usable on-chain coordinate.
144
+ *
145
+ * All three parts are required. A contract with no token id identifies a
146
+ * collection rather than a work, and resolving that would attach some other
147
+ * item's manifest to this one.
148
+ */
149
+ function coordinateFor(item) {
150
+ const contract = item.provenance?.contract;
151
+ if (!contract) {
152
+ return null;
153
+ }
154
+ const { chain, address, tokenId } = contract;
155
+ if (!chain || !address || tokenId === undefined || tokenId === null) {
156
+ return null;
157
+ }
158
+ const token = String(tokenId).trim();
159
+ if (token.length === 0) {
160
+ return null;
161
+ }
162
+ const normalized = chain.trim().toLowerCase();
163
+ return {
164
+ chain: CHAIN_ALIASES[normalized] ?? normalized,
165
+ contractAddress: address,
166
+ tokenId: token,
167
+ };
168
+ }
169
+ /**
170
+ * stillFromIndexerSource recovers a thumbnail the manifest builder suppressed.
171
+ *
172
+ * `resolveStillUri` blanks a still that equals the source it was given, on the
173
+ * reasonable ground that pointing a thumbnail at the artwork itself adds
174
+ * payload and no information. But the source it was given is the one the
175
+ * INDEXER chose, not the one the curator kept. For a static work the indexer
176
+ * commonly selects the still as the source, so the thumbnail is dropped —
177
+ * and when the curator's item points at a live HTML generator instead, the
178
+ * item ends up with no still at all. That is precisely the case this command
179
+ * exists to repair: a live work the app cannot rasterize, whose grid tile
180
+ * stays empty.
181
+ *
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.
185
+ */
186
+ function stillFromIndexerSource(item, resolved) {
187
+ const indexerSource = typeof resolved.source === 'string' ? resolved.source.trim() : '';
188
+ const itemSource = typeof item.source === 'string' ? item.source.trim() : '';
189
+ if (!indexerSource || indexerSource === itemSource) {
190
+ return '';
191
+ }
192
+ if (!indexerSource.startsWith('http://') && !indexerSource.startsWith('https://')) {
193
+ return '';
194
+ }
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));
240
+ }
241
+ /**
242
+ * hasThumbnail reports whether a manifest already carries a default still.
243
+ */
244
+ function hasThumbnail(manifest) {
245
+ const thumbnails = manifest.metadata
246
+ ?.thumbnails;
247
+ return typeof thumbnails?.default?.uri === 'string' && thumbnails.default.uri.length > 0;
248
+ }
249
+ /**
250
+ * manifestFor returns the manifest to write onto an item, or undefined when
251
+ * the indexer resolved the token without building one.
252
+ *
253
+ * The curator's own title wins. `item.title` survives enrichment untouched,
254
+ * but the tombstone reads the manifest first, so attaching an indexer title
255
+ * that disagrees would change the displayed label while appearing to preserve
256
+ * it — the indexer writes "Pre-Process #0" where a curator wrote
257
+ * "Pre-Process". Everything else in the manifest (artist, description,
258
+ * thumbnails) is what enrichment exists to fetch and is taken as given.
259
+ */
260
+ function manifestFor(item, resolved) {
261
+ const still = stillFromIndexerSource(item, resolved);
262
+ let manifest = resolved.inlineManifest;
263
+ let rekey = false;
264
+ if (manifest && still && !hasThumbnail(manifest)) {
265
+ manifest = {
266
+ ...manifest,
267
+ metadata: {
268
+ ...(manifest.metadata ?? {}),
269
+ thumbnails: { default: { uri: still } },
270
+ },
271
+ };
272
+ rekey = true;
273
+ }
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
+ };
290
+ }
291
+ if (!manifest) {
292
+ return undefined;
293
+ }
294
+ const metadata = manifest.metadata ?? {};
295
+ const curatorTitle = typeof item.title === 'string' ? item.title.trim() : '';
296
+ const overrideTitle = curatorTitle.length > 0 && metadata.title !== curatorTitle;
297
+ // The id is derived from the finished payload every time, not only when this
298
+ // command changed something. The indexer keys its id on the token
299
+ // coordinate, so every rendition of one artwork carries the same id whatever
300
+ // its content — and under --force the indexer may itself return a different
301
+ // artist or description under that unchanged id. Deriving from the payload
302
+ // is the only rule that holds in every case: identical content keeps one
303
+ // identity, differing content never shares one.
304
+ //
305
+ // The original id is folded in, so the result stays anchored to the token
306
+ // 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
+ const finalMetadata = overrideTitle ? { ...metadata, title: curatorTitle } : metadata;
312
+ return {
313
+ ...manifest,
314
+ id: derivedManifestId(typeof manifest.id === 'string' ? manifest.id : '', stableStringify(finalMetadata)),
315
+ metadata: finalMetadata,
316
+ };
317
+ }
318
+ /**
319
+ * stableStringify serializes a value with object keys in sorted order, so the
320
+ * same content hashes the same however it was assembled.
321
+ */
322
+ function stableStringify(value) {
323
+ if (value === null || typeof value !== 'object') {
324
+ return JSON.stringify(value) ?? 'null';
325
+ }
326
+ if (Array.isArray(value)) {
327
+ return `[${value.map(stableStringify).join(',')}]`;
328
+ }
329
+ const entries = Object.entries(value)
330
+ .filter(([, entryValue]) => entryValue !== undefined)
331
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
332
+ .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
333
+ return `{${entries.join(',')}}`;
334
+ }
335
+ /**
336
+ * derivedManifestId produces a stable id for a manifest whose title this
337
+ * command replaced, shaped as a UUID so it reads like every other manifest id.
338
+ */
339
+ function derivedManifestId(originalId, title) {
340
+ const digest = (0, node_crypto_1.createHash)('sha256').update(`${originalId}\n${title}`).digest('hex');
341
+ return [
342
+ digest.slice(0, 8),
343
+ digest.slice(8, 12),
344
+ digest.slice(12, 16),
345
+ digest.slice(16, 20),
346
+ digest.slice(20, 32),
347
+ ].join('-');
348
+ }
349
+ /**
350
+ * enrichPlaylistManifests attaches an inline Ref Manifest to every item that
351
+ * lacks one and can be resolved on chain.
352
+ *
353
+ * The playlist is mutated in place and returned; callers that need the
354
+ * original should read it twice. Items are looked up in one batch so the
355
+ * indexer sees a single burst rather than a request per item — warming
356
+ * previously-unseen tokens is the slow path, and it parallelizes internally.
357
+ *
358
+ * Signatures are dropped when anything changed, in both the DP-1 v1.1.0
359
+ * `signatures[]` form and the legacy flat `signature` this repo still verifies.
360
+ * A signature covers the document bytes, and an enriched document is a
361
+ * different document; leaving a stale envelope in place would produce a file
362
+ * that fails verification at the device with no explanation of why.
363
+ */
364
+ async function enrichPlaylistManifests(playlist, lookup, options = {}) {
365
+ const items = Array.isArray(playlist.items) ? playlist.items : [];
366
+ const skipped = [];
367
+ const pending = [];
368
+ // One lookup per distinct artwork, not per item. getNFTTokenInfoBatch runs a
369
+ // lookup for each token it is handed, and a token the indexer has not seen
370
+ // triggers an indexing job and a poll that can take minutes. A playlist that
371
+ // deliberately repeats a work — the same piece at two points in a loop —
372
+ // would otherwise submit that job once per appearance.
373
+ const coordinates = new Map();
374
+ items.forEach((item, index) => {
375
+ // An item with a `ref` is already labelled, by a manifest this command
376
+ // cannot see. Ref outranks inlineManifest in the resolution order, so
377
+ // enriching one writes a manifest the device will ignore while still
378
+ // invalidating the signature: damage with no visible change.
379
+ //
380
+ // --force does not override this. Making the inline manifest win would
381
+ // mean deleting the curator's `ref` and `refHash`, which is a different
382
+ // and larger decision than filling in missing metadata — and one that
383
+ // silently discards the integrity hash the remote manifest is checked
384
+ // against. If that conversion is ever wanted it should be its own flag,
385
+ // named for what it does.
386
+ if (typeof item.ref === 'string' && item.ref.trim().length > 0) {
387
+ skipped.push({ index, title: describeItem(item, index), reason: 'external-ref' });
388
+ return;
389
+ }
390
+ if (item.inlineManifest && !options.force) {
391
+ skipped.push({ index, title: describeItem(item, index), reason: 'already-labelled' });
392
+ return;
393
+ }
394
+ const coordinate = coordinateFor(item);
395
+ const key = coordinateKeyOf(item.provenance);
396
+ if (!coordinate || !key) {
397
+ skipped.push({ index, title: describeItem(item, index), reason: 'no-provenance' });
398
+ return;
399
+ }
400
+ // `evm` names a family and the response cannot narrow it, so enriching one
401
+ // on the assumption of Ethereum can attach a different artwork's artist,
402
+ // description, and still — into a document the curator then signs. The
403
+ // operator has to assert the network; the command will not assume it.
404
+ if (key.startsWith(`${AMBIGUOUS_CHAIN}:`) && !options.assumeEthereum) {
405
+ skipped.push({ index, title: describeItem(item, index), reason: 'ambiguous-chain' });
406
+ return;
407
+ }
408
+ pending.push({ index, key });
409
+ if (!coordinates.has(key)) {
410
+ coordinates.set(key, coordinate);
411
+ }
412
+ });
413
+ const assumedEthereum = [...coordinates.keys()].filter((key) => key.startsWith(`${AMBIGUOUS_CHAIN}:`)).length;
414
+ if (pending.length === 0) {
415
+ return { playlist, enriched: 0, skipped, signatureInvalidated: false, assumedEthereum: 0 };
416
+ }
417
+ const resolved = await lookup([...coordinates.values()], options.onProgress);
418
+ // Index what came back by its own coordinate. A response carrying no usable
419
+ // provenance cannot be attributed to any request, so it is dropped rather
420
+ // than guessed at; the affected item then reports as not-indexed, which is
421
+ // exactly what happened.
422
+ const byCoordinate = new Map();
423
+ for (const entry of resolved) {
424
+ const key = coordinateKeyOf(entry.provenance);
425
+ if (key && !byCoordinate.has(key)) {
426
+ byCoordinate.set(key, entry);
427
+ }
428
+ }
429
+ let enriched = 0;
430
+ for (const entry of pending) {
431
+ const item = items[entry.index];
432
+ const match = byCoordinate.get(entry.key);
433
+ const manifest = match ? manifestFor(item, match) : undefined;
434
+ if (!manifest) {
435
+ // The indexer resolving a token and the indexer having anything worth
436
+ // attaching are different outcomes. buildInlineManifestForToken returns
437
+ // undefined when it has only a title, because a title-only manifest adds
438
+ // payload to every transfer without adding information. Reporting that
439
+ // as "the indexer returned nothing" would be false.
440
+ skipped.push({
441
+ index: entry.index,
442
+ title: describeItem(item, entry.index),
443
+ reason: match ? 'no-metadata' : 'not-indexed',
444
+ });
445
+ continue;
446
+ }
447
+ item.inlineManifest = manifest;
448
+ enriched += 1;
449
+ }
450
+ const hadSignature = playlist.signatures !== undefined || playlist.signature !== undefined;
451
+ const signatureInvalidated = enriched > 0 && hadSignature;
452
+ if (signatureInvalidated) {
453
+ delete playlist.signatures;
454
+ delete playlist.signature;
455
+ logger.debug('[Enrich] Dropped signatures: the enriched document must be re-signed.');
456
+ }
457
+ skipped.sort((a, b) => a.index - b.index);
458
+ return { playlist, enriched, skipped, signatureInvalidated, assumedEthereum };
459
+ }
@@ -329,6 +329,25 @@ function shuffleArray(array) {
329
329
  }
330
330
  return shuffled;
331
331
  }
332
+ /**
333
+ * Normalize a legacy item `provenance` block to the DP-1 schema shape.
334
+ *
335
+ * A prior `buildUrlItem` stamped off-chain URL items with
336
+ * `provenance: { type: 'offChainURI', uri }`, but the DP-1 ProvenanceBlock has
337
+ * no top-level `uri` — a `uri` only exists under `provenance.contract`. Drop the
338
+ * stray top-level `uri` so rebuilding a legacy feed playlist produces the same
339
+ * clean shape as freshly-built items; every other provenance field is preserved.
340
+ *
341
+ * @param {Object} provenance - Item provenance block (already known to be an object)
342
+ * @returns {Object} Provenance without a stray top-level `uri`
343
+ */
344
+ function normalizeProvenance(provenance) {
345
+ if (!('uri' in provenance)) {
346
+ return provenance;
347
+ }
348
+ const { uri: _uri, ...rest } = provenance;
349
+ return rest;
350
+ }
332
351
  /**
333
352
  * Get playlist items from a playlist
334
353
  *
@@ -352,11 +371,22 @@ function extractPlaylistItems(playlist, quantity, duration, shuffle = true) {
352
371
  // Apply an explicit duration override when one was requested; otherwise
353
372
  // keep each item's own timing (including intentionally absent duration,
354
373
  // which DP-1 §4.1 uses for natural-length playback of video/audio).
374
+ //
375
+ // Strip any item-level `created`: the DP-1 PlaylistItem schema has no
376
+ // `created` field (it lives only at the playlist level), so drop it here —
377
+ // including from legacy feed entries that may still carry it — before the
378
+ // items reach PlaylistBuilder for construction.
379
+ //
380
+ // Also normalize legacy `offChainURI` provenance: an earlier `buildUrlItem`
381
+ // emitted `provenance: { type: 'offChainURI', uri }`, but the DP-1 schema has
382
+ // no top-level `provenance.uri` (a `uri` only lives under `provenance.contract`).
383
+ // Rebuilding a legacy feed playlist would otherwise carry that stray field
384
+ // through PlaylistBuilder into the output, so drop it during extraction.
355
385
  items = items.map((item) => {
356
- const next = {
357
- ...item,
358
- created: item.created || new Date().toISOString(), // Ensure created field exists
359
- };
386
+ const { created: _created, ...next } = item;
387
+ if (next.provenance && typeof next.provenance === 'object') {
388
+ next.provenance = normalizeProvenance(next.provenance);
389
+ }
360
390
  if (typeof duration === 'number') {
361
391
  next.duration = duration;
362
392
  }
@@ -3,10 +3,24 @@
3
3
  * This module provides functions to interact with NFT indexing services
4
4
  * to retrieve comprehensive token information.
5
5
  */
6
- const GRAPHQL_ENDPOINT = 'https://indexer.feralfile.com/graphql';
6
+ const { PlaylistItemBuilder, ProvenanceBuilder, ContractBuilder } = require('dp1-js');
7
+ // The production endpoint is hardcoded, as PROJECT_SPEC states, and stays
8
+ // that way. INDEXER_API_URL is a test hook, not configuration: the
9
+ // find-command suite spawns the CLI as a child process, so the only way to
10
+ // point it at a local mock is through the environment.
11
+ //
12
+ // It is gated on NODE_ENV=test because index.ts loads dotenv at startup. An
13
+ // ungated read would mean a stray INDEXER_API_URL in someone's .env, or an
14
+ // inherited shell value, silently redirecting every token lookup and indexing
15
+ // mutation — a configuration surface nobody documented and no one would think
16
+ // to check. The gate keeps the hook usable without creating that surface.
17
+ const GRAPHQL_ENDPOINT = process.env.NODE_ENV === 'test' && process.env.INDEXER_API_URL
18
+ ? process.env.INDEXER_API_URL
19
+ : 'https://indexer.feralfile.com/graphql';
7
20
  const { fetchWithTimeout } = require('./http');
8
21
  const logger = require('../logger');
9
- const { applyItemTiming } = require('./playlist-builder');
22
+ const { applyTimingToItemBuilder, buildDefaultDisplay } = require('./playlist-builder');
23
+ const { buildInlineManifestForToken } = require('./ref-manifest');
10
24
  // Polling configuration (in milliseconds)
11
25
  const POLLING_INTERVAL_MS = 2000; // Poll every 2 seconds
12
26
  const POLLING_TIMEOUT_MS = 60000; // Max poll for 1 minute
@@ -355,6 +369,9 @@ function mapIndexerDataToStandardFormat(indexerData, chain) {
355
369
  * Extracts source URL from indexer data with proper priority:
356
370
  * animation_url > image.url, ensuring the best quality media is used.
357
371
  *
372
+ * When the token carries description, artist, or a still image distinct from
373
+ * the source, the item also gets an inline Ref Manifest (see ref-manifest.ts).
374
+ *
358
375
  * @param {Object} tokenData - Token data in standard format
359
376
  * @param {number} [duration] - Explicit display seconds; omit for auto timing
360
377
  * (video/audio play natural length per DP-1 §4.1)
@@ -434,27 +451,40 @@ function convertToDP1Item(tokenData, duration) {
434
451
  tezos: 'tezos', // DP1 spec uses 'tezos', not 'tez'
435
452
  bitmark: 'bitmark', // DP1 spec uses 'bitmark', not 'bmk'
436
453
  };
437
- // Build DP1 item structure (strict DP1 v1.0.0 compliance)
438
- const dp1Item = {
439
- id: itemId,
440
- source: sourceUrl,
441
- license: 'open',
442
- created: new Date().toISOString(),
443
- provenance: {
444
- type: 'onChain',
445
- contract: {
446
- chain: chainMap[token.chain.toLowerCase()] || 'other',
447
- standard: token.standard || detectTokenStandard(token.chain, token.contractAddress),
448
- address: token.contractAddress,
449
- tokenId: String(token.tokenId),
450
- },
451
- },
452
- };
454
+ // Build via dp1-js PlaylistItemBuilder so leaf blocks match DP-1 AJV schema.
455
+ const itemBuilder = new PlaylistItemBuilder()
456
+ .id(itemId)
457
+ .source(sourceUrl)
458
+ .license('open')
459
+ .provenance(new ProvenanceBuilder().type('onChain').contract(new ContractBuilder()
460
+ .chain(chainMap[token.chain.toLowerCase()] || 'other')
461
+ .standard(token.standard || detectTokenStandard(token.chain, token.contractAddress))
462
+ .address(token.contractAddress)
463
+ .tokenId(String(token.tokenId))));
453
464
  // Add title if available (valid DP1 field)
454
465
  if (token.name) {
455
- dp1Item.title = token.name;
466
+ itemBuilder.title(token.name);
467
+ }
468
+ // Carry description, artist, and still image inline (DP-1 Playlist Extension
469
+ // §3.6). These are the fields the indexer returns that have no PlaylistItem
470
+ // home; before inline manifests existed they were mapped and then dropped on
471
+ // the floor here. The helper returns undefined when the token has nothing
472
+ // beyond its title, so thin tokens stay thin.
473
+ const inlineManifest = buildInlineManifestForToken(token, { sourceUrl });
474
+ if (inlineManifest) {
475
+ itemBuilder.inlineManifest(inlineManifest);
476
+ }
477
+ applyTimingToItemBuilder(itemBuilder, { mimeType: token.image?.mimeType, sourceUrl }, duration, buildDefaultDisplay());
478
+ let dp1Item;
479
+ try {
480
+ dp1Item = itemBuilder.build();
481
+ }
482
+ catch (error) {
483
+ return {
484
+ success: false,
485
+ error: error instanceof Error ? error.message : String(error),
486
+ };
456
487
  }
457
- applyItemTiming(dp1Item, { mimeType: token.image?.mimeType, sourceUrl }, duration);
458
488
  logger.debug('[NFT Indexer] ✓ Converted to DP1:', {
459
489
  title: token.name,
460
490
  source: sourceUrl ? sourceUrl.substring(0, 60) + '...' : '(no source URL)',