@feralfile/cli 2.10.0 → 3.0.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,378 @@
1
+ "use strict";
2
+ /**
3
+ * Raster token source: the opt-in provider under evaluation (feral-file#3576).
4
+ *
5
+ * Reads token rows from Raster's public GraphQL API and maps each one to the
6
+ * vendor-neutral StandardToken, which token-item.ts turns into a DP-1 item
7
+ * with the same code the indexer path uses. Item ids, provenance, inline
8
+ * manifests and timing therefore come out identical in shape to the indexer's.
9
+ *
10
+ * What Raster cannot do, and how this provider answers instead of guessing:
11
+ * - no contract listing query: listTokensByContract throws a capability
12
+ * error rather than inventing a probe;
13
+ * - a token it does not cover, or a burned one, comes back null: reported
14
+ * as a failure, never filled from another vendor (one vendor per run);
15
+ * - Feral File metadata is a snapshot: bare ipfs:// and a retired CDN host
16
+ * are fixed up in media-url.ts;
17
+ * - `media.contentType` is a category, not a MIME type: rasterTimingMime
18
+ * turns it into a timing hint that never reaches the document.
19
+ */
20
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ var desc = Object.getOwnPropertyDescriptor(m, k);
23
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
24
+ desc = { enumerable: true, get: function() { return m[k]; } };
25
+ }
26
+ Object.defineProperty(o, k2, desc);
27
+ }) : (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ o[k2] = m[k];
30
+ }));
31
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
32
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
33
+ }) : function(o, v) {
34
+ o["default"] = v;
35
+ });
36
+ var __importStar = (this && this.__importStar) || (function () {
37
+ var ownKeys = function(o) {
38
+ ownKeys = Object.getOwnPropertyNames || function (o) {
39
+ var ar = [];
40
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
41
+ return ar;
42
+ };
43
+ return ownKeys(o);
44
+ };
45
+ return function (mod) {
46
+ if (mod && mod.__esModule) return mod;
47
+ var result = {};
48
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
49
+ __setModuleDefault(result, mod);
50
+ return result;
51
+ };
52
+ })();
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ exports.rasterTokenStandard = rasterTokenStandard;
55
+ exports.rasterTokenName = rasterTokenName;
56
+ exports.rasterTimingMime = rasterTimingMime;
57
+ exports.rasterTokenToStandardToken = rasterTokenToStandardToken;
58
+ exports.itemsFromRasterTokens = itemsFromRasterTokens;
59
+ exports.createRasterTokenSource = createRasterTokenSource;
60
+ const viem_1 = require("viem");
61
+ const logger = __importStar(require("../logger"));
62
+ const raster_client_1 = require("./raster-client");
63
+ const media_url_1 = require("./media-url");
64
+ const token_item_1 = require("./token-item");
65
+ const token_source_contract_1 = require("./token-source-contract");
66
+ /** CAIP-2 chains ff-cli builds items for. Others are skipped and counted. */
67
+ const CAIP_TO_CHAIN = {
68
+ 'eip155:1': 'ethereum',
69
+ 'tezos:NetXdQprcVkpaWU': 'tezos',
70
+ };
71
+ /** Raster standards DP-1 can name; others become `other` (see rasterTokenStandard). */
72
+ const RASTER_STANDARDS = {
73
+ ERC721: 'erc721',
74
+ ERC1155: 'erc1155',
75
+ FA2: 'fa2',
76
+ };
77
+ /** Page size for the wallet walk. 100 matches listArtistArtworks; 200 was probed fine. */
78
+ const OWNED_PAGE_SIZE = 100;
79
+ /** NOT_ON_RASTER explains a null `tokenByRef` in the user's terms. */
80
+ const NOT_ON_RASTER = 'Raster has no record of this token (not indexed, burned, or nonexistent)';
81
+ /**
82
+ * rasterTokenStandard maps Raster's standard enum to a DP-1 standard.
83
+ *
84
+ * CRYPTOPUNKS and ORDINAL_INSCRIPTION have no DP-1 name, so they are `other`;
85
+ * falling through to detection would call a punk `erc721`, which it is not.
86
+ * A missing standard uses the caller's assertion, then detection.
87
+ */
88
+ function rasterTokenStandard(raw, chain, contractAddress, asserted) {
89
+ if (raw) {
90
+ return RASTER_STANDARDS[raw.toUpperCase()] ?? 'other';
91
+ }
92
+ return (0, token_item_1.resolveTokenStandard)(chain, contractAddress, asserted);
93
+ }
94
+ /**
95
+ * rasterTokenName picks the display title.
96
+ *
97
+ * Raster's GraphQL `Token.name` is empty for Art Blocks tokens (a known Raster
98
+ * bug, feral-file#3546). Art Blocks token ids are project * 1,000,000 + mint,
99
+ * so "<series> #<mint>" reproduces the title Art Blocks itself publishes,
100
+ * e.g. "Fidenza #629".
101
+ */
102
+ function rasterTokenName(token) {
103
+ const name = (token.name ?? '').trim();
104
+ if (name) {
105
+ return name;
106
+ }
107
+ const artwork = token.artworks[0];
108
+ if (artwork?.platform?.id === 'artblocks' && /^\d+$/.test(token.tokenId)) {
109
+ return `${artwork.title} #${BigInt(token.tokenId) % 1000000n}`;
110
+ }
111
+ return artwork?.title || `Token #${token.tokenId}`;
112
+ }
113
+ /**
114
+ * rasterTimingMime turns Raster's media category into a timing hint.
115
+ *
116
+ * The value only steers DP-1 §4.1 timing (video plays its length, web works
117
+ * get the generative duration, the rest the static duration); it is never
118
+ * written into the playlist. `previewType` describes how Raster encoded the
119
+ * preview, not the work (a web app's preview is an image), so it is consulted
120
+ * only when it says video. Empty means unknown, and the URL extension decides,
121
+ * which is the indexer path's behaviour for extensionless URLs too.
122
+ */
123
+ function rasterTimingMime(media) {
124
+ switch ((media?.contentType ?? '').toLowerCase()) {
125
+ case 'video':
126
+ return 'video/*';
127
+ case 'webapp':
128
+ return 'text/html';
129
+ case 'gif':
130
+ return 'image/gif';
131
+ case 'svg':
132
+ return 'image/svg+xml';
133
+ case 'image':
134
+ return 'image/*';
135
+ }
136
+ return (media?.previewType ?? '').toLowerCase().startsWith('video/') ? 'video/*' : '';
137
+ }
138
+ /**
139
+ * RASTER_STILL is deliberately empty: Raster items carry no still image.
140
+ *
141
+ * Raster's preview renditions (`bits.raster.art/.../7200.avif`) load for a
142
+ * browser user agent but answer 403 to curl and to the Feral File app's
143
+ * default `dart:io` client (verified 2026-10-21), and AVIF decoding outside
144
+ * browsers is not guaranteed. A still lands in `thumbnails.default` of a
145
+ * manifest the curator signs, so a URL that is dead for some readers is worse
146
+ * than none. Set a real rendition here only once Raster confirms access that
147
+ * does not depend on the user agent, and record that evidence here.
148
+ */
149
+ const RASTER_STILL = '';
150
+ /**
151
+ * canonicalContract returns the address as the indexer recorded it.
152
+ *
153
+ * Raster lowercases EVM addresses; the indexer returned them checksummed, and
154
+ * the DP-1 item id is sha256 of the address. Checksumming keeps ids and
155
+ * provenance identical across vendors. Tezos addresses are case-sensitive
156
+ * Base58 and Raster keeps them as written.
157
+ */
158
+ function canonicalContract(chain, address) {
159
+ if (chain !== 'ethereum') {
160
+ return address;
161
+ }
162
+ try {
163
+ return (0, viem_1.getAddress)(address);
164
+ }
165
+ catch {
166
+ return address;
167
+ }
168
+ }
169
+ /**
170
+ * rasterTokenToStandardToken maps one Raster row to the vendor-neutral token.
171
+ *
172
+ * @param token - Raster row selected through TOKEN_FIELDS
173
+ * @param gateways - IPFS gateways for bare ipfs:// sources
174
+ * @param requested - The coordinate asked for, when there was one
175
+ * @returns TokenData ready for convertToDP1Item
176
+ */
177
+ function rasterTokenToStandardToken(token, gateways, requested) {
178
+ const chain = CAIP_TO_CHAIN[token.chainId];
179
+ const rawAddress = token.contractAddress ?? requested?.contractAddress;
180
+ if (!chain || !rawAddress) {
181
+ return { success: false, error: `Unsupported chain ${token.chainId}` };
182
+ }
183
+ const contractAddress = canonicalContract(chain, rawAddress);
184
+ const artwork = token.artworks[0];
185
+ const platformId = artwork?.platform?.id ?? null;
186
+ const name = rasterTokenName(token);
187
+ const description = (token.description || artwork?.description || '').trim();
188
+ const artistName = (token.artists[0] ?? artwork?.artists?.[0])?.name ?? '';
189
+ const sourceUrl = (0, media_url_1.fixUpMediaUrl)(token.media?.contentUrl || token.media?.normalizedContentUrl, platformId, gateways);
190
+ return {
191
+ success: true,
192
+ token: {
193
+ chain,
194
+ caip2Chain: token.chainId,
195
+ contractAddress,
196
+ tokenId: token.tokenId,
197
+ standard: rasterTokenStandard(token.tokenStandard, chain, contractAddress, requested?.standard),
198
+ name,
199
+ description,
200
+ image: {
201
+ url: sourceUrl,
202
+ mimeType: rasterTimingMime(token.media),
203
+ thumbnail: RASTER_STILL,
204
+ },
205
+ metadata: { attributes: [], artistName },
206
+ collection: { name: artwork?.title || name, description },
207
+ burned: false,
208
+ },
209
+ };
210
+ }
211
+ /**
212
+ * itemsFromRasterTokens builds results from rows already in hand.
213
+ *
214
+ * Used by the series path of `find`, whose `artwork.tokens` page already
215
+ * carries every field, and by the wallet walk, so neither pays a second
216
+ * round trip.
217
+ */
218
+ function itemsFromRasterTokens(entries, gateways, duration) {
219
+ return entries.map(({ token, requested }) => {
220
+ const tokenData = rasterTokenToStandardToken(token, gateways, requested);
221
+ const coordinate = requested ?? coordinateOf(tokenData, token);
222
+ if (!tokenData.success) {
223
+ return { success: false, coordinate, error: tokenData.error ?? 'Unmappable token' };
224
+ }
225
+ const converted = (0, token_item_1.convertToDP1Item)(tokenData, duration);
226
+ if (!converted.success || !converted.item) {
227
+ return { success: false, coordinate, error: converted.error ?? 'Item build failed' };
228
+ }
229
+ return { success: true, coordinate, item: converted.item, still: converted.still ?? '' };
230
+ });
231
+ }
232
+ /** coordinateOf names the coordinate a row describes when nobody asked for it. */
233
+ function coordinateOf(tokenData, token) {
234
+ return {
235
+ chain: tokenData.token?.chain ?? token.chainId,
236
+ contractAddress: tokenData.token?.contractAddress ?? token.contractAddress ?? '',
237
+ tokenId: token.tokenId,
238
+ };
239
+ }
240
+ /**
241
+ * normalizeChain maps a coordinate's chain to Raster's vocabulary, or null.
242
+ *
243
+ * The address prefix outranks the caller's chain name: `KT…` is Tezos and
244
+ * `0x…` is EVM whatever the caller said, mirroring the auto-correction the
245
+ * indexer path applied. Only Ethereum and Tezos mainnet are supported.
246
+ */
247
+ function normalizeChain(coord) {
248
+ if (coord.contractAddress.startsWith('KT')) {
249
+ return 'tezos';
250
+ }
251
+ if (coord.contractAddress.startsWith('0x')) {
252
+ const chain = coord.chain.toLowerCase();
253
+ return chain === 'ethereum' || chain === 'eth' || chain === 'evm' ? 'ethereum' : null;
254
+ }
255
+ return null;
256
+ }
257
+ /** reportProgress calls a progress callback, which must never break a run. */
258
+ function reportProgress(opts, done, total) {
259
+ try {
260
+ opts.onProgress?.(done, total);
261
+ }
262
+ catch {
263
+ /* progress display is best-effort */
264
+ }
265
+ }
266
+ /** DECIMAL_TOKEN_ID is the only token id shape Raster's BigInt scalar accepts. */
267
+ const DECIMAL_TOKEN_ID = /^\d+$/;
268
+ /**
269
+ * rejectionFor explains why a coordinate cannot be sent to Raster, or ''.
270
+ *
271
+ * Checked before batching because Raster validates every variable of a query
272
+ * together: one non-decimal token id (`0x1f`, `#12`, a typo) rejects the whole
273
+ * request, which would fail up to 39 valid neighbours with an unrelated error.
274
+ */
275
+ function rejectionFor(coord) {
276
+ if (normalizeChain(coord) === null) {
277
+ return `Unsupported chain "${coord.chain}" (Ethereum and Tezos mainnet only)`;
278
+ }
279
+ if (!DECIMAL_TOKEN_ID.test(String(coord.tokenId ?? '').trim())) {
280
+ return `Invalid token id "${coord.tokenId}" (expected a decimal integer)`;
281
+ }
282
+ return '';
283
+ }
284
+ /**
285
+ * resolveBatch looks up one batch and turns each answer into a result.
286
+ * Coordinates that cannot be sent fail on their own; a failed request fails
287
+ * every coordinate it carried with the same reason.
288
+ */
289
+ async function resolveBatch(batch, gateways, duration) {
290
+ const lookups = batch.filter((coord) => rejectionFor(coord) === '');
291
+ let rows;
292
+ try {
293
+ rows = await (0, raster_client_1.fetchTokensByRef)(lookups.map((coord) => ({
294
+ chain: normalizeChain(coord),
295
+ contractAddress: coord.contractAddress,
296
+ tokenId: String(coord.tokenId).trim(),
297
+ })));
298
+ }
299
+ catch (error) {
300
+ const message = error instanceof Error ? error.message : String(error);
301
+ return batch.map((coordinate) => {
302
+ const rejection = rejectionFor(coordinate);
303
+ return { success: false, coordinate, error: rejection || message };
304
+ });
305
+ }
306
+ const byCoord = new Map(lookups.map((coord, i) => [coord, rows[i]]));
307
+ return batch.map((coordinate) => resolveOne(coordinate, byCoord, gateways, duration));
308
+ }
309
+ /** resolveOne turns one coordinate's row (or its absence) into a result. */
310
+ function resolveOne(coordinate, rows, gateways, duration) {
311
+ const rejection = rejectionFor(coordinate);
312
+ if (rejection) {
313
+ return { success: false, coordinate, error: rejection };
314
+ }
315
+ const row = rows.get(coordinate);
316
+ if (!row) {
317
+ return { success: false, coordinate, error: NOT_ON_RASTER };
318
+ }
319
+ return itemsFromRasterTokens([{ token: row, requested: coordinate }], gateways, duration)[0];
320
+ }
321
+ /**
322
+ * walkOwnedTokens pages through an address's holdings until `limit` supported
323
+ * tokens are collected or the walk ends. Any page failure throws: a silently
324
+ * truncated wallet would look like a complete one.
325
+ */
326
+ async function walkOwnedTokens(address, limit) {
327
+ const tokens = [];
328
+ let skippedUnsupported = 0;
329
+ let after;
330
+ do {
331
+ const page = await (0, raster_client_1.fetchTokensOwned)(address, { first: OWNED_PAGE_SIZE, after });
332
+ for (const token of page.tokens) {
333
+ if (!CAIP_TO_CHAIN[token.chainId] || !token.contractAddress) {
334
+ skippedUnsupported += 1;
335
+ }
336
+ else if (limit === 'all' || tokens.length < limit) {
337
+ tokens.push(token);
338
+ }
339
+ }
340
+ after = page.nextCursor ?? undefined;
341
+ } while (after && (limit === 'all' || tokens.length < limit));
342
+ return { tokens, skippedUnsupported };
343
+ }
344
+ /**
345
+ * createRasterTokenSource builds the Raster provider.
346
+ *
347
+ * Batches run one after another: 40 tokens per request already turns the
348
+ * ff-cli#132 case (43 tokens, ~10 minutes on the indexer) into two requests,
349
+ * and sequential batches keep load on an unmetered beta API predictable.
350
+ *
351
+ * @param gateways - Resolved IPFS gateways from config
352
+ */
353
+ function createRasterTokenSource(gateways) {
354
+ return {
355
+ name: 'raster',
356
+ async resolveTokens(coords, opts = {}) {
357
+ const results = [];
358
+ for (let i = 0; i < coords.length; i += raster_client_1.RASTER_TOKEN_BATCH_SIZE) {
359
+ const batch = coords.slice(i, i + raster_client_1.RASTER_TOKEN_BATCH_SIZE);
360
+ results.push(...(await resolveBatch(batch, gateways, opts.duration)));
361
+ reportProgress(opts, results.length, coords.length);
362
+ }
363
+ logger.debug(`[Raster] Resolved ${results.filter((r) => r.success).length}/${coords.length}`);
364
+ return results;
365
+ },
366
+ async listTokensByOwner(address, opts) {
367
+ const walked = await walkOwnedTokens(address, opts.limit);
368
+ const entries = walked.tokens.map((token) => ({ token }));
369
+ return {
370
+ results: itemsFromRasterTokens(entries, gateways, opts.duration),
371
+ skippedUnsupported: walked.skippedUnsupported,
372
+ };
373
+ },
374
+ async listTokensByContract() {
375
+ throw new token_source_contract_1.TokenSourceCapabilityError(token_source_contract_1.RASTER_CANNOT_LIST_CONTRACT);
376
+ },
377
+ };
378
+ }
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ /**
3
+ * Token source factory: the one place that decides which vendor answers.
4
+ *
5
+ * Commands call getTokenSource() and see only the TokenSource contract.
6
+ * The Feral File indexer (ff-indexer-v2) is the default. Raster is opt-in with
7
+ * `tokenSource: "raster"` (or FF_TOKEN_SOURCE=raster) while it is evaluated:
8
+ * its data still has open bugs (feral-file#3546), so it is not the default
9
+ * until they are fixed (owner decision, 2026-10-22). There is no fallback
10
+ * between providers: one vendor answers a whole run (feral-file#3576).
11
+ */
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
24
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
25
+ };
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.createTokenSource = createTokenSource;
28
+ exports.getTokenSource = getTokenSource;
29
+ const config_1 = require("../config");
30
+ const token_source_indexer_1 = require("./token-source-indexer");
31
+ const token_source_raster_1 = require("./token-source-raster");
32
+ __exportStar(require("./token-source-contract"), exports);
33
+ /**
34
+ * createTokenSource builds the provider a configuration selects.
35
+ *
36
+ * @param config - Resolved token source configuration
37
+ * @returns The selected provider
38
+ */
39
+ function createTokenSource(config) {
40
+ if (config.source === 'raster') {
41
+ return (0, token_source_raster_1.createRasterTokenSource)(config.ipfsGateways);
42
+ }
43
+ return (0, token_source_indexer_1.createIndexerTokenSource)();
44
+ }
45
+ /** getTokenSource builds the provider from the current configuration. */
46
+ function getTokenSource() {
47
+ return createTokenSource((0, config_1.getTokenSourceConfig)());
48
+ }
@@ -27,6 +27,10 @@ npm run dev -- config show
27
27
  - **generativeDuration** (number, seconds)
