@feralfile/cli 2.10.1 → 4.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.
Files changed (59) hide show
  1. package/README.md +31 -23
  2. package/config.json.example +6 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +40 -0
  6. package/dist/src/commands/config.js +1 -0
  7. package/dist/src/commands/device.js +85 -40
  8. package/dist/src/commands/enrich.js +18 -23
  9. package/dist/src/commands/find.js +144 -106
  10. package/dist/src/commands/helpers/device-control.js +937 -0
  11. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  12. package/dist/src/commands/helpers/playlist-display.js +2 -15
  13. package/dist/src/commands/helpers/prompt.js +85 -10
  14. package/dist/src/commands/play.js +162 -84
  15. package/dist/src/commands/report.js +251 -0
  16. package/dist/src/commands/setup.js +82 -7
  17. package/dist/src/commands/status.js +4 -1
  18. package/dist/src/config.js +256 -6
  19. package/dist/src/main.js +14 -5
  20. package/dist/src/utilities/control/codec.js +578 -0
  21. package/dist/src/utilities/control/control-errors.js +157 -0
  22. package/dist/src/utilities/control/controller-store.js +964 -0
  23. package/dist/src/utilities/control/jwk.js +141 -0
  24. package/dist/src/utilities/control/pairing-channel.js +52 -0
  25. package/dist/src/utilities/control/pairing.js +416 -0
  26. package/dist/src/utilities/control/registration.js +391 -0
  27. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  28. package/dist/src/utilities/control/secure-transport.js +850 -0
  29. package/dist/src/utilities/control/strict-json.js +376 -0
  30. package/dist/src/utilities/device-lookup.js +36 -11
  31. package/dist/src/utilities/device-network.js +129 -0
  32. package/dist/src/utilities/device-upsert.js +1 -1
  33. package/dist/src/utilities/feral-file-artwork.js +2 -2
  34. package/dist/src/utilities/ff1-compatibility.js +87 -11
  35. package/dist/src/utilities/ff1-device.js +161 -78
  36. package/dist/src/utilities/ff1-relayer.js +34 -14
  37. package/dist/src/utilities/handoff-client.js +23 -9
  38. package/dist/src/utilities/index.js +270 -259
  39. package/dist/src/utilities/media-url.js +117 -0
  40. package/dist/src/utilities/neort-marketplace.js +3 -3
  41. package/dist/src/utilities/nft-indexer.js +12 -376
  42. package/dist/src/utilities/playlist-builder.js +11 -2
  43. package/dist/src/utilities/playlist-cast.js +6 -6
  44. package/dist/src/utilities/playlist-source.js +223 -34
  45. package/dist/src/utilities/raster-client.js +101 -14
  46. package/dist/src/utilities/relay-client.js +774 -0
  47. package/dist/src/utilities/ssh-access.js +192 -58
  48. package/dist/src/utilities/support-report.js +511 -0
  49. package/dist/src/utilities/temporary-channel.js +139 -0
  50. package/dist/src/utilities/token-item.js +428 -0
  51. package/dist/src/utilities/token-source-contract.js +55 -0
  52. package/dist/src/utilities/token-source-indexer.js +99 -0
  53. package/dist/src/utilities/token-source-raster.js +378 -0
  54. package/dist/src/utilities/token-source.js +48 -0
  55. package/docs/CONFIGURATION.md +331 -28
  56. package/docs/EXAMPLES.md +33 -33
  57. package/docs/PROJECT_SPEC.md +55 -36
  58. package/docs/README.md +145 -43
  59. package/package.json +5 -2
@@ -1,7 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PlaylistUrlLoadError = void 0;
3
4
  exports.isPlaylistSourceUrl = isPlaylistSourceUrl;
4
5
  exports.loadPlaylistSource = loadPlaylistSource;
6
+ exports.buildMediaPlaySource = buildMediaPlaySource;
7
+ exports.refusalForLoadFailure = refusalForLoadFailure;
8
+ exports.checkWebPageUrl = checkWebPageUrl;
5
9
  exports.resolvePlaySource = resolvePlaySource;
