@feralfile/cli 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.json +3 -3
- package/dist/src/commands/enrich.js +23 -4
- package/dist/src/utilities/enrich-playlist.js +88 -79
- package/dist/src/utilities/nft-indexer.js +100 -18
- package/dist/src/utilities/playlist-builder.js +18 -1
- package/dist/src/utilities/playlist-cast.js +20 -1
- package/dist/src/utilities/playlist-publisher.js +74 -5
- package/docs/CONFIGURATION.md +28 -1
- package/docs/EXAMPLES.md +94 -9
- package/docs/PROJECT_SPEC.md +28 -15
- package/docs/README.md +27 -6
- package/package.json +3 -3
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feralfile/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@feralfile/source-resolver": "^1.0.0",
|
|
62
62
|
"@napi-rs/keyring": "1.3.0",
|
|
63
|
-
"axios": "^1.
|
|
63
|
+
"axios": "^1.20.0",
|
|
64
64
|
"bonjour-service": "^1.3.0",
|
|
65
65
|
"chalk": "^4.1.2",
|
|
66
66
|
"commander": "^11.1.0",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"dp1-js": "2.3.1",
|
|
69
69
|
"ff1": "^1.0.0",
|
|
70
70
|
"fuzzysort": "^3.1.0",
|
|
71
|
-
"viem": "^2.
|
|
71
|
+
"viem": "^2.56.3"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@types/node": "^24.7.1",
|
|
@@ -12,7 +12,7 @@ const path_1 = require("path");
|
|
|
12
12
|
const enrich_playlist_1 = require("../utilities/enrich-playlist");
|
|
13
13
|
const playlist_verifier_1 = require("../utilities/playlist-verifier");
|
|
14
14
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
15
|
-
const {
|
|
15
|
+
const { resolveTokenBatch } = require('../utilities/nft-indexer');
|
|
16
16
|
/**
|
|
17
17
|
* writePlaylistAtomically replaces a file only once the new bytes are safely on
|
|
18
18
|
* disk.
|
|
@@ -63,7 +63,16 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
|
63
63
|
// of it, or widen who can reach it. Carry the ownership across, and when
|
|
64
64
|
// that is not permitted refuse the in-place replacement instead of
|
|
65
65
|
// completing it with different access than the file had.
|
|
66
|
-
|
|
66
|
+
//
|
|
67
|
+
// Only where POSIX identity exists. Windows has no getuid/getgid and
|
|
68
|
+
// reports uid and gid as 0 on every stat, so the comparison there would
|
|
69
|
+
// always "differ" and chown a file whose ownership never moved. (libuv
|
|
70
|
+
// makes fchown a no-op on Windows, so nothing would break — but the
|
|
71
|
+
// branch would be asserting something it cannot know.)
|
|
72
|
+
const uid = process.getuid?.();
|
|
73
|
+
const gid = process.getgid?.();
|
|
74
|
+
const canCompareOwnership = uid !== undefined && gid !== undefined;
|
|
75
|
+
if (current && canCompareOwnership && (current.uid !== uid || current.gid !== gid)) {
|
|
67
76
|
try {
|
|
68
77
|
await handle.chown(current.uid, current.gid);
|
|
69
78
|
}
|
|
@@ -230,12 +239,22 @@ exports.enrichCommand = new commander_1.Command('enrich')
|
|
|
230
239
|
printedProgress = true;
|
|
231
240
|
process.stdout.write(chalk_1.default.dim(`\r ${done}/${count} looked up...`));
|
|
232
241
|
};
|
|
233
|
-
//
|
|
242
|
+
// resolveTokenBatch is (tokens, duration, onProgress) — the second
|
|
234
243
|
// positional is DP-1 display seconds, not the callback. Enrichment never
|
|
235
244
|
// sets duration (the curator's timing is not ours to touch), so it is
|
|
236
245
|
// passed undefined and the callback goes third. find.ts carries the same
|
|
237
246
|
// warning; getting this wrong silently returns unusable results.
|
|
238
|
-
|
|
247
|
+
//
|
|
248
|
+
// The raw results are used rather than getNFTTokenInfoBatch's items so
|
|
249
|
+
// the indexer's still rides beside each item: the item itself drops the
|
|
250
|
+
// still whenever it equals the indexer's chosen source, and enrichment
|
|
251
|
+
// needs it against the curator's source instead.
|
|
252
|
+
const lookup = async (tokens, onProgressCallback) => {
|
|
253
|
+
const results = await resolveTokenBatch(tokens, undefined, onProgressCallback);
|
|
254
|
+
return results
|
|
255
|
+
.filter((result) => result.success && result.item)
|
|
256
|
+
.map((result) => ({ ...result.item, still: result.still }));
|
|
257
|
+
};
|
|
239
258
|
const result = await (0, enrich_playlist_1.enrichPlaylistManifests)(playlist, lookup, {
|
|
240
259
|
force: options.force,
|
|
241
260
|
assumeEthereum: options.assumeEthereum,
|
|
@@ -129,7 +129,43 @@ function coordinateKeyOf(provenance) {
|
|
|
129
129
|
if (token.length === 0) {
|
|
130
130
|
return null;
|
|
131
131
|
}
|
|
132
|
-
|
|
132
|
+
const canonical = canonicalChain(chain);
|
|
133
|
+
const standard = effectiveStandard(canonical, address, contract.standard);
|
|
134
|
+
return `${canonical}:${standard}:${address.trim().toLowerCase()}:${token}`;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Standards the indexer keys CIDs on. Mirrors CID_STANDARDS in nft-indexer.js.
|
|
138
|
+
*/
|
|
139
|
+
const CID_STANDARDS = new Set(['erc721', 'erc1155', 'fa2']);
|
|
140
|
+
/**
|
|
141
|
+
* effectiveStandard names the standard a coordinate is looked up under, so the
|
|
142
|
+
* correlation key separates what the indexer separates.
|
|
143
|
+
*
|
|
144
|
+
* The indexer keys ERC-721 and ERC-1155 tokens at one address under different
|
|
145
|
+
* CIDs, so a hybrid contract can hold both standards at one token id. Two
|
|
146
|
+
* playlist items asserting different standards for one coordinate are two
|
|
147
|
+
* lookups, and each answer must reach only the item that asked for it.
|
|
148
|
+
*
|
|
149
|
+
* When the playlist asserts nothing the client detects, and this mirrors that
|
|
150
|
+
* detection (resolveTokenStandard in nft-indexer.js) so the request key and
|
|
151
|
+
* the key read off the response agree. The mirror is deliberate rather than
|
|
152
|
+
* an import: this module takes its lookup by injection and stays free of the
|
|
153
|
+
* indexer client. If the two ever drift, correlation fails closed — the item
|
|
154
|
+
* reports not-indexed — rather than attributing one token's metadata to
|
|
155
|
+
* another.
|
|
156
|
+
*/
|
|
157
|
+
function effectiveStandard(canonicalChainName, address, asserted) {
|
|
158
|
+
const normalized = typeof asserted === 'string' ? asserted.trim().toLowerCase() : '';
|
|
159
|
+
if (CID_STANDARDS.has(normalized)) {
|
|
160
|
+
return normalized;
|
|
161
|
+
}
|
|
162
|
+
if (canonicalChainName === 'tezos' || address.trim().startsWith('KT')) {
|
|
163
|
+
return 'fa2';
|
|
164
|
+
}
|
|
165
|
+
if (canonicalChainName === 'evm') {
|
|
166
|
+
return 'erc721';
|
|
167
|
+
}
|
|
168
|
+
return 'other';
|
|
133
169
|
}
|
|
134
170
|
/**
|
|
135
171
|
* describeItem names an item for operator output, preferring its title.
|
|
@@ -160,14 +196,22 @@ function coordinateFor(item) {
|
|
|
160
196
|
return null;
|
|
161
197
|
}
|
|
162
198
|
const normalized = chain.trim().toLowerCase();
|
|
163
|
-
|
|
199
|
+
const coordinate = {
|
|
164
200
|
chain: CHAIN_ALIASES[normalized] ?? normalized,
|
|
165
201
|
contractAddress: address,
|
|
166
202
|
tokenId: token,
|
|
167
203
|
};
|
|
204
|
+
// Carried as written. The indexer client accepts the standards its CIDs
|
|
205
|
+
// are keyed on and falls back to detection for anything else, so an
|
|
206
|
+
// unexpected value costs nothing beyond the lookup it would have lost anyway.
|
|
207
|
+
const standard = contract.standard;
|
|
208
|
+
if (typeof standard === 'string' && standard.trim().length > 0) {
|
|
209
|
+
coordinate.standard = standard.trim().toLowerCase();
|
|
210
|
+
}
|
|
211
|
+
return coordinate;
|
|
168
212
|
}
|
|
169
213
|
/**
|
|
170
|
-
*
|
|
214
|
+
* stillFor recovers a thumbnail the manifest builder suppressed.
|
|
171
215
|
*
|
|
172
216
|
* `resolveStillUri` blanks a still that equals the source it was given, on the
|
|
173
217
|
* reasonable ground that pointing a thumbnail at the artwork itself adds
|
|
@@ -179,64 +223,26 @@ function coordinateFor(item) {
|
|
|
179
223
|
* exists to repair: a live work the app cannot rasterize, whose grid tile
|
|
180
224
|
* stays empty.
|
|
181
225
|
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
226
|
+
* Recovery is the same rule applied against the curator's source instead of
|
|
227
|
+
* the indexer's: the still the indexer names for the token (`resolved.still`,
|
|
228
|
+
* its `display.image_url`) is used whenever it differs from the item's own
|
|
229
|
+
* source. It is the same field `find` and `build` write into
|
|
230
|
+
* `thumbnails.default`, and it is taken on the indexer's word, as they take
|
|
231
|
+
* it. No guess is made from a URL's shape — the indexer's source is often a
|
|
232
|
+
* live rendition and its stills are often extensionless CDN URLs, so a
|
|
233
|
+
* suffix check gets both cases wrong — and a lookup that names no still
|
|
234
|
+
* leaves the item without one.
|
|
185
235
|
*/
|
|
186
|
-
function
|
|
187
|
-
const
|
|
236
|
+
function stillFor(item, resolved) {
|
|
237
|
+
const still = typeof resolved.still === 'string' ? resolved.still.trim() : '';
|
|
188
238
|
const itemSource = typeof item.source === 'string' ? item.source.trim() : '';
|
|
189
|
-
if (!
|
|
239
|
+
if (!still || still === itemSource) {
|
|
190
240
|
return '';
|
|
191
241
|
}
|
|
192
|
-
if (!
|
|
242
|
+
if (!still.startsWith('http://') && !still.startsWith('https://')) {
|
|
193
243
|
return '';
|
|
194
244
|
}
|
|
195
|
-
|
|
196
|
-
// display.animation_url and media assets over display.image_url, so it is
|
|
197
|
-
// frequently a live HTML rendition — and writing one into the thumbnail slot
|
|
198
|
-
// is worse than leaving the slot empty: the grid still cannot rasterize it,
|
|
199
|
-
// and the item now claims a still it does not have.
|
|
200
|
-
//
|
|
201
|
-
// Only a URL that is demonstrably an image is used. The indexer exposes no
|
|
202
|
-
// separately identified still on the item this command receives, so the file
|
|
203
|
-
// extension is the evidence available; carrying a real image field through
|
|
204
|
-
// the batch result would be the better fix and belongs upstream.
|
|
205
|
-
return looksLikeImage(indexerSource) ? indexerSource : '';
|
|
206
|
-
}
|
|
207
|
-
/**
|
|
208
|
-
* Media the app can draw a grid tile from, matching the types
|
|
209
|
-
* playlist-builder.js already recognizes. SVG and video belong here: the app
|
|
210
|
-
* rasterizes both, and excluding them left works with the empty tile this
|
|
211
|
-
* command exists to remove. HTML is deliberately absent — an unrasterizable
|
|
212
|
-
* page in the thumbnail slot is worse than an empty slot, because the item
|
|
213
|
-
* then claims a still it does not have.
|
|
214
|
-
*/
|
|
215
|
-
const RASTERIZABLE_EXTENSIONS = [
|
|
216
|
-
'.png',
|
|
217
|
-
'.jpg',
|
|
218
|
-
'.jpeg',
|
|
219
|
-
'.gif',
|
|
220
|
-
'.webp',
|
|
221
|
-
'.avif',
|
|
222
|
-
'.bmp',
|
|
223
|
-
'.svg',
|
|
224
|
-
'.mp4',
|
|
225
|
-
'.webm',
|
|
226
|
-
];
|
|
227
|
-
/**
|
|
228
|
-
* looksLikeImage tests a URL's path extension, ignoring query and fragment.
|
|
229
|
-
*/
|
|
230
|
-
function looksLikeImage(url) {
|
|
231
|
-
let pathname = url;
|
|
232
|
-
try {
|
|
233
|
-
pathname = new URL(url).pathname;
|
|
234
|
-
}
|
|
235
|
-
catch {
|
|
236
|
-
pathname = url.split('?')[0].split('#')[0];
|
|
237
|
-
}
|
|
238
|
-
const lower = pathname.toLowerCase();
|
|
239
|
-
return RASTERIZABLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
245
|
+
return still;
|
|
240
246
|
}
|
|
241
247
|
/**
|
|
242
248
|
* hasThumbnail reports whether a manifest already carries a default still.
|
|
@@ -258,9 +264,10 @@ function hasThumbnail(manifest) {
|
|
|
258
264
|
* thumbnails) is what enrichment exists to fetch and is taken as given.
|
|
259
265
|
*/
|
|
260
266
|
function manifestFor(item, resolved) {
|
|
261
|
-
const still =
|
|
267
|
+
const still = stillFor(item, resolved);
|
|
268
|
+
const curatorTitle = typeof item.title === 'string' ? item.title.trim() : '';
|
|
269
|
+
const resolvedTitle = typeof resolved.title === 'string' ? resolved.title.trim() : '';
|
|
262
270
|
let manifest = resolved.inlineManifest;
|
|
263
|
-
let rekey = false;
|
|
264
271
|
if (manifest && still && !hasThumbnail(manifest)) {
|
|
265
272
|
manifest = {
|
|
266
273
|
...manifest,
|
|
@@ -269,30 +276,36 @@ function manifestFor(item, resolved) {
|
|
|
269
276
|
thumbnails: { default: { uri: still } },
|
|
270
277
|
},
|
|
271
278
|
};
|
|
272
|
-
rekey = true;
|
|
273
279
|
}
|
|
274
|
-
else if (!manifest
|
|
275
|
-
// Nothing was emitted because the
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
280
|
+
else if (!manifest) {
|
|
281
|
+
// Nothing was emitted because the token had only a title, or only a still
|
|
282
|
+
// that was suppressed against the indexer's source. Either can still be
|
|
283
|
+
// worth a manifest here, for a reason `find` and `build` never meet: their
|
|
284
|
+
// items always carry the token's title, so a title-only manifest would
|
|
285
|
+
// repeat what the item already says. A curator's item may carry no title
|
|
286
|
+
// at all, and the tombstone then shows nothing — so a substantiated title
|
|
287
|
+
// is written when the item lacks one, and a still is written whenever
|
|
288
|
+
// there is one, because it is the difference between a grid tile and an
|
|
289
|
+
// empty square.
|
|
290
|
+
const synthesizedTitle = curatorTitle || resolvedTitle;
|
|
291
|
+
const titleWorthCarrying = !curatorTitle && resolvedTitle.length > 0;
|
|
292
|
+
if (still || titleWorthCarrying) {
|
|
293
|
+
manifest = {
|
|
294
|
+
refVersion: '1.1.0',
|
|
295
|
+
id: derivedManifestId('', `${synthesizedTitle}\n${still}`),
|
|
296
|
+
created: MANIFEST_CREATED,
|
|
297
|
+
locale: 'en',
|
|
298
|
+
metadata: {
|
|
299
|
+
...(synthesizedTitle ? { title: synthesizedTitle } : {}),
|
|
300
|
+
...(still ? { thumbnails: { default: { uri: still } } } : {}),
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
290
304
|
}
|
|
291
305
|
if (!manifest) {
|
|
292
306
|
return undefined;
|
|
293
307
|
}
|
|
294
308
|
const metadata = manifest.metadata ?? {};
|
|
295
|
-
const curatorTitle = typeof item.title === 'string' ? item.title.trim() : '';
|
|
296
309
|
const overrideTitle = curatorTitle.length > 0 && metadata.title !== curatorTitle;
|
|
297
310
|
// The id is derived from the finished payload every time, not only when this
|
|
298
311
|
// command changed something. The indexer keys its id on the token
|
|
@@ -304,10 +317,6 @@ function manifestFor(item, resolved) {
|
|
|
304
317
|
//
|
|
305
318
|
// The original id is folded in, so the result stays anchored to the token
|
|
306
319
|
// rather than floating free of it, and stays deterministic across runs.
|
|
307
|
-
//
|
|
308
|
-
// `rekey` is read only to record that a thumbnail was recovered; the
|
|
309
|
-
// derivation below covers it either way.
|
|
310
|
-
void rekey;
|
|
311
320
|
const finalMetadata = overrideTitle ? { ...metadata, title: curatorTitle } : metadata;
|
|
312
321
|
return {
|
|
313
322
|
...manifest,
|
|
@@ -128,6 +128,33 @@ function detectTokenStandard(chain, contractAddress) {
|
|
|
128
128
|
}
|
|
129
129
|
return 'other';
|
|
130
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Token standards the indexer keys CIDs on. An asserted standard outside this
|
|
133
|
+
* set says nothing the CID can carry, so detection decides instead.
|
|
134
|
+
*/
|
|
135
|
+
const CID_STANDARDS = new Set(['erc721', 'erc1155', 'fa2']);
|
|
136
|
+
/**
|
|
137
|
+
* resolveTokenStandard picks the standard a CID is built with.
|
|
138
|
+
*
|
|
139
|
+
* detectTokenStandard cannot tell ERC-721 from ERC-1155 — it has only a chain
|
|
140
|
+
* and an address, and answers erc721 for every EVM contract. The indexer keys
|
|
141
|
+
* the two differently (`eip155:1:erc721:…` and `eip155:1:erc1155:…`), so an
|
|
142
|
+
* ERC-1155 token looked up by detection alone is simply never found. When the
|
|
143
|
+
* caller knows the standard — a DP-1 playlist carries it in
|
|
144
|
+
* `provenance.contract.standard` — that assertion wins.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} chain - Blockchain network
|
|
147
|
+
* @param {string} contractAddress - Contract address
|
|
148
|
+
* @param {string} [asserted] - Standard the caller vouches for, if any
|
|
149
|
+
* @returns {string} Token standard for the CID
|
|
150
|
+
*/
|
|
151
|
+
function resolveTokenStandard(chain, contractAddress, asserted) {
|
|
152
|
+
const normalized = typeof asserted === 'string' ? asserted.trim().toLowerCase() : '';
|
|
153
|
+
if (CID_STANDARDS.has(normalized)) {
|
|
154
|
+
return normalized;
|
|
155
|
+
}
|
|
156
|
+
return detectTokenStandard(chain, contractAddress);
|
|
157
|
+
}
|
|
131
158
|
/**
|
|
132
159
|
* Build CAIP-2 token CID for indexer v2
|
|
133
160
|
*
|
|
@@ -137,15 +164,19 @@ function detectTokenStandard(chain, contractAddress) {
|
|
|
137
164
|
* @param {string} chain - Blockchain network (ethereum, polygon, tezos, etc)
|
|
138
165
|
* @param {string} contractAddress - Contract address
|
|
139
166
|
* @param {string} tokenId - Token ID
|
|
167
|
+
* @param {string} [standard] - Asserted token standard; detected when omitted
|
|
140
168
|
* @returns {string} Token CID in CAIP-2 format
|
|
141
169
|
* @example
|
|
142
170
|
* // Returns: eip155:1:erc721:0xabc123:456
|
|
143
171
|
* const cid = buildTokenCID('ethereum', '0xabc123', '456');
|
|
144
172
|
* @example
|
|
173
|
+
* // Returns: eip155:1:erc1155:0xabc123:456
|
|
174
|
+
* const cid = buildTokenCID('ethereum', '0xabc123', '456', 'erc1155');
|
|
175
|
+
* @example
|
|
145
176
|
* // Returns: tezos:mainnet:fa2:KT1abc:789
|
|
146
177
|
* const cid = buildTokenCID('tezos', 'KT1abc', '789');
|
|
147
178
|
*/
|
|
148
|
-
function buildTokenCID(chain, contractAddress, tokenId) {
|
|
179
|
+
function buildTokenCID(chain, contractAddress, tokenId, standard) {
|
|
149
180
|
// Map chain names to CAIP-2 format (supports only Ethereum and Tezos)
|
|
150
181
|
const caip2Map = {
|
|
151
182
|
ethereum: 'eip155:1',
|
|
@@ -157,8 +188,8 @@ function buildTokenCID(chain, contractAddress, tokenId) {
|
|
|
157
188
|
if (!caip2Chain) {
|
|
158
189
|
throw new Error(`Unsupported chain: ${chain}. Only ethereum and tezos are supported.`);
|
|
159
190
|
}
|
|
160
|
-
const
|
|
161
|
-
return `${caip2Chain}:${
|
|
191
|
+
const resolvedStandard = resolveTokenStandard(chain, contractAddress, standard);
|
|
192
|
+
return `${caip2Chain}:${resolvedStandard}:${contractAddress}:${tokenId}`;
|
|
162
193
|
}
|
|
163
194
|
/**
|
|
164
195
|
* Unified GraphQL query for tokens from indexer v2
|
|
@@ -319,9 +350,10 @@ function getBestMediaUrl(display = {}, mediaAssets = []) {
|
|
|
319
350
|
*
|
|
320
351
|
* @param {Object} indexerData - Token fields from indexer GraphQL
|
|
321
352
|
* @param {string} chain - Blockchain network
|
|
353
|
+
* @param {string} [standard] - Asserted token standard; detected when omitted
|
|
322
354
|
* @returns {Object} Standardized token data
|
|
323
355
|
*/
|
|
324
|
-
function mapIndexerDataToStandardFormat(indexerData, chain) {
|
|
356
|
+
function mapIndexerDataToStandardFormat(indexerData, chain, standard) {
|
|
325
357
|
if (!indexerData) {
|
|
326
358
|
return {
|
|
327
359
|
success: false,
|
|
@@ -334,14 +366,14 @@ function mapIndexerDataToStandardFormat(indexerData, chain) {
|
|
|
334
366
|
const artistName = extractArtistName(display.artists);
|
|
335
367
|
const name = display.name || `Token #${indexerData.token_number}`;
|
|
336
368
|
const description = display.description || '';
|
|
337
|
-
const
|
|
369
|
+
const resolvedStandard = resolveTokenStandard(chain, indexerData.contract_address, standard);
|
|
338
370
|
return {
|
|
339
371
|
success: true,
|
|
340
372
|
token: {
|
|
341
373
|
chain,
|
|
342
374
|
contractAddress: indexerData.contract_address,
|
|
343
375
|
tokenId: indexerData.token_number,
|
|
344
|
-
standard,
|
|
376
|
+
standard: resolvedStandard,
|
|
345
377
|
name,
|
|
346
378
|
description,
|
|
347
379
|
image: {
|
|
@@ -492,8 +524,34 @@ function convertToDP1Item(tokenData, duration) {
|
|
|
492
524
|
return {
|
|
493
525
|
success: true,
|
|
494
526
|
item: dp1Item,
|
|
527
|
+
still: stillOf(token),
|
|
495
528
|
};
|
|
496
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* stillOf names the token's still image as the indexer identifies it, or ''.
|
|
532
|
+
*
|
|
533
|
+
* This is `display.image_url`, the field the indexer reserves for a still and
|
|
534
|
+
* the same one `buildInlineManifestForToken` writes into `thumbnails.default`.
|
|
535
|
+
* It is returned beside the DP-1 item rather than inside it because the item
|
|
536
|
+
* only carries the still when it differs from the item's own source, and a
|
|
537
|
+
* consumer building a manifest against a *different* source (see
|
|
538
|
+
* enrich-playlist.ts) needs the still regardless. Nothing about the URL's
|
|
539
|
+
* shape is inspected: the indexer's word that it is an image is the evidence,
|
|
540
|
+
* and a suffix check would reject the extensionless CDN URLs it commonly
|
|
541
|
+
* returns.
|
|
542
|
+
*
|
|
543
|
+
* @param {Object} token - Token in the internal standard format
|
|
544
|
+
* @returns {string} http(s) still URL, or '' when the indexer named none
|
|
545
|
+
*/
|
|
546
|
+
function stillOf(token) {
|
|
547
|
+
const image = token.image;
|
|
548
|
+
// Only `thumbnail` qualifies. `image.url` is the media getBestMediaUrl
|
|
549
|
+
// selected as the source, which is the live rendition whenever one exists —
|
|
550
|
+
// exactly what must never land in a thumbnail slot.
|
|
551
|
+
const candidate = typeof image === 'string' ? image : typeof image === 'object' && image ? image.thumbnail : '';
|
|
552
|
+
const trimmed = String(candidate || '').trim();
|
|
553
|
+
return trimmed.startsWith('http://') || trimmed.startsWith('https://') ? trimmed : '';
|
|
554
|
+
}
|
|
497
555
|
/**
|
|
498
556
|
* Get NFT token information from indexer and return as DP1 item (supports single or batch)
|
|
499
557
|
* @param {Object|Array} params - Token parameters (single object or array)
|
|
@@ -507,8 +565,8 @@ async function getNFTTokenInfo(params) {
|
|
|
507
565
|
return await getNFTTokenInfoBatch(params.tokens, duration);
|
|
508
566
|
}
|
|
509
567
|
// Handle single token
|
|
510
|
-
const { chain, contractAddress, tokenId } = params;
|
|
511
|
-
const result = await getNFTTokenInfoSingle({ chain, contractAddress, tokenId }, duration);
|
|
568
|
+
const { chain, contractAddress, tokenId, standard } = params;
|
|
569
|
+
const result = await getNFTTokenInfoSingle({ chain, contractAddress, tokenId, standard }, duration);
|
|
512
570
|
return result;
|
|
513
571
|
}
|
|
514
572
|
/**
|
|
@@ -530,7 +588,7 @@ async function getNFTTokenInfo(params) {
|
|
|
530
588
|
*/
|
|
531
589
|
async function getNFTTokenInfoSingle(params, duration, options = {}) {
|
|
532
590
|
let chain = params.chain;
|
|
533
|
-
const { contractAddress, tokenId } = params;
|
|
591
|
+
const { contractAddress, tokenId, standard } = params;
|
|
534
592
|
// DEFENSIVE: Auto-detect and correct chain based on contract address format
|
|
535
593
|
if (contractAddress.startsWith('KT') && chain !== 'tezos') {
|
|
536
594
|
logger.warn(`[NFT Indexer] Chain mismatch detected! Contract ${contractAddress} starts with KT but chain="${chain}". Auto-correcting to "tezos".`);
|
|
@@ -547,7 +605,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
|
|
|
547
605
|
});
|
|
548
606
|
try {
|
|
549
607
|
// Build token CID in CAIP-2 format
|
|
550
|
-
const tokenCID = buildTokenCID(chain, contractAddress, tokenId);
|
|
608
|
+
const tokenCID = buildTokenCID(chain, contractAddress, tokenId, standard);
|
|
551
609
|
logger.info(`[NFT Indexer] Built token CID: ${tokenCID}`);
|
|
552
610
|
// Query the indexer
|
|
553
611
|
logger.info(`[NFT Indexer] Querying indexer GraphQL for token...`);
|
|
@@ -556,7 +614,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
|
|
|
556
614
|
if (!indexerData) {
|
|
557
615
|
logger.info(`[NFT Indexer] Token not in database, triggering async indexing...`);
|
|
558
616
|
// Trigger background indexing workflow
|
|
559
|
-
const indexResult = await triggerIndexingAsync(chain, contractAddress, tokenId);
|
|
617
|
+
const indexResult = await triggerIndexingAsync(chain, contractAddress, tokenId, standard);
|
|
560
618
|
if (!indexResult.success) {
|
|
561
619
|
logger.debug(`[NFT Indexer] Failed to trigger indexing:`, indexResult.error);
|
|
562
620
|
return {
|
|
@@ -609,7 +667,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
|
|
|
609
667
|
}
|
|
610
668
|
}
|
|
611
669
|
// Map to standard format and convert to DP1
|
|
612
|
-
const tokenData = mapIndexerDataToStandardFormat(indexerData, chain);
|
|
670
|
+
const tokenData = mapIndexerDataToStandardFormat(indexerData, chain, standard);
|
|
613
671
|
return convertToDP1Item(tokenData, duration);
|
|
614
672
|
}
|
|
615
673
|
catch (error) {
|
|
@@ -621,15 +679,21 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
|
|
|
621
679
|
}
|
|
622
680
|
}
|
|
623
681
|
/**
|
|
624
|
-
*
|
|
682
|
+
* Resolve a batch of tokens and return every outcome (parallel processing)
|
|
625
683
|
*
|
|
626
684
|
* For missing tokens: triggers indexing per token, polls by job_id, then fetches again.
|
|
627
685
|
*
|
|
628
|
-
*
|
|
686
|
+
* Unlike getNFTTokenInfoBatch this keeps one result per input token, in input
|
|
687
|
+
* order: a resolved token as `{ success: true, item, still }`, a failed one as
|
|
688
|
+
* `{ success: false, error, token }`. Callers that must know which request an
|
|
689
|
+
* answer belongs to, or that need the still beside the item, use this.
|
|
690
|
+
*
|
|
691
|
+
* @param {Array} tokens - Array of token parameters (chain, contractAddress, tokenId, standard?)
|
|
629
692
|
* @param {number} duration - Display duration in seconds
|
|
630
|
-
* @
|
|
693
|
+
* @param {Function} [onProgress] - Called with (done, total) after each batch
|
|
694
|
+
* @returns {Promise<Array>} One result per input token
|
|
631
695
|
*/
|
|
632
|
-
async function
|
|
696
|
+
async function resolveTokenBatch(tokens, duration, onProgress) {
|
|
633
697
|
logger.info(`[NFT Indexer] 📦 Starting batch processing for ${tokens.length} token(s)...`);
|
|
634
698
|
logger.debug('[NFT Indexer] Batch tokens:', tokens);
|
|
635
699
|
const results = [];
|
|
@@ -669,6 +733,23 @@ async function getNFTTokenInfoBatch(tokens, duration, onProgress) {
|
|
|
669
733
|
const successCount = results.filter((r) => r.success && r.item).length;
|
|
670
734
|
const failedCount = results.length - successCount;
|
|
671
735
|
logger.info(`[NFT Indexer] Final: ${successCount} items with data, ${failedCount} without`);
|
|
736
|
+
return results;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Get NFT token information in batch and return as DP1 items (parallel processing)
|
|
740
|
+
*
|
|
741
|
+
* For missing tokens: triggers indexing per token, polls by job_id, then fetches again.
|
|
742
|
+
*
|
|
743
|
+
* Returns only the resolved items. Failures are dropped, not represented, so
|
|
744
|
+
* the result is neither aligned with nor as long as the input; a caller that
|
|
745
|
+
* needs the association uses resolveTokenBatch.
|
|
746
|
+
*
|
|
747
|
+
* @param {Array} tokens - Array of token parameters
|
|
748
|
+
* @param {number} duration - Display duration in seconds
|
|
749
|
+
* @returns {Promise<Array>} Array of DP1 items
|
|
750
|
+
*/
|
|
751
|
+
async function getNFTTokenInfoBatch(tokens, duration, onProgress) {
|
|
752
|
+
const results = await resolveTokenBatch(tokens, duration, onProgress);
|
|
672
753
|
// Return only items (not error objects)
|
|
673
754
|
const items = results.filter((r) => r.success && r.item).map((r) => r.item);
|
|
674
755
|
logger.info(`[NFT Indexer] Returning ${items.length} items`);
|
|
@@ -719,10 +800,10 @@ async function getCollectionInfo(params) {
|
|
|
719
800
|
* @returns {number} [returns.job_id] - Postgres queue job id; use with jobStatus / pollForJobCompletion
|
|
720
801
|
* @returns {string} [returns.error] - Error message if failed
|
|
721
802
|
*/
|
|
722
|
-
async function triggerIndexingAsync(chain, contractAddress, tokenId) {
|
|
803
|
+
async function triggerIndexingAsync(chain, contractAddress, tokenId, standard) {
|
|
723
804
|
try {
|
|
724
805
|
// Build token CID
|
|
725
|
-
const tokenCID = buildTokenCID(chain, contractAddress, tokenId);
|
|
806
|
+
const tokenCID = buildTokenCID(chain, contractAddress, tokenId, standard);
|
|
726
807
|
logger.debug('[NFT Indexer] Triggering token indexing job via GraphQL mutation:', {
|
|
727
808
|
tokenCID,
|
|
728
809
|
});
|
|
@@ -1050,6 +1131,7 @@ module.exports = {
|
|
|
1050
1131
|
getNFTTokenInfo,
|
|
1051
1132
|
// Batch processing
|
|
1052
1133
|
getNFTTokenInfoBatch,
|
|
1134
|
+
resolveTokenBatch,
|
|
1053
1135
|
// Single token processing
|
|
1054
1136
|
getNFTTokenInfoSingle,
|
|
1055
1137
|
// Additional functions
|
|
@@ -14,6 +14,14 @@ const { playlistSigningDidKey } = require('./signing-identity');
|
|
|
14
14
|
const { buildInlineManifestForToken } = require('./ref-manifest');
|
|
15
15
|
/** Default display background that passes DP-1 hex color validation. */
|
|
16
16
|
const DEFAULT_BACKGROUND = '#111111';
|
|
17
|
+
/**
|
|
18
|
+
* DP-1 signature role that carries ownership of a playlist.
|
|
19
|
+
*
|
|
20
|
+
* The feed derives a playlist's owners from `curators[]` and then requires one of those keys to have
|
|
21
|
+
* signed as `curator`: being named is a claim, signing in this role is the proof. Playlist groups use
|
|
22
|
+
* the same role; channels use `publisher`, which ff-cli does not publish.
|
|
23
|
+
*/
|
|
24
|
+
const PLAYLIST_OWNER_ROLE = 'curator';
|
|
17
25
|
/**
|
|
18
26
|
* isTimeBasedMedia reports whether an item's media has an intrinsic runtime
|
|
19
27
|
* (video or audio) per DP-1 §4.1 "time-based sources".
|
|
@@ -497,7 +505,16 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
|
|
|
497
505
|
if (!declared.some((curator) => curator && curator.key === key)) {
|
|
498
506
|
playlist.curators = [...declared, { name: playlistConfig.curatorName || 'ff-cli', key }];
|
|
499
507
|
}
|
|
500
|
-
|
|
508
|
+
// Sign in the `curator` role, not the configured one. Declaring the key in curators[] is the
|
|
509
|
+
// document claiming that key is an owner; the feed treats a declared key as an owner only when the
|
|
510
|
+
// same key also signed in the owner role, so any other role leaves the document declared, verified
|
|
511
|
+
// and unpublishable — the exact shape that made every ff-cli playlist fail before (#107). The role
|
|
512
|
+
// has to follow the claim the builder just wrote. `playlist.role` still governs `ff-cli sign`,
|
|
513
|
+
// where the user chooses what claim to make; to build under a different role, build without a
|
|
514
|
+
// configured key and sign the result explicitly.
|
|
515
|
+
playlist.signatures = [
|
|
516
|
+
await signPlaylist(playlist, playlistConfig.privateKey, PLAYLIST_OWNER_ROLE),
|
|
517
|
+
];
|
|
501
518
|
delete playlist.signature;
|
|
502
519
|
}
|
|
503
520
|
}
|
|
@@ -34,6 +34,25 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.castPlaylist = castPlaylist;
|
|
37
|
+
/**
|
|
38
|
+
* DP-1 signature role used when signing a playlist for playback.
|
|
39
|
+
*
|
|
40
|
+
* A cast never passes through a feed, so a document signed here carries no `feed` signature and its own
|
|
41
|
+
* entry is the only one a player could judge. DP-1 §7.1.1 rule 1 has players verify a `feed` or `curator`
|
|
42
|
+
* signature, so the configured role (shipped default `agent`) is the weakest choice available for a
|
|
43
|
+
* document this CLI is minting itself.
|
|
44
|
+
*
|
|
45
|
+
* Scope: this governs only the envelope the CLI *creates* — direct media, which arrives unsigned and must
|
|
46
|
+
* be signed before delivery. A playlist that already carries signatures is cast verbatim whatever its
|
|
47
|
+
* roles, because rewriting someone else's envelope is not this function's business. No player in this
|
|
48
|
+
* ecosystem enforces rule 1 today (ff-player performs no signature verification at all), so this is about
|
|
49
|
+
* not minting the weakest document we could, rather than about a failure observed on a device.
|
|
50
|
+
*
|
|
51
|
+
* This also has to match what the builder wrote: a wrapped media URL arrives here already declaring the
|
|
52
|
+
* signing key in `curators[]` and signed as `curator`, and signing REPLACES `signatures[]` rather than
|
|
53
|
+
* appending — so any other role here silently downgrades that envelope on its way to the device.
|
|
54
|
+
*/
|
|
55
|
+
const PLAYBACK_SIGNING_ROLE = 'curator';
|
|
37
56
|
// playlist-signer and ff1-device are still CommonJS; require keeps interop simple.
|
|
38
57
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
58
|
const { signPlaylist } = require('./playlist-signer');
|
|
@@ -68,7 +87,7 @@ async function castPlaylist(playlist, options = {}) {
|
|
|
68
87
|
};
|
|
69
88
|
}
|
|
70
89
|
try {
|
|
71
|
-
const signature = await signPlaylist(current, options.signingKey);
|
|
90
|
+
const signature = await signPlaylist(current, options.signingKey, PLAYBACK_SIGNING_ROLE);
|
|
72
91
|
current = {
|
|
73
92
|
...current,
|
|
74
93
|
signature: undefined,
|
|
@@ -7,6 +7,12 @@ exports.publishPlaylist = publishPlaylist;
|
|
|
7
7
|
const axios_1 = __importDefault(require("axios"));
|
|
8
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
9
|
const playlist_verifier_1 = require("./playlist-verifier");
|
|
10
|
+
/**
|
|
11
|
+
* DP-1 signature role that carries ownership of a playlist.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors the feed's owner role for playlists (channels use `publisher`, which ff-cli does not publish).
|
|
14
|
+
*/
|
|
15
|
+
const OWNER_ROLE = 'curator';
|
|
10
16
|
/**
|
|
11
17
|
* Publish a verified playlist to a DP-1 feed server
|
|
12
18
|
*
|
|
@@ -68,7 +74,9 @@ async function publishPlaylist(filePath, feedServerUrl) {
|
|
|
68
74
|
` Re-sign it as a DP-1 v1.1 envelope:\n` +
|
|
69
75
|
` 1. remove the "signature" field\n` +
|
|
70
76
|
` 2. declare your key: "curators": [{ "name": "Your name", "key": "<did:key from ff-cli status>" }]\n` +
|
|
71
|
-
` 3. ff-cli sign <file
|
|
77
|
+
` 3. ff-cli sign <file> -r ${OWNER_ROLE}\n` +
|
|
78
|
+
` The role matters: a declared key counts as an owner only when it also signed as "${OWNER_ROLE}",\n` +
|
|
79
|
+
` and plain "ff-cli sign" uses playlist.role, which defaults to "agent".`,
|
|
72
80
|
};
|
|
73
81
|
}
|
|
74
82
|
// Step 3: Verify signature integrity before publishing.
|
|
@@ -92,20 +100,81 @@ async function publishPlaylist(filePath, feedServerUrl) {
|
|
|
92
100
|
.map((c) => (typeof c?.key === 'string' ? c.key.trim() : ''))
|
|
93
101
|
.filter((k) => k.length > 0));
|
|
94
102
|
const signatures = Array.isArray(playlist.signatures)
|
|
95
|
-
? (playlist
|
|
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.
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -63,7 +63,7 @@ Used for signing DP‑1 playlists.
|
|
|
63
63
|
`PLAYLIST_PRIVATE_KEY` even when no config file exists yet, and `ff-cli status --key <privateKey>`
|
|
64
64
|
reports it for an explicit key — the one `sign --key` would use.
|
|
65
65
|
|
|
66
|
-
- `playlist.role` (string): DP-1 signing role
|
|
66
|
+
- `playlist.role` (string): DP-1 signing role used by `ff-cli sign`. Defaults to `agent` if omitted. You can also set this via `PLAYLIST_ROLE` in `.env`. Guided `ff-cli setup`, `config validate`, and `sign --role` only accept the usual DP-1 signing roles (`agent`, `feed`, `curator`, `institution`, `licensor`). It does **not** apply to `find` and `build`, which always sign as `curator` — see [Signing role and ownership](#signing-role-and-ownership).
|
|
67
67
|
|
|
68
68
|
### Generate an Ed25519 private key
|
|
69
69
|
|
|
@@ -88,6 +88,33 @@ Paste the value into `playlist.privateKey`. Any of these are accepted:
|
|
|
88
88
|
|
|
89
89
|
If you need a different role, set `playlist.role` to one of the DP-1 signing roles such as `agent`, `feed`, `curator`, `institution`, or `licensor`. The CLI rejects any other string before it reaches `dp1-js`.
|
|
90
90
|
|
|
91
|
+
### Signing role and ownership
|
|
92
|
+
|
|
93
|
+
A feed decides who owns a playlist from the document itself: the keys in `curators[]` are the owner set.
|
|
94
|
+
Under **role-aware ownership**, a key counts as an owner only when that same key *also signed in the
|
|
95
|
+
`curator` role* — being named is a claim, signing as `curator` is the proof.
|
|
96
|
+
|
|
97
|
+
`ff-cli publish` requires that role. **This is ff-cli's check, not every feed's answer today**: a feed that
|
|
98
|
+
does not yet enforce roles accepts an `agent`-signed document, so a manual upload of the same file can
|
|
99
|
+
still succeed. It is refused here because once accepted, such a document can never be replaced or deleted —
|
|
100
|
+
both need an owner signature it does not carry, and the only way to add one is to replace it. Publishing is
|
|
101
|
+
what makes that permanent.
|
|
102
|
+
|
|
103
|
+
The `kid`-to-`curators[]` match is different: feeds enforce that today, and a playlist failing it is
|
|
104
|
+
rejected by the server regardless of ff-cli.
|
|
105
|
+
|
|
106
|
+
Two consequences:
|
|
107
|
+
|
|
108
|
+
- `find` and `build` declare your key in `curators[]` and therefore always sign as `curator`, whatever
|
|
109
|
+
`playlist.role` says. To build under a different role, build without a configured signing key and sign the
|
|
110
|
+
result yourself with `ff-cli sign --role`.
|
|
111
|
+
- `ff-cli publish` refuses a playlist whose declared key signed under another role, and names the fix
|
|
112
|
+
(`ff-cli sign <file> -r curator`, from any key declared in `curators[]`). This runs before any request,
|
|
113
|
+
so it costs nothing — and deliberately does not defer to a feed that would accept the document today,
|
|
114
|
+
because acceptance is what makes it unfixable.
|
|
115
|
+
|
|
116
|
+
Channels use `publisher` as the owner role instead of `curator`; ff-cli does not publish channels.
|
|
117
|
+
|
|
91
118
|
If you already have a base64 key and want hex, convert it:
|
|
92
119
|
|
|
93
120
|
```bash
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -239,7 +239,7 @@ title-only on the FF1 tombstone, and shows empty tiles in the app for every
|
|
|
239
239
|
live HTML work. `enrich` repairs it in place.
|
|
240
240
|
|
|
241
241
|
```bash
|
|
242
|
-
ff-cli enrich playlist.json
|
|
242
|
+
ff-cli enrich playlist.json --assume-ethereum
|
|
243
243
|
```
|
|
244
244
|
|
|
245
245
|
```text
|
|
@@ -251,25 +251,37 @@ Enrich playlist
|
|
|
251
251
|
Output: playlist.json
|
|
252
252
|
```
|
|
253
253
|
|
|
254
|
+
`--assume-ethereum` is the operator's assertion that the playlist's `evm`
|
|
255
|
+
coordinates are Ethereum. DP-1 records every EVM network as `chain: "evm"`,
|
|
256
|
+
and `find` writes that for Ethereum works too, so an Ethereum playlist needs
|
|
257
|
+
the flag; without it those items are skipped rather than guessed at, because
|
|
258
|
+
the wrong network returns a different artwork's metadata. A Tezos playlist
|
|
259
|
+
needs no flag.
|
|
260
|
+
|
|
254
261
|
Items the indexer cannot resolve are listed rather than guessed at:
|
|
255
262
|
|
|
256
263
|
```text
|
|
257
264
|
Nothing to enrich
|
|
258
265
|
|
|
259
|
-
Skipped
|
|
266
|
+
Skipped 3:
|
|
260
267
|
Kim Asendorf — PXL NET — no provenance.contract chain/address/tokenId to look up
|
|
261
268
|
Untitled — the indexer returned nothing for it
|
|
269
|
+
Pre-Process — chain "evm" names a family; pass --assume-ethereum to assert the network
|
|
262
270
|
```
|
|
263
271
|
|
|
264
272
|
Enrichment changes the document, so a signed playlist loses its envelope and
|
|
265
273
|
has to be re-signed:
|
|
266
274
|
|
|
267
275
|
```bash
|
|
268
|
-
ff-cli enrich playlist.json
|
|
276
|
+
ff-cli enrich playlist.json --assume-ethereum
|
|
269
277
|
ff-cli sign playlist.json
|
|
270
278
|
ff-cli play playlist.json -d "living room"
|
|
271
279
|
```
|
|
272
280
|
|
|
281
|
+
Casting does not care about the signing role. Add `-r curator` to that `sign` if the playlist is also
|
|
282
|
+
going to a feed: `ff-cli publish` requires the owner role, for the reasons in
|
|
283
|
+
[Publish to Feed Server](#publish-to-feed-server).
|
|
284
|
+
|
|
273
285
|
## Validate / Sign / Play
|
|
274
286
|
|
|
275
287
|
```bash
|
|
@@ -390,7 +402,17 @@ Publish failed
|
|
|
390
402
|
Playlist verification failed: Playlist signature verification failed
|
|
391
403
|
```
|
|
392
404
|
|
|
393
|
-
|
|
405
|
+
Declare your key in `curators[]`, then sign it as the owner before publishing:
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
ff-cli status # the did:key to declare
|
|
409
|
+
ff-cli sign <file> -r curator
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Plain `ff-cli sign` would use `playlist.role` (default `agent`), producing a document the next two checks
|
|
413
|
+
refuse — under role-aware ownership a declared key counts as an owner only when it signed as `curator`, and
|
|
414
|
+
`ff-cli publish` requires that whether or not the target feed enforces it yet. (Posting the unsigned
|
|
415
|
+
document straight to the feed answers
|
|
394
416
|
`{"error":"unauthorized","message":"missing authentication: request body must carry signatures"}`.)
|
|
395
417
|
|
|
396
418
|
**Signed, but the signer is not declared as a curator:**
|
|
@@ -402,8 +424,12 @@ Publish failed
|
|
|
402
424
|
The feed accepts a publish when a signature's kid appears in the playlist's own curators[].
|
|
403
425
|
Add this to the playlist before signing:
|
|
404
426
|
"curators": [{ "name": "Your name", "key": "did:key:z6Mkv7qJ..." }]
|
|
405
|
-
then sign again from the unsigned file
|
|
406
|
-
|
|
427
|
+
then sign again from the unsigned file, with the same key you just declared:
|
|
428
|
+
ff-cli sign <file> -r curator --key <private key for did:key:z6Mkv7qJ...>
|
|
429
|
+
Drop --key if that key is your configured one; "sign" uses the configured key otherwise, and a
|
|
430
|
+
signature from an undeclared key would not satisfy the feed. Start from the unsigned file
|
|
431
|
+
because declaring curators[] changes the signed payload: signing appends, so the earlier
|
|
432
|
+
signature would be left covering a document that no longer exists.
|
|
407
433
|
```
|
|
408
434
|
|
|
409
435
|
This is the most common publish failure, and it is not about credentials. The feed accepts a create when
|
|
@@ -425,8 +451,8 @@ ff-cli status # → Signing identity (did:key) did:key:z6Mk...
|
|
|
425
451
|
# "curators": [{ "name": "Your Name", "key": "did:key:z6Mk..." }]
|
|
426
452
|
# DP-1 requires `name` alongside `key`.
|
|
427
453
|
|
|
428
|
-
# 3. Sign once, then publish.
|
|
429
|
-
ff-cli sign playlist.json
|
|
454
|
+
# 3. Sign once as curator, then publish. The role must match the claim in curators[].
|
|
455
|
+
ff-cli sign playlist.json -r curator
|
|
430
456
|
ff-cli publish playlist.json
|
|
431
457
|
```
|
|
432
458
|
|
|
@@ -436,6 +462,63 @@ rather than re-signing: remove `signatures`, declare the curator, then sign.
|
|
|
436
462
|
`ff-cli publish` checks this before uploading, so a missing declaration fails immediately with the key to
|
|
437
463
|
add rather than as a server error. No API key is involved: the feed does not accept one.
|
|
438
464
|
|
|
465
|
+
**Declared, but signed under a non-owner role:**
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
Publish failed
|
|
469
|
+
Playlist is signed by a declared curator, but under a non-owner role ("agent").
|
|
470
|
+
|
|
471
|
+
This is ff-cli's check, not a feed's answer. Feeds are moving to role-aware ownership, where
|
|
472
|
+
a key in curators[] counts as an owner only if it also signed as "curator". A feed that
|
|
473
|
+
does not check the role yet will accept this document, and then be unable to authorize a
|
|
474
|
+
replace or a delete for it: both need an owner signature it does not carry. Publishing it is
|
|
475
|
+
what makes that permanent, so ff-cli refuses here instead.
|
|
476
|
+
|
|
477
|
+
curators[] is already correct — only the role is missing, so add that signature to this file:
|
|
478
|
+
ff-cli sign <file> -r curator --key <private key for a declared curator>
|
|
479
|
+
Any key this playlist declares will do — you do not need the one that signed under the wrong
|
|
480
|
+
role. Declared: did:key:z6Mkv7qJ...
|
|
481
|
+
It must be one of those: a curator signature from an undeclared key satisfies neither
|
|
482
|
+
this check nor role-aware ownership, since both read roles only from declared keys. "sign"
|
|
483
|
+
uses the configured key unless --key says otherwise; drop --key if a declared key is already
|
|
484
|
+
your configured one, and confirm which identity a key carries with
|
|
485
|
+
"ff-cli status --key <private key>".
|
|
486
|
+
Signing appends, and the payload hash excludes signatures, so the existing entry stays valid
|
|
487
|
+
and the document ends up carrying both. No unsigned copy is needed: you only have to start
|
|
488
|
+
from one when changing signed content such as curators[] itself.
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Declaring a key in `curators[]` is a claim that it owns the playlist; signing as `curator` is the proof.
|
|
492
|
+
This failure means the document is right and the signature is not — the fix is the role, not `curators[]`.
|
|
493
|
+
|
|
494
|
+
**This one is repairable in place**, unlike the two above. Nothing signed needs to change, so appending is
|
|
495
|
+
enough — the payload hash covers the document with `signature`/`signatures` stripped, which leaves the
|
|
496
|
+
earlier entry valid over bytes that did not move:
|
|
497
|
+
|
|
498
|
+
```bash
|
|
499
|
+
# --key only when the declared curator is not your configured key; `ff-cli status --key <k>`
|
|
500
|
+
# reports which identity a key carries.
|
|
501
|
+
ff-cli sign playlist.json -r curator --key <declared curator's key> # ["agent"] -> ["agent","curator"]
|
|
502
|
+
ff-cli publish playlist.json
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
The signature has to come from a key the playlist already declares — **any** of them, not specifically the
|
|
506
|
+
one that signed under the wrong role. On a playlist with several curators, whoever holds one of the other
|
|
507
|
+
declared keys can repair it. `sign` uses the configured key unless `--key` says otherwise, so on a machine
|
|
508
|
+
whose configured key is not declared, the plain command appends a `curator` signature the feed ignores and
|
|
509
|
+
the publish fails identically.
|
|
510
|
+
|
|
511
|
+
Start again from an unsigned document only when the fix changes signed content — adding `curators[]`, for
|
|
512
|
+
instance. There the earlier signature would cover a document that no longer exists.
|
|
513
|
+
|
|
514
|
+
**This is a CLI preflight, not a report of what a feed said.** A feed that does not yet check the role
|
|
515
|
+
accepts such a document, so uploading it by hand can still succeed. It is refused here because once
|
|
516
|
+
accepted it can never be replaced **or** deleted: both need an owner signature it does not carry, and the
|
|
517
|
+
only way to add one is to replace the document. Publishing is what makes that permanent.
|
|
518
|
+
|
|
519
|
+
`find` and `build` sign as `curator` automatically, since they declare the key themselves. This applies to
|
|
520
|
+
documents you sign by hand, where `playlist.role` (default `agent`) decides the role.
|
|
521
|
+
|
|
439
522
|
## Complete Flow (build → validate → sign → play → publish)
|
|
440
523
|
|
|
441
524
|
```bash
|
|
@@ -448,7 +531,9 @@ npm run dev -- validate playlist.json
|
|
|
448
531
|
# 3. Sign it. Declare curators[] BEFORE this step: the signature covers it, and the feed only
|
|
449
532
|
# accepts a publish when a signature's kid matches a declared curator key.
|
|
450
533
|
# Run `ff-cli status` for the kid (add -k <key> if you sign with `sign --key`).
|
|
451
|
-
|
|
534
|
+
# -r curator: ff-cli publish requires the owner role (role-aware ownership); the kid match is
|
|
535
|
+
# what feeds enforce today.
|
|
536
|
+
npm run dev -- sign playlist.json -r curator -o signed.json
|
|
452
537
|
|
|
453
538
|
# 4. Play it on a device
|
|
454
539
|
npm run dev -- play signed.json -d "Living Room Display"
|
package/docs/PROJECT_SPEC.md
CHANGED
|
@@ -237,28 +237,41 @@ and nothing else. `enrich` is the repair path for those.
|
|
|
237
237
|
changing the title would produce two documents claiming to be the same cached
|
|
238
238
|
manifest. The replacement is derived from the original id and the title, so
|
|
239
239
|
it is deterministic across runs.
|
|
240
|
+
- The token standard the playlist asserts (`provenance.contract.standard`)
|
|
241
|
+
rides the lookup. The indexer keys ERC-721 and ERC-1155 tokens under
|
|
242
|
+
different CIDs (`eip155:1:erc721:…`, `eip155:1:erc1155:…`) and the client's
|
|
243
|
+
detection, having only an address, answers erc721 for every EVM contract —
|
|
244
|
+
so without the assertion an ERC-1155 work is queried as ERC-721 and never
|
|
245
|
+
found. A value outside the standards the CID can carry falls back to
|
|
246
|
+
detection.
|
|
240
247
|
- The indexer resolving a token and having something worth attaching are
|
|
241
248
|
different outcomes: `buildInlineManifestForToken` returns nothing when it has
|
|
242
|
-
only a title, since
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
only a title, since on the items `find` and `build` create a title-only
|
|
250
|
+
manifest repeats what the item already says. A curator's item may carry no
|
|
251
|
+
title at all, and the tombstone then shows nothing, so enrichment does
|
|
252
|
+
synthesize a title-only manifest in that one case — the indexer's title is
|
|
253
|
+
substantiated, and `item.title` itself is still never written. An item that
|
|
254
|
+
has a title and resolves to nothing more is reported as resolved-without-
|
|
255
|
+
metadata, separately from tokens the indexer could not resolve at all.
|
|
245
256
|
- A still the manifest builder suppressed is recovered against the item's own
|
|
246
257
|
source. `resolveStillUri` blanks a thumbnail equal to the source it was
|
|
247
258
|
handed, which is the source the *indexer* chose; for a static work that is
|
|
248
259
|
often the still itself. When the curator kept a live HTML source instead, the
|
|
249
260
|
item would otherwise end up with no thumbnail — the empty grid tile this
|
|
250
|
-
command exists to remove.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
261
|
+
command exists to remove. The lookup therefore carries the still the indexer
|
|
262
|
+
names for the token (`display.image_url`, returned beside the DP-1 item by
|
|
263
|
+
`resolveTokenBatch`), and enrichment applies the same rule against the
|
|
264
|
+
curator's source: when that still differs from the item's source it becomes
|
|
265
|
+
the thumbnail. It is the same field `find` and `build` write into
|
|
266
|
+
`thumbnails.default`, taken on the indexer's word as they take it. Nothing is
|
|
267
|
+
inferred from the indexer's *source*: `getBestMediaUrl` prefers
|
|
268
|
+
`display.animation_url` and media assets over `display.image_url`, so that
|
|
269
|
+
source is frequently a live HTML rendition, and a file-suffix check would
|
|
270
|
+
reject the extensionless CDN URLs the indexer commonly returns as stills
|
|
271
|
+
while `display.mime_type` describes the primary rendition rather than the
|
|
272
|
+
still (a live work reports `text/html` beside a PNG `image_url`). A
|
|
273
|
+
thumbnail-only manifest is emitted where none was: it is the difference
|
|
274
|
+
between a tile and an empty square.
|
|
262
275
|
- `evm` names a family, and enrichment refuses to guess which member. DP-1 §6
|
|
263
276
|
carries no network identity, and the indexer maps Ethereum, Polygon,
|
|
264
277
|
Arbitrum, Optimism, Base, and Zora all back to `evm`, so neither the request
|
package/docs/README.md
CHANGED
|
@@ -193,8 +193,8 @@ image, video, or SVG source, but not from a live HTML work.
|
|
|
193
193
|
|
|
194
194
|
```bash
|
|
195
195
|
ff-cli enrich playlist.json --assume-ethereum
|
|
196
|
-
ff-cli enrich playlist.json -o labelled.json
|
|
197
|
-
ff-cli enrich playlist.json --force
|
|
196
|
+
ff-cli enrich playlist.json --assume-ethereum -o labelled.json
|
|
197
|
+
ff-cli enrich playlist.json --assume-ethereum --force # replace existing manifests
|
|
198
198
|
```
|
|
199
199
|
|
|
200
200
|
`--assume-ethereum` is needed for most playlists. DP-1 records an EVM
|
|
@@ -212,9 +212,12 @@ changes the document, so the signature is removed and the playlist must be
|
|
|
212
212
|
re-signed before playing:
|
|
213
213
|
|
|
214
214
|
```bash
|
|
215
|
-
ff-cli enrich playlist.json && ff-cli sign playlist.json
|
|
215
|
+
ff-cli enrich playlist.json --assume-ethereum && ff-cli sign playlist.json
|
|
216
216
|
```
|
|
217
217
|
|
|
218
|
+
That role is fine for casting. Use `ff-cli sign playlist.json -r curator` if the enriched playlist is
|
|
219
|
+
also going to a feed.
|
|
220
|
+
|
|
218
221
|
### Validate, sign, and play
|
|
219
222
|
|
|
220
223
|
```bash
|
|
@@ -282,12 +285,30 @@ Configure feed servers in `config.json`:
|
|
|
282
285
|
Publishing needs no credentials. The feed authorizes a create from the signatures inside the playlist:
|
|
283
286
|
it requires a signature whose `kid` matches a key the document declares in `curators[]`.
|
|
284
287
|
|
|
288
|
+
`ff-cli publish` additionally requires that signature to carry the **`curator` role**. That is ff-cli's
|
|
289
|
+
check, not every feed's answer today: feeds are moving to role-aware ownership, where being named in
|
|
290
|
+
`curators[]` is a claim and signing as `curator` is the proof. A feed that does not check the role yet
|
|
291
|
+
accepts an `agent`-signed document — and can then authorize neither a replace nor a delete for it, since
|
|
292
|
+
both need an owner signature it does not carry. Publishing is what makes that permanent, so the CLI
|
|
293
|
+
refuses first.
|
|
294
|
+
|
|
285
295
|
Declare it before signing, not after: a signature covers `curators[]`, so adding the entry to an
|
|
286
296
|
already-signed playlist invalidates it, and signing again appends rather than replaces. Run
|
|
287
297
|
`ff-cli status` to read the `did:key` your signatures will carry, put it in `curators[]`, then sign
|
|
288
|
-
once
|
|
289
|
-
|
|
290
|
-
|
|
298
|
+
once **with the owner role**:
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
ff-cli sign playlist.json -r curator
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
The flag is needed because plain `ff-cli sign` uses `playlist.role`, which defaults to `agent` — a
|
|
305
|
+
document `ff-cli publish` then refuses. Set `"role": "curator"` under `playlist` in `config.json` to make
|
|
306
|
+
that the default for hand-signed playlists.
|
|
307
|
+
|
|
308
|
+
If you sign with `sign --key <privateKey>`, read that key's identity with
|
|
309
|
+
`ff-cli status --key <privateKey>` — the configured identity would be the wrong one to declare. `find`
|
|
310
|
+
and `build` need none of this: they declare the key and sign it as `curator` for you when a signing key
|
|
311
|
+
is configured. An `apiKey` left over in an existing config is ignored.
|
|
291
312
|
|
|
292
313
|
```json
|
|
293
314
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feralfile/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@feralfile/source-resolver": "^1.0.0",
|
|
62
62
|
"@napi-rs/keyring": "1.3.0",
|
|
63
|
-
"axios": "^1.
|
|
63
|
+
"axios": "^1.20.0",
|
|
64
64
|
"bonjour-service": "^1.3.0",
|
|
65
65
|
"chalk": "^4.1.2",
|
|
66
66
|
"commander": "^11.1.0",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"dp1-js": "2.3.1",
|
|
69
69
|
"ff1": "^1.0.0",
|
|
70
70
|
"fuzzysort": "^3.1.0",
|
|
71
|
-
"viem": "^2.
|
|
71
|
+
"viem": "^2.56.3"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@types/node": "^24.7.1",
|