28
28
  - Display duration stamped on generative/interactive (HTML) works, which have no intrinsic runtime and no end-of-stream event. Defaults to **60s** (override via `DEFAULT_GENERATIVE_DURATION`).
29
29
  - Set to **0** to omit the duration entirely, so a conformant player parks on the work open-ended instead of rotating. Note: some players (including FF1) require every item to carry a `duration` and will reject a playlist whose items omit it — keep this non-zero unless you know your player tolerates an absent duration.
30
+ - **tokenSource** (`"raster"` | `"indexer"`)
31
+ - Which service `find`, `build`, and `enrich` read token data from. Defaults to `indexer`. See [tokenSource](#tokensource).
32
+ - **ipfsGateways** (object)
33
+ - Gateways used to turn bare `ipfs://` sources into HTTPS URLs. See [ipfsGateways](#ipfsgateways).
30
34
 
31
35
  ## browser
32
36
 
@@ -215,9 +219,59 @@ Environment variable alternative:
215
219
  FEED_BASE_URLS=https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1,https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1
216
220
  ```
217
221
 
218
- ## find (Raster API)
222
+ ## tokenSource
219
223
 
220
- `ff-cli find` resolves tokens, series, and artist addresses through Raster's public GraphQL API ([docs.api.raster.art](https://docs.api.raster.art/)). No configuration is required; two environment variables adjust the integration:
224
+ Token data for `find`, `build`, and `enrich` (title, artist, description, playable source, on-chain provenance, and a still image where the service supplies one) comes from one service per run:
225
+
226
+ - `indexer` (default): the Feral File indexer (ff-indexer-v2). A token it has not seen yet triggers an indexing job, so a first build of unseen tokens can take minutes.
227
+ - `raster` (opt-in, under evaluation): Raster's public GraphQL API. Tokens are looked up 40 per request, so a build does not wait for indexing jobs. Raster's data still has known bugs, tracked on [feral-file#3546](https://github.com/feral-file/feral-file/issues/3546): some Feral File works point at a retired image host and play black, and evolving works play an older snapshot. No still image is supplied: Raster's preview renditions refuse non-browser clients, so items and `enrich` get no thumbnail from Raster.
228
+
229
+ ```json
230
+ { "tokenSource": "raster" }
231
+ ```
232
+
233
+ Omit the key, or set `"indexer"`, to use the default.
234
+
235
+ Environment variable alternative:
236
+
237
+ ```env
238
+ FF_TOKEN_SOURCE=raster
239
+ ```
240
+
241
+ There is no fallback between the two. A token Raster does not cover, or one that was burned, is reported as unresolved rather than looked up elsewhere.
242
+
243
+ Raster has no way to list a contract's tokens. With `tokenSource: "raster"`, a `build_playlist` requirement without `tokenIds` fails with a message saying so, and a `query_address` requirement for an address that holds nothing is not retried as a contract. Give token ids, an owner address, or run `find` on the series URL instead.
244
+
245
+ Only Ethereum mainnet and Tezos mainnet tokens are built. Tokens on other chains in a wallet or series are skipped and counted.
246
+
247
+ ## ipfsGateways
248
+
249
+ With `tokenSource: "raster"`, many sources come back as bare `ipfs://` CIDs, which the FF1 cannot load. ff-cli rewrites them through a gateway:
250
+
251
+ - `ipfsGateways.feralFile`: used for Feral File works. Default `https://ipfs.feralfile.com/ipfs/`.
252
+ - `ipfsGateways.default`: used for everything else. Default `https://ipfs.filebase.io/ipfs/`.
253
+
254
+ A bare origin such as `https://gateway.example` is accepted and normalized to `https://gateway.example/ipfs/`. Sources on the retired `cdn.feralfileassets.com` host are rewritten to `cdn.artworks.feralfile.io`. Sources on the legacy `ipfs.bitmark.com` gateway go through `ipfsGateways.feralFile` instead.
255
+
256
+ ```json
257
+ {
258
+ "ipfsGateways": {
259
+ "feralFile": "https://ipfs.feralfile.com/ipfs/",
260
+ "default": "https://ipfs.filebase.io/ipfs/"
261
+ }
262
+ }
263
+ ```
264
+
265
+ Environment variable alternative:
266
+
267
+ ```env
268
+ IPFS_GATEWAY_FERAL_FILE=https://ipfs.feralfile.com/ipfs/
269
+ IPFS_GATEWAY_DEFAULT=https://ipfs.filebase.io/ipfs/
270
+ ```
271
+
272
+ ## Raster API
273
+
274
+ `find` resolves tokens, series, and artist addresses through Raster's public GraphQL API ([docs.api.raster.art](https://docs.api.raster.art/)), and with the default `tokenSource` so do `build` and `enrich`. No configuration is required; two environment variables adjust the integration:
221
275
 
222
276
  ```env
223
277
  # API key sent as the x-api-key header. The API currently answers without
@@ -262,6 +316,13 @@ You can also manage devices independently with:
262
316
 
263
317
  Setup and `device add` both preserve existing devices. Adding a device with the same host as an existing one updates it in place.
264
318
 
319
+ For an explicitly enrolled owner Tailscale connection, use the FF1's Tailscale IPv4
320
+ address with the **same physical `--id` and existing `--name`**. Save its old
321
+ host first. This updates that device in place, preserving its list position,
322
+ API key and ID; relayer credentials remain in the vault under the same ID.
323
+ The configured host is replaced, with no automatic LAN/Tailscale failover.
324
+ See the [owner-access procedure](https://github.com/feral-file/ffos/blob/36e25cec84505271a7232d6936bb46a19fab5cfd/docs/OWNER_TAILSCALE.md).
325
+
265
326
  Selection rules when sending:
266
327
 
267
328
  - If you omit `-d`, the first configured device is used.
package/docs/EXAMPLES.md CHANGED
@@ -64,6 +64,9 @@ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --publish
64
64
 
65
65
  # Build, play on a named device, and skip interactive prompts
66
66
  npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play -d "Living Room Display" -y
67
+
68
+ # -y alone also plays (the scripted default); add --no-play to build and save without casting
69
+ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 -y --no-play
67
70
  ```
68
71
 
69
72
  OpenSea and Verse accept both item and collection/series URLs:
@@ -258,14 +261,14 @@ the flag; without it those items are skipped rather than guessed at, because
258
261
  the wrong network returns a different artwork's metadata. A Tezos playlist
259
262
  needs no flag.
260
263
 
261
- Items the indexer cannot resolve are listed rather than guessed at:
264
+ Items the token source cannot resolve are listed rather than guessed at:
262
265
 
263
266
  ```text
264
267
  Nothing to enrich
265
268
 
266
269
  Skipped 3:
267
270
  Kim Asendorf — PXL NET — no provenance.contract chain/address/tokenId to look up
268
- Untitled — the indexer returned nothing for it
271
+ Untitled — the token source returned nothing for it
269
272
  Pre-Process — chain "evm" names a family; pass --assume-ethereum to assert the network
270
273
  ```
271
274
 
@@ -81,7 +81,7 @@ The current code and internal context imply these practical goals:
81
81
 
82
82
  ### Current behavior
83
83
 
84
- The current implementation still depends on Feral File-operated infrastructure for some data retrieval paths, including the hardcoded production indexer endpoint used for NFT lookup.
84
+ Token data for `find`, `build`, and `enrich` comes from the Feral File indexer (ff-indexer-v2) by default. Raster's public GraphQL API is selectable with `tokenSource: "raster"` and is under evaluation as the replacement (feral-file#3576); it stays opt-in until the data bugs reported on feral-file#3546 are fixed. One token source answers a whole run; there is no fallback between them.
85
85
 
86
86
  ### Design direction
87
87
 
@@ -105,7 +105,7 @@ Based on the code today, the CLI is responsible for:
105
105
  - resolving marketplace URLs, on-chain coordinates, and wallet addresses into playlists (`find`)
106
106
  - running feed fetches, address queries, contract-based NFT queries, domain resolution, playlist building, verification, publishing, and playback
107
107
  - supporting a deterministic build path from structured JSON (`build`)
108
- - building DP-1 playlist envelopes from NFT metadata or direct media URLs via `dp1-js` document and leaf builders (so constructed playlists stay schema-conformant), including inline Ref Manifests on items that carry indexer metadata
108
+ - building DP-1 playlist envelopes from NFT metadata or direct media URLs via `dp1-js` document and leaf builders (so constructed playlists stay schema-conformant), including inline Ref Manifests on items that carry token-source metadata
109
109
  - validating and verifying playlist structure and signatures
110
110
  - signing playlists when a private key is configured
111
111
  - publishing validated playlists to configured feed servers
@@ -159,13 +159,17 @@ Today the CLI groups into these workflow areas:
159
159
  - `find`
160
160
  - `build`
161
161
 
162
- An item's `provenance.contract` is read from the indexer row, not inferred. Each
163
- row carries the token's own `standard` (`erc721`, `erc1155`, `fa2`) and its
164
- CAIP-2 `chain` (`eip155:1`, `tezos:mainnet`), and both are written through. The
165
- client's own detection sees a chain name and an address and nothing else, so it
166
- answers `erc721` for every EVM contract; it applies only to rows that carry no
167
- standard, and to the CID a lookup by coordinate must build before the indexer
168
- has been asked anything. A row naming a chain DP-1 has no word for is recorded
162
+ An item's `provenance.contract` is read from the token source's row, not
163
+ inferred. Each row carries the token's own standard and its CAIP-2 `chain`
164
+ (`eip155:1`, `tezos:NetXdQprcVkpaWU` on Raster, `tezos:mainnet` on the
165
+ indexer), and both are written through. Raster standards DP-1 cannot name
166
+ (`CRYPTOPUNKS`, `ORDINAL_INSCRIPTION`) are written as `other`. Raster
167
+ lowercases EVM addresses; they are checksummed before use, so an item's `id`
168
+ (derived from the address and token id) and its provenance are the same
169
+ whichever source built it. The client's own detection sees a chain name and an
170
+ address and nothing else, so it answers `erc721` for every EVM contract; it
171
+ applies only to rows that carry no standard, and to the CID a lookup by
172
+ coordinate must build before the indexer has been asked anything. A row naming a chain DP-1 has no word for is recorded
169
173
  as `other` — the item is still built, and the client's guess is never
170
174
  substituted for a chain the row already named.
171
175
 
@@ -284,13 +288,13 @@ and nothing else. `enrich` is the repair path for those.
284
288
  - It writes `inlineManifest` and nothing else. `source`, `duration`, `title`,
285
289
  `id`, and `display` are curator decisions and survive untouched even when the
286
290
  indexer disagrees with them.
287
- - An item the indexer cannot resolve is reported, never synthesized. The same
291
+ - An item the token source cannot resolve is reported, never synthesized. The same
288
292
  rule the manifest mapping follows applies here: the CLI does not invent fields
289
293
  it cannot substantiate, and an invented artist line inside a signed document
290
294
  is worse than an absent one.
291
- - DP-1 names the EVM family `evm`; the FF indexer names it `ethereum`. Enrich
292
- translates between the two. Unlisted chain names pass through so the indexer
293
- decides what it supports.
295
+ - DP-1 names the EVM family `evm`; the token sources name it `ethereum`. Enrich
296
+ translates between the two. Unlisted chain names pass through so the token
297
+ source decides what it supports.
294
298
  - Enrichment changes the document, so both the `signatures[]` envelope and the
295
299
  legacy flat `signature` are removed and the playlist must be re-signed.
296
300
  Leaving a stale envelope in place would produce a file that fails
@@ -302,9 +306,10 @@ and nothing else. `enrich` is the repair path for those.
302
306
  discards the integrity hash the remote manifest is checked against. That
303
307
  conversion, if ever wanted, belongs behind its own flag.
304
308
  - Enrichment looks each distinct coordinate up once, however many items share
305
- it. A token the indexer has not seen triggers an indexing job and a poll that
306
- can take minutes, and a playlist that repeats a work would otherwise submit
307
- that job once per appearance.
309
+ it. On Raster the lookups are batched; on the indexer a token it
310
+ has not seen triggers an indexing job and a poll that can take minutes, and a
311
+ playlist that repeats a work would otherwise submit that job once per
312
+ appearance.
308
313
  - Overriding an item's title changes the manifest's content, so it also changes
309
314
  the manifest's `id`. The indexer derives that id from the token coordinate,
310
315
  so two items of one artwork receive the same one; leaving it alone while
@@ -352,7 +357,8 @@ and nothing else. `enrich` is the repair path for those.
352
357
  nor the response can say which network is meant. Ethereum is chosen because
353
358
  it is the only EVM network this client can reach — `buildTokenCID` maps
354
359
  ethereum to `eip155:1` and tezos to `tezos:mainnet` and throws for anything
355
- else. The residual risk is narrow: an L2 work whose address and token id also
360
+ else, and the Raster token source likewise builds Ethereum and Tezos mainnet
361
+ only. The residual risk is narrow: an L2 work whose address and token id also
356
362
  exist on Ethereum would take the Ethereum work's metadata. The command
357
363
  is the operator's to make: `--assume-ethereum` asserts it, and without that
358
364
  flag `evm` items are skipped with a reason that names the flag. The warning