6
10
  const http_1 = require("./http");
7
11
  const fs_1 = require("fs");
@@ -27,12 +31,87 @@ function isPlaylistSourceUrl(source) {
27
31
  return false;
28
32
  }
29
33
  }
34
+ /**
35
+ * PlaylistUrlLoadError is a failure to load an http(s) URL as a playlist,
36
+ * tagged with how it failed so `resolvePlaySource` can tell a missing playlist
37
+ * from a URL that is simply not a playlist.
38
+ *
39
+ * - `status`: the server answered with a non-OK HTTP status (`status` is set).
40
+ * - `network`: no answer at all (DNS, refused connection, timeout); `code` is
41
+ * the underlying system error code when fetch exposes one.
42
+ * - `read`: an OK answer whose body was cut off (timeout or dropped connection
43
+ * mid-download). Nothing was learned about the URL, so it is not "not a
44
+ * playlist" either.
45
+ * - `json`: an OK answer, read in full, that is not JSON. This is what every
46
+ * ordinary web page and media file produces.
47
+ */
48
+ class PlaylistUrlLoadError extends Error {
49
+ kind;
50
+ status;
51
+ code;
52
+ constructor(message, detail) {
53
+ super(message);
54
+ this.name = 'PlaylistUrlLoadError';
55
+ this.kind = detail.kind;
56
+ this.status = detail.status;
57
+ this.code = detail.code;
58
+ }
59
+ }
60
+ exports.PlaylistUrlLoadError = PlaylistUrlLoadError;
61
+ /**
62
+ * networkErrorCode digs the system error code (ENOTFOUND, ECONNREFUSED, …)
63
+ * out of a fetch failure. Node's fetch throws `TypeError('fetch failed')` and
64
+ * puts the real error on `cause`.
65
+ *
66
+ * @param {unknown} error - What fetch threw
67
+ * @returns {string | undefined} The code, when there is one
68
+ */
69
+ function networkErrorCode(error) {
70
+ const cause = error?.cause;
71
+ return typeof cause?.code === 'string' ? cause.code : undefined;
72
+ }
73
+ /**
74
+ * loadPlaylistUrl fetches an http(s) URL and parses it as a playlist.
75
+ *
76
+ * @param {string} url - Trimmed http(s) URL
77
+ * @returns {Promise<LoadedPlaylist>} The parsed document
78
+ * @throws {PlaylistUrlLoadError} On any failure, tagged with its kind
79
+ */
80
+ async function loadPlaylistUrl(url) {
81
+ let response;
82
+ try {
83
+ response = await (0, http_1.fetchWithTimeout)(url);
84
+ }
85
+ catch (error) {
86
+ throw new PlaylistUrlLoadError(`Could not reach ${url}: ${error.message}`, {
87
+ kind: 'network',
88
+ code: networkErrorCode(error),
89
+ });
90
+ }
91
+ if (!response.ok) {
92
+ throw new PlaylistUrlLoadError(`Failed to fetch playlist URL: ${response.status} ${response.statusText}`, { kind: 'status', status: response.status });
93
+ }
94
+ let playlistText;
95
+ try {
96
+ playlistText = await response.text();
97
+ }
98
+ catch (error) {
99
+ throw new PlaylistUrlLoadError(`Failed to read playlist response from ${url}: ${error.message}`, { kind: 'read' });
100
+ }
101
+ try {
102
+ return { playlist: JSON.parse(playlistText), source: url, sourceType: 'url' };
103
+ }
104
+ catch (error) {
105
+ throw new PlaylistUrlLoadError(`Invalid JSON from playlist URL ${url}: ${error.message}`, { kind: 'json' });
106
+ }
107
+ }
30
108
  /**
31
109
  * Load a DP-1 playlist from a local file or hosted URL.
32
110
  *
33
111
  * @param {string} source - Playlist file path or URL
34
112
  * @returns {Promise<LoadedPlaylist>} Loaded playlist payload with source metadata
35
- * @throws {Error} When source is empty, cannot be loaded, or JSON is invalid
113
+ * @throws {Error} When source is empty, cannot be loaded, or JSON is invalid; a URL
114
+ * failure is a PlaylistUrlLoadError
36
115
  */
37
116
  async function loadPlaylistSource(source) {
38
117
  const trimmedSource = source.trim();
@@ -40,27 +119,7 @@ async function loadPlaylistSource(source) {
40
119
  throw new Error('Playlist source is required');
41
120
  }
42
121
  if (isPlaylistSourceUrl(trimmedSource)) {
43
- const response = await (0, http_1.fetchWithTimeout)(trimmedSource);
44
- if (!response.ok) {
45
- throw new Error(`Failed to fetch playlist URL: ${response.status} ${response.statusText}`);
46
- }
47
- let playlistText;
48
- try {
49
- playlistText = await response.text();
50
- }
51
- catch (error) {
52
- throw new Error(`Failed to read playlist response from ${trimmedSource}: ${error.message}`);
53
- }
54
- try {
55
- return {
56
- playlist: JSON.parse(playlistText),
57
- source: trimmedSource,
58
- sourceType: 'url',
59
- };
60
- }
61
- catch (error) {
62
- throw new Error(`Invalid JSON from playlist URL ${trimmedSource}: ${error.message}`);
63
- }
122
+ return loadPlaylistUrl(trimmedSource);
64
123
  }
65
124
  let fileText;
66
125
  try {
@@ -81,24 +140,149 @@ async function loadPlaylistSource(source) {
81
140
  }
82
141
  }
83
142
  /**
84
- * Resolve a `play` command argument to a playable source.
143
+ * buildMediaPlaySource wraps a URL in a synthesized single-item playlist, the
144
+ * shape `display` sends for a direct media file or web page.
145
+ *
146
+ * @param {string} url - Trimmed http(s) URL
147
+ * @param {Object} [options] - Item options
148
+ * @param {number} [options.duration] - Explicit display seconds; omit for auto timing
149
+ * @param {boolean} [options.asWebPage] - Time the item as a page whatever the URL looks like
150
+ * @param {string} [options.note] - Carried to the caller (see PlaySource.note)
151
+ * @returns {Promise<PlaySource>} A `media` play source
152
+ */
153
+ async function buildMediaPlaySource(url, options = {}) {
154
+ const item = buildUrlItem(url, options.duration, { asWebPage: options.asWebPage });
155
+ const playlist = (await buildDP1Playlist({ items: [item], title: item.title }));
156
+ return options.note
157
+ ? { kind: 'media', playlist, source: url, note: options.note }
158
+ : { kind: 'media', playlist, source: url };
159
+ }
160
+ /**
161
+ * looksLikePlaylistUrl reports whether a URL names a JSON document by its
162
+ * path. A `.json` URL that fails to load is a broken playlist link, never a
163
+ * page someone meant to show on a wall.
164
+ *
165
+ * @param {string} url - http(s) URL
166
+ * @returns {boolean} True when the path ends in `.json`
167
+ */
168
+ function looksLikePlaylistUrl(url) {
169
+ try {
170
+ return new URL(url).pathname.toLowerCase().endsWith('.json');
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ }
176
+ /**
177
+ * refusalForLoadFailure returns the error `display` must stop with when a URL it
178
+ * tried to load is known not to exist, or null when it may still be sent.
179
+ *
180
+ * Wrapping a URL that does not exist sends the device an error page and
181
+ * reports "Displaying", the silent wrong result an agent trusting exit codes
182
+ * cannot catch. These are refused:
183
+ * - HTTP 404 or 410: nothing is there.
184
+ * - DNS failure (ENOTFOUND): the host does not exist. A temporary resolver
185
+ * failure (EAI_AGAIN) is not refused.
186
+ * - A `.json` URL that cannot be loaded or parsed: a broken playlist link.
187
+ * Real marketplace pages answer 200 to a command-line client (checked against
188
+ * OpenSea, Art Blocks, objkt, SuperRare, Feral File, Neort, Verse), so this
189
+ * rule is safe on discovery hosts too.
190
+ *
191
+ * @param {string} url - The URL that failed to load
192
+ * @param {unknown} failure - What loading threw
193
+ * @returns {Error | null} The refusal, or null
194
+ */
195
+ function refusalForLoadFailure(url, failure) {
196
+ const loadFailure = failure;
197
+ if (loadFailure?.status === 404 || loadFailure?.status === 410) {
198
+ return new Error(`Nothing at ${url} (HTTP ${loadFailure.status}). Check the URL; nothing was sent to the device.`);
199
+ }
200
+ if (loadFailure?.code === 'ENOTFOUND') {
201
+ return new Error(`No such host for ${url}. Check the URL; nothing was sent to the device.`);
202
+ }
203
+ if (looksLikePlaylistUrl(url)) {
204
+ return new Error(`${url} looks like a playlist (.json) but could not be loaded: ${failure?.message}`);
205
+ }
206
+ return null;
207
+ }
208
+ /**
209
+ * noteForLoadFailure returns the line `display` prints when it sends a URL it
210
+ * could not read itself, or undefined when the URL was read in full and is
211
+ * simply not a playlist (an ordinary page or media file).
212
+ *
213
+ * @param {unknown} failure - What loading threw
214
+ * @returns {string | undefined} The note, if any
215
+ */
216
+ function noteForLoadFailure(failure) {
217
+ const loadFailure = failure;
218
+ if (loadFailure?.kind === 'json') {
219
+ return undefined;
220
+ }
221
+ return `ff-cli could not read this URL itself (${failure?.message}); sending it to the device anyway.`;
222
+ }
223
+ /**
224
+ * checkWebPageUrl fetches a URL that `display --web` is about to send, refusing
225
+ * one that does not exist and returning a note when it could not be read.
226
+ *
227
+ * `--web` skips the playlist probe, so without this check a typo or a dead
228
+ * link handed to `--web` (the hint `display` prints for marketplace pages) would
229
+ * be cast as an error page with a successful exit. Only the status line is
230
+ * needed; the body is cancelled unread. The `.json` rule does not apply: the
231
+ * caller said this is a page.
232
+ *
233
+ * @param {string} url - Trimmed http(s) URL
234
+ * @returns {Promise<string | undefined>} A note when the URL could not be read
235
+ * @throws {Error} When the URL answers 404/410 or its host does not resolve
236
+ */
237
+ async function checkWebPageUrl(url) {
238
+ let failure;
239
+ try {
240
+ const response = await (0, http_1.fetchWithTimeout)(url);
241
+ await response.body?.cancel().catch(() => undefined);
242
+ if (response.ok) {
243
+ return undefined;
244
+ }
245
+ failure = new PlaylistUrlLoadError(`HTTP ${response.status} ${response.statusText}`.trim(), {
246
+ kind: 'status',
247
+ status: response.status,
248
+ });
249
+ }
250
+ catch (error) {
251
+ failure = new PlaylistUrlLoadError(`Could not reach ${url}: ${error.message}`, {
252
+ kind: 'network',
253
+ code: networkErrorCode(error),
254
+ });
255
+ }
256
+ if (failure.status === 404 || failure.status === 410 || failure.code === 'ENOTFOUND') {
257
+ throw refusalForLoadFailure(url, failure);
258
+ }
259
+ return noteForLoadFailure(failure);
260
+ }
261
+ /**
262
+ * Resolve a `display` command argument to a source it can send.
85
263
  *
86
264
  * Files are always treated as playlists. URLs are tried as playlists first;
87
- * if loading fails (network, non-JSON, etc.), the URL is wrapped in a
88
- * synthesized single-item playlist so direct media URLs still work.
265
+ * a URL whose body is not a playlist (an ordinary web page, a video, an
266
+ * image) is wrapped in a synthesized single-item playlist so direct media
267
+ * URLs and web pages still work.
89
268
  *
90
269
  * The "URL → playlist or media" path uses throw-and-fallback because there
91
270
  * is no cheap way to distinguish a 200-OK media file from a malformed
92
- * playlist response without trying to parse it. Keeping the fallback
93
- * scoped to one helper means the play action no longer carries the
94
- * `loadedAsPlaylist` boolean dance.
271
+ * playlist response without trying to parse it.
272
+ *
273
+ * Not every failure falls back: a URL that does not exist is refused (see
274
+ * refusalForLoadFailure). Other failures (403 from bot protection, 5xx,
275
+ * timeouts, a body cut off mid-download) still fall back, with a `note`,
276
+ * because the device fetches media itself and often succeeds where a
277
+ * command-line client is refused.
95
278
  *
96
279
  * @param source - User-supplied path or URL
97
280
  * @param defaultDuration - Explicit duration (seconds) for the synthesized
98
281
  * media item; omit for auto timing (video/audio URLs play their natural
99
282
  * length per DP-1 §4.1, static media uses the configured default)
100
283
  * @returns Resolved playlist + metadata describing how it was loaded
101
- * @throws Error When `source` is empty, or is a non-URL file path that cannot be loaded
284
+ * @throws Error When `source` is empty, is a non-URL file path that cannot be loaded,
285
+ * or is a URL that does not exist (see above)
102
286
  */
103
287
  async function resolvePlaySource(source, defaultDuration) {
104
288
  const trimmed = source.trim();
@@ -115,7 +299,7 @@ async function resolvePlaySource(source, defaultDuration) {
115
299
  };
116
300
  }
117
301
  try {
118
- const loaded = await loadPlaylistSource(trimmed);
302
+ const loaded = await loadPlaylistUrl(trimmed);
119
303
  return {
120
304
  kind: 'playlist',
121
305
  playlist: loaded.playlist,
@@ -123,9 +307,14 @@ async function resolvePlaySource(source, defaultDuration) {
123
307
  source: loaded.source,
124
308
  };
125
309
  }
126
- catch {
127
- const item = buildUrlItem(trimmed, defaultDuration);
128
- const playlist = (await buildDP1Playlist({ items: [item], title: item.title }));
129
- return { kind: 'media', playlist, source: trimmed };
310
+ catch (error) {
311
+ const refusal = refusalForLoadFailure(trimmed, error);
312
+ if (refusal) {
313
+ throw refusal;
314
+ }
315
+ return buildMediaPlaySource(trimmed, {
316
+ duration: defaultDuration,
317
+ note: noteForLoadFailure(error),
318
+ });
130
319
  }
131
320
  }
@@ -3,12 +3,12 @@
3
3
  * Raster API client (api.raster.art — public GraphQL API).
4
4
  *
5
5
  * Resolves on-chain coordinates and artist addresses into Raster artworks
6
- * (series) and artists (catalog) — the inputs the `ff-cli find` flow needs
7
- * before handing tokens to the FF GraphQL indexer for DP-1 conversion.
6
+ * (series) and artists (catalog) for `ff-cli find`, and fetches the full token
7
+ * rows the Raster token source (token-source-raster.ts) turns into DP-1 items.
8
8
  *
9
9
  * This module is pure data: it does not construct DP-1 items, send anything
10
- * to devices, or build playlists. The fulfiller layer (FF indexer) consumes
11
- * the `{ chain, contractAddress, tokenId }` shapes this module emits.
10
+ * to devices, or build playlists. Mapping a RasterToken to the vendor-neutral
11
+ * token shape happens in token-source-raster.ts.
12
12
  *
13
13
  * Chain filter: the FF indexer only supports Ethereum mainnet and Tezos
14
14
  * mainnet. Token lists are filtered here at the CAIP level and a
@@ -57,10 +57,14 @@ var __importStar = (this && this.__importStar) || (function () {
57
57
  };
58
58
  })();
59
59
  Object.defineProperty(exports, "__esModule", { value: true });
60
- exports.RasterUnreachableError = void 0;
60
+ exports.RASTER_TOKEN_BATCH_SIZE = exports.TOKEN_FIELDS = exports.RasterUnreachableError = void 0;
61
+ exports.rasterQuery = rasterQuery;
61
62
  exports.resolveTokenToArtwork = resolveTokenToArtwork;
62
63
  exports.resolveSlugToArtwork = resolveSlugToArtwork;
63
64
  exports.resolveAddressToArtist = resolveAddressToArtist;
65
+ exports.buildTokenByRefBatchQuery = buildTokenByRefBatchQuery;
66
+ exports.fetchTokensByRef = fetchTokensByRef;
67
+ exports.fetchTokensOwned = fetchTokensOwned;
64
68
  exports.listArtworkTokens = listArtworkTokens;
65
69
  exports.listArtistArtworks = listArtistArtworks;
66
70
  exports.formatSummaryLine = formatSummaryLine;
@@ -91,7 +95,7 @@ const INDEXER_CHAIN_TO_CAIP = {
91
95
  * Raster could not be reached at the network level (connect failure, broken
92
96
  * IPv6 path, timeout). Distinct from API-level errors so callers that hold
93
97
  * usable token coordinates can degrade gracefully instead of dying — Raster
94
- * only enriches a find, it is not required to build a playable playlist.
98
+ * only enriches a find, it is not required to build a playlist to display.
95
99
  */
96
100
  class RasterUnreachableError extends Error {
97
101
  constructor(message) {
@@ -241,6 +245,93 @@ async function resolveAddressToArtist(address) {
241
245
  const artist = data.address?.artists?.[0];
242
246
  return artist ? toRasterArtist(artist) : null;
243
247
  }
248
+ /**
249
+ * TOKEN_FIELDS is the one selection every token-reading query shares, so the
250
+ * three entry points (by reference, by owner, by artwork) feed the same mapper.
251
+ */
252
+ exports.TOKEN_FIELDS = `fragment TokenFields on Token {
253
+ chainId contractAddress tokenId tokenStandard name description
254
+ media { previewHash previewType contentUrl normalizedContentUrl contentType }
255
+ artworks { id slug title description platform { id name } artists { id name slug } }
256
+ artists { id name slug }
257
+ }`;
258
+ /**
259
+ * RASTER_TOKEN_BATCH_SIZE caps aliased `tokenByRef` lookups per request.
260
+ * 40 is the size probed on feral-file#3546 (one request, 2.9 s); larger
261
+ * batches are unmeasured, so raise it only with a new measurement.
262
+ */
263
+ exports.RASTER_TOKEN_BATCH_SIZE = 40;
264
+ /**
265
+ * buildTokenByRefBatchQuery builds one query with `count` aliased lookups.
266
+ *
267
+ * Refs travel as numbered variables (`$r0`…), never interpolated into the
268
+ * text, so token ids above 2^53 (every Feral File id) arrive as strings and
269
+ * no caller-supplied value can alter the document.
270
+ */
271
+ function buildTokenByRefBatchQuery(count) {
272
+ const indices = Array.from({ length: count }, (_, i) => i);
273
+ const params = indices.map((i) => `$r${i}: TokenRefInput!`).join(', ');
274
+ const fields = indices.map((i) => ` t${i}: tokenByRef(ref: $r${i}) { ...TokenFields }`);
275
+ return `query TokensByRef(${params}) {\n${fields.join('\n')}\n}\n${exports.TOKEN_FIELDS}`;
276
+ }
277
+ /**
278
+ * fetchTokensByRef looks up one batch of coordinates in a single request.
279
+ *
280
+ * The result is aligned with the input; a token Raster does not cover, or one
281
+ * that was burned, is `null` (Raster answers null rather than an error).
282
+ * Batching across requests, progress, and per-batch failure handling belong to
283
+ * the caller, which is why more than RASTER_TOKEN_BATCH_SIZE refs is refused
284
+ * instead of split here.
285
+ *
286
+ * @throws RasterUnreachableError on network failure; Error on API failure
287
+ */
288
+ async function fetchTokensByRef(refs) {
289
+ if (refs.length > exports.RASTER_TOKEN_BATCH_SIZE) {
290
+ throw new Error(`fetchTokensByRef takes at most ${exports.RASTER_TOKEN_BATCH_SIZE} refs per call.`);
291
+ }
292
+ if (refs.length === 0) {
293
+ return [];
294
+ }
295
+ const variables = {};
296
+ refs.forEach((ref, i) => {
297
+ variables[`r${i}`] = {
298
+ chainId: INDEXER_CHAIN_TO_CAIP[ref.chain],
299
+ contractAddress: ref.contractAddress,
300
+ tokenId: ref.tokenId,
301
+ };
302
+ });
303
+ const data = await rasterQuery(buildTokenByRefBatchQuery(refs.length), variables);
304
+ return refs.map((_, i) => data[`t${i}`] ?? null);
305
+ }
306
+ /**
307
+ * fetchTokensOwned reads one page of `address.tokensOwned`, the wallet walk.
308
+ *
309
+ * Raster resolves any well-formed address and returns an empty connection when
310
+ * it knows no holdings, so an empty page is an answer, not an error. Chain
311
+ * filtering is left to the caller, which owns the skipped-chain report.
312
+ */
313
+ async function fetchTokensOwned(address, options) {
314
+ const data = await rasterQuery(`query TokensOwned($address: String!, $first: Int, $after: String) {
315
+ address(address: $address) {
316
+ tokensOwned(first: $first, after: $after) {
317
+ totalCount
318
+ nodes { token { ...TokenFields } }
319
+ pageInfo { hasNextPage endCursor }
320
+ }
321
+ }
322
+ }
323
+ ${exports.TOKEN_FIELDS}`, { address: address.trim(), first: options.first, after: options.after ?? null });
324
+ const owned = data.address?.tokensOwned;
325
+ if (!owned) {
326
+ return { tokens: [], nextCursor: null, totalCount: 0 };
327
+ }
328
+ const { hasNextPage, endCursor } = owned.pageInfo;
329
+ return {
330
+ tokens: owned.nodes.map((node) => node.token),
331
+ nextCursor: hasNextPage && endCursor ? endCursor : null,
332
+ totalCount: owned.totalCount,
333
+ };
334
+ }
244
335
  /**
245
336
  * List tokens in a Raster artwork (series), one page at a time.
246
337
  *
@@ -257,11 +348,12 @@ async function listArtworkTokens(artworkId, options = {}) {
257
348
  const data = await rasterQuery(`query ArtworkTokens($id: ID!, $first: Int, $after: String) {
258
349
  artwork(id: $id) {
259
350
  tokens(first: $first, after: $after) {
260
- nodes { chainId contractAddress tokenId media { contentUrl contentType previewType } }
351
+ nodes { ...TokenFields }
261
352
  pageInfo { hasNextPage endCursor }
262
353
  }
263
354
  }
264
- }`, { id: artworkId, first: options.pageSize ?? null, after: options.cursor ?? null });
355
+ }
356
+ ${exports.TOKEN_FIELDS}`, { id: artworkId, first: options.pageSize ?? null, after: options.cursor ?? null });
265
357
  if (!data.artwork) {
266
358
  throw new Error(`Raster: artwork ${artworkId} not found.`);
267
359
  }
@@ -277,12 +369,7 @@ async function listArtworkTokens(artworkId, options = {}) {
277
369
  chain,
278
370
  contractAddress: raw.contractAddress,
279
371
  tokenId: raw.tokenId,
280
- mediaUrl: raw.media?.contentUrl ?? null,
281
- // Prefer the real MIME (`contentType`); today it is usually empty, so we
282
- // fall back to `previewType` as a last resort. NOTE: previewType only
283
- // describes the preview encoding, not the work, so this fallback can
284
- // misclassify (see the mediaType doc comment above). Tracked for revisit.
285
- mediaType: raw.media?.contentType || raw.media?.previewType || null,
372
+ token: raw,
286
373
  });
287
374
  }
288
375
  const { hasNextPage, endCursor } = data.artwork.tokens.pageInfo;