@feralfile/cli 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.json.example +4 -5
- package/dist/index.js +2 -0
- package/dist/package.json +1 -1
- package/dist/src/commands/enrich.js +315 -0
- package/dist/src/commands/find.js +1 -2
- package/dist/src/commands/play.js +5 -2
- package/dist/src/commands/publish.js +1 -8
- package/dist/src/commands/status.js +51 -1
- package/dist/src/config.js +45 -5
- package/dist/src/utilities/enrich-playlist.js +459 -0
- package/dist/src/utilities/index.js +1 -1
- package/dist/src/utilities/nft-indexer.js +13 -4
- package/dist/src/utilities/playlist-builder.js +12 -0
- package/dist/src/utilities/playlist-publisher.js +61 -13
- package/dist/src/utilities/signing-identity.js +34 -0
- package/docs/CONFIGURATION.md +13 -0
- package/docs/EXAMPLES.md +106 -8
- package/docs/PROJECT_SPEC.md +122 -0
- package/docs/README.md +52 -5
- package/package.json +1 -1
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Fills in missing inline Ref Manifests on an existing DP-1 playlist.
|
|
4
|
+
*
|
|
5
|
+
* `find` and `build` attach an `inlineManifest` to every item they create, so
|
|
6
|
+
* playlists this CLI authors end to end are already labelled. Playlists that
|
|
7
|
+
* arrive any other way are not: items copied out of another playlist, items
|
|
8
|
+
* hand-written against a known contract, items inherited from a document
|
|
9
|
+
* predating §3.6. Those carry a `source` and a `provenance` block and nothing
|
|
10
|
+
* else, and there was no way to repair them short of editing JSON by hand.
|
|
11
|
+
*
|
|
12
|
+
* What the gap costs is visible on a device. The FF1 tombstone resolves its
|
|
13
|
+
* label through `ref` -> `inlineManifest` -> `item.metadata` and falls back to
|
|
14
|
+
* the item title, so an unlabelled item shows a title with no artist line. The
|
|
15
|
+
* app's grid is worse: it can rasterize a thumbnail from a direct image, video,
|
|
16
|
+
* or SVG source, but not from a live HTML work, so every generative item in an
|
|
17
|
+
* unlabelled playlist renders as an empty tile. A playlist of computational art
|
|
18
|
+
* is mostly live HTML, which is exactly the case that looks broken.
|
|
19
|
+
*
|
|
20
|
+
* Enrichment is additive and narrow on purpose. It writes `inlineManifest` and
|
|
21
|
+
* nothing else: `source`, `duration`, `id`, and `display` are the curator's
|
|
22
|
+
* decisions and are left exactly as found, even when the indexer disagrees. An
|
|
23
|
+
* item the indexer cannot resolve is reported, never guessed at — a fabricated
|
|
24
|
+
* artist line in a signed document is worse than a missing one.
|
|
25
|
+
*/
|
|
26
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
29
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
30
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
31
|
+
}
|
|
32
|
+
Object.defineProperty(o, k2, desc);
|
|
33
|
+
}) : (function(o, m, k, k2) {
|
|
34
|
+
if (k2 === undefined) k2 = k;
|
|
35
|
+
o[k2] = m[k];
|
|
36
|
+
}));
|
|
37
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
38
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
39
|
+
}) : function(o, v) {
|
|
40
|
+
o["default"] = v;
|
|
41
|
+
});
|
|
42
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
43
|
+
var ownKeys = function(o) {
|
|
44
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
45
|
+
var ar = [];
|
|
46
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
47
|
+
return ar;
|
|
48
|
+
};
|
|
49
|
+
return ownKeys(o);
|
|
50
|
+
};
|
|
51
|
+
return function (mod) {
|
|
52
|
+
if (mod && mod.__esModule) return mod;
|
|
53
|
+
var result = {};
|
|
54
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
55
|
+
__setModuleDefault(result, mod);
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
})();
|
|
59
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
60
|
+
exports.enrichPlaylistManifests = enrichPlaylistManifests;
|
|
61
|
+
const node_crypto_1 = require("node:crypto");
|
|
62
|
+
const logger = __importStar(require("../logger"));
|
|
63
|
+
/**
|
|
64
|
+
* CHAIN_ALIASES translates a DP-1 chain name into the indexer's, outbound.
|
|
65
|
+
*
|
|
66
|
+
* DP-1 names the EVM family `evm` (core spec §6: "evm" | "tezos" | "bitmark" |
|
|
67
|
+
* "other"), because the protocol describes a class of chains. The FF indexer
|
|
68
|
+
* names the same thing `ethereum`, because it queries a specific one. Without
|
|
69
|
+
* this every EVM item comes back unresolved, and the failure looks like a
|
|
70
|
+
* missing artwork rather than a vocabulary mismatch.
|
|
71
|
+
*
|
|
72
|
+
* Unlisted values pass through so the indexer, not this table, decides what it
|
|
73
|
+
* supports.
|
|
74
|
+
*/
|
|
75
|
+
const CHAIN_ALIASES = {
|
|
76
|
+
evm: 'ethereum',
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* `evm` names a family, and this maps it to one member of that family.
|
|
80
|
+
*
|
|
81
|
+
* DP-1 core §6 offers "evm" | "tezos" | "bitmark" | "other" and carries no
|
|
82
|
+
* network identity, while the indexer maps Ethereum, Polygon, Arbitrum,
|
|
83
|
+
* Optimism, Base, and Zora all back to `evm`. So a DP-1 coordinate cannot say
|
|
84
|
+
* which EVM network it means, and neither can the response.
|
|
85
|
+
*
|
|
86
|
+
* Ethereum is chosen because it is the only EVM network this client can reach:
|
|
87
|
+
* buildTokenCID maps ethereum to eip155:1 and tezos to tezos:mainnet, and
|
|
88
|
+
* throws for everything else. A --chain flag would not widen that — it would
|
|
89
|
+
* only move the throw. The residual risk is narrow and real: an L2 work whose
|
|
90
|
+
* address and token id also exist on Ethereum would be enriched with the
|
|
91
|
+
* Ethereum work's metadata, and nothing in the response could reveal it.
|
|
92
|
+
*
|
|
93
|
+
* The count is surfaced so the assumption is stated at the point of use rather
|
|
94
|
+
* than buried here. Widening it belongs upstream, in the indexer client.
|
|
95
|
+
*/
|
|
96
|
+
const AMBIGUOUS_CHAIN = 'evm';
|
|
97
|
+
/**
|
|
98
|
+
* One CLI invocation stamps one manifest timestamp, matching ref-manifest.ts:
|
|
99
|
+
* a run that synthesizes several manifests should not write several clocks.
|
|
100
|
+
*/
|
|
101
|
+
const MANIFEST_CREATED = new Date().toISOString();
|
|
102
|
+
/**
|
|
103
|
+
* canonicalChain collapses both vocabularies onto one name so a coordinate
|
|
104
|
+
* sent to the indexer and a coordinate read back off its response compare
|
|
105
|
+
* equal. The direction is deliberately the reverse of CHAIN_ALIASES: this one
|
|
106
|
+
* is for matching, that one is for querying.
|
|
107
|
+
*/
|
|
108
|
+
function canonicalChain(chain) {
|
|
109
|
+
const lower = chain.trim().toLowerCase();
|
|
110
|
+
return lower === 'ethereum' ? 'evm' : lower;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* coordinateKeyOf builds the correlation key for a provenance block, or null
|
|
114
|
+
* when the block cannot identify a single work.
|
|
115
|
+
*
|
|
116
|
+
* The address is lowercased because the indexer echoes it checksummed while
|
|
117
|
+
* callers typically write it lowercase, and the two must still match.
|
|
118
|
+
*/
|
|
119
|
+
function coordinateKeyOf(provenance) {
|
|
120
|
+
const contract = provenance?.contract;
|
|
121
|
+
if (!contract) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const { chain, address, tokenId } = contract;
|
|
125
|
+
if (!chain || !address || tokenId === undefined || tokenId === null) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const token = String(tokenId).trim();
|
|
129
|
+
if (token.length === 0) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return `${canonicalChain(chain)}:${address.trim().toLowerCase()}:${token}`;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* describeItem names an item for operator output, preferring its title.
|
|
136
|
+
*/
|
|
137
|
+
function describeItem(item, index) {
|
|
138
|
+
const title = typeof item.title === 'string' ? item.title.trim() : '';
|
|
139
|
+
return title.length > 0 ? title : `item ${index + 1}`;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* coordinateFor extracts a lookup key from an item, or null when the item
|
|
143
|
+
* carries no usable on-chain coordinate.
|
|
144
|
+
*
|
|
145
|
+
* All three parts are required. A contract with no token id identifies a
|
|
146
|
+
* collection rather than a work, and resolving that would attach some other
|
|
147
|
+
* item's manifest to this one.
|
|
148
|
+
*/
|
|
149
|
+
function coordinateFor(item) {
|
|
150
|
+
const contract = item.provenance?.contract;
|
|
151
|
+
if (!contract) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const { chain, address, tokenId } = contract;
|
|
155
|
+
if (!chain || !address || tokenId === undefined || tokenId === null) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const token = String(tokenId).trim();
|
|
159
|
+
if (token.length === 0) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const normalized = chain.trim().toLowerCase();
|
|
163
|
+
return {
|
|
164
|
+
chain: CHAIN_ALIASES[normalized] ?? normalized,
|
|
165
|
+
contractAddress: address,
|
|
166
|
+
tokenId: token,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* stillFromIndexerSource recovers a thumbnail the manifest builder suppressed.
|
|
171
|
+
*
|
|
172
|
+
* `resolveStillUri` blanks a still that equals the source it was given, on the
|
|
173
|
+
* reasonable ground that pointing a thumbnail at the artwork itself adds
|
|
174
|
+
* payload and no information. But the source it was given is the one the
|
|
175
|
+
* INDEXER chose, not the one the curator kept. For a static work the indexer
|
|
176
|
+
* commonly selects the still as the source, so the thumbnail is dropped —
|
|
177
|
+
* and when the curator's item points at a live HTML generator instead, the
|
|
178
|
+
* item ends up with no still at all. That is precisely the case this command
|
|
179
|
+
* exists to repair: a live work the app cannot rasterize, whose grid tile
|
|
180
|
+
* stays empty.
|
|
181
|
+
*
|
|
182
|
+
* Recovering it is the inverse of the suppression. When the indexer's source
|
|
183
|
+
* differs from the item's and is an http(s) URL, that source IS the still that
|
|
184
|
+
* was elided, so it becomes the thumbnail relative to the item's own source.
|
|
185
|
+
*/
|
|
186
|
+
function stillFromIndexerSource(item, resolved) {
|
|
187
|
+
const indexerSource = typeof resolved.source === 'string' ? resolved.source.trim() : '';
|
|
188
|
+
const itemSource = typeof item.source === 'string' ? item.source.trim() : '';
|
|
189
|
+
if (!indexerSource || indexerSource === itemSource) {
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
if (!indexerSource.startsWith('http://') && !indexerSource.startsWith('https://')) {
|
|
193
|
+
return '';
|
|
194
|
+
}
|
|
195
|
+
// The indexer's source is not necessarily a still. getBestMediaUrl prefers
|
|
196
|
+
// display.animation_url and media assets over display.image_url, so it is
|
|
197
|
+
// frequently a live HTML rendition — and writing one into the thumbnail slot
|
|
198
|
+
// is worse than leaving the slot empty: the grid still cannot rasterize it,
|
|
199
|
+
// and the item now claims a still it does not have.
|
|
200
|
+
//
|
|
201
|
+
// Only a URL that is demonstrably an image is used. The indexer exposes no
|
|
202
|
+
// separately identified still on the item this command receives, so the file
|
|
203
|
+
// extension is the evidence available; carrying a real image field through
|
|
204
|
+
// the batch result would be the better fix and belongs upstream.
|
|
205
|
+
return looksLikeImage(indexerSource) ? indexerSource : '';
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Media the app can draw a grid tile from, matching the types
|
|
209
|
+
* playlist-builder.js already recognizes. SVG and video belong here: the app
|
|
210
|
+
* rasterizes both, and excluding them left works with the empty tile this
|
|
211
|
+
* command exists to remove. HTML is deliberately absent — an unrasterizable
|
|
212
|
+
* page in the thumbnail slot is worse than an empty slot, because the item
|
|
213
|
+
* then claims a still it does not have.
|
|
214
|
+
*/
|
|
215
|
+
const RASTERIZABLE_EXTENSIONS = [
|
|
216
|
+
'.png',
|
|
217
|
+
'.jpg',
|
|
218
|
+
'.jpeg',
|
|
219
|
+
'.gif',
|
|
220
|
+
'.webp',
|
|
221
|
+
'.avif',
|
|
222
|
+
'.bmp',
|
|
223
|
+
'.svg',
|
|
224
|
+
'.mp4',
|
|
225
|
+
'.webm',
|
|
226
|
+
];
|
|
227
|
+
/**
|
|
228
|
+
* looksLikeImage tests a URL's path extension, ignoring query and fragment.
|
|
229
|
+
*/
|
|
230
|
+
function looksLikeImage(url) {
|
|
231
|
+
let pathname = url;
|
|
232
|
+
try {
|
|
233
|
+
pathname = new URL(url).pathname;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
pathname = url.split('?')[0].split('#')[0];
|
|
237
|
+
}
|
|
238
|
+
const lower = pathname.toLowerCase();
|
|
239
|
+
return RASTERIZABLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* hasThumbnail reports whether a manifest already carries a default still.
|
|
243
|
+
*/
|
|
244
|
+
function hasThumbnail(manifest) {
|
|
245
|
+
const thumbnails = manifest.metadata
|
|
246
|
+
?.thumbnails;
|
|
247
|
+
return typeof thumbnails?.default?.uri === 'string' && thumbnails.default.uri.length > 0;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* manifestFor returns the manifest to write onto an item, or undefined when
|
|
251
|
+
* the indexer resolved the token without building one.
|
|
252
|
+
*
|
|
253
|
+
* The curator's own title wins. `item.title` survives enrichment untouched,
|
|
254
|
+
* but the tombstone reads the manifest first, so attaching an indexer title
|
|
255
|
+
* that disagrees would change the displayed label while appearing to preserve
|
|
256
|
+
* it — the indexer writes "Pre-Process #0" where a curator wrote
|
|
257
|
+
* "Pre-Process". Everything else in the manifest (artist, description,
|
|
258
|
+
* thumbnails) is what enrichment exists to fetch and is taken as given.
|
|
259
|
+
*/
|
|
260
|
+
function manifestFor(item, resolved) {
|
|
261
|
+
const still = stillFromIndexerSource(item, resolved);
|
|
262
|
+
let manifest = resolved.inlineManifest;
|
|
263
|
+
let rekey = false;
|
|
264
|
+
if (manifest && still && !hasThumbnail(manifest)) {
|
|
265
|
+
manifest = {
|
|
266
|
+
...manifest,
|
|
267
|
+
metadata: {
|
|
268
|
+
...(manifest.metadata ?? {}),
|
|
269
|
+
thumbnails: { default: { uri: still } },
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
rekey = true;
|
|
273
|
+
}
|
|
274
|
+
else if (!manifest && still) {
|
|
275
|
+
// Nothing was emitted because the still was the only content the token
|
|
276
|
+
// had, and it had been suppressed. A thumbnail-only manifest is worth its
|
|
277
|
+
// payload in a way a title-only one is not: it is the difference between
|
|
278
|
+
// a grid tile and an empty square.
|
|
279
|
+
const synthesizedTitle = typeof item.title === 'string' ? item.title.trim() : '';
|
|
280
|
+
manifest = {
|
|
281
|
+
refVersion: '1.1.0',
|
|
282
|
+
id: derivedManifestId('', `${synthesizedTitle}\n${still}`),
|
|
283
|
+
created: MANIFEST_CREATED,
|
|
284
|
+
locale: 'en',
|
|
285
|
+
metadata: {
|
|
286
|
+
...(synthesizedTitle ? { title: synthesizedTitle } : {}),
|
|
287
|
+
thumbnails: { default: { uri: still } },
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (!manifest) {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
const metadata = manifest.metadata ?? {};
|
|
295
|
+
const curatorTitle = typeof item.title === 'string' ? item.title.trim() : '';
|
|
296
|
+
const overrideTitle = curatorTitle.length > 0 && metadata.title !== curatorTitle;
|
|
297
|
+
// The id is derived from the finished payload every time, not only when this
|
|
298
|
+
// command changed something. The indexer keys its id on the token
|
|
299
|
+
// coordinate, so every rendition of one artwork carries the same id whatever
|
|
300
|
+
// its content — and under --force the indexer may itself return a different
|
|
301
|
+
// artist or description under that unchanged id. Deriving from the payload
|
|
302
|
+
// is the only rule that holds in every case: identical content keeps one
|
|
303
|
+
// identity, differing content never shares one.
|
|
304
|
+
//
|
|
305
|
+
// The original id is folded in, so the result stays anchored to the token
|
|
306
|
+
// rather than floating free of it, and stays deterministic across runs.
|
|
307
|
+
//
|
|
308
|
+
// `rekey` is read only to record that a thumbnail was recovered; the
|
|
309
|
+
// derivation below covers it either way.
|
|
310
|
+
void rekey;
|
|
311
|
+
const finalMetadata = overrideTitle ? { ...metadata, title: curatorTitle } : metadata;
|
|
312
|
+
return {
|
|
313
|
+
...manifest,
|
|
314
|
+
id: derivedManifestId(typeof manifest.id === 'string' ? manifest.id : '', stableStringify(finalMetadata)),
|
|
315
|
+
metadata: finalMetadata,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* stableStringify serializes a value with object keys in sorted order, so the
|
|
320
|
+
* same content hashes the same however it was assembled.
|
|
321
|
+
*/
|
|
322
|
+
function stableStringify(value) {
|
|
323
|
+
if (value === null || typeof value !== 'object') {
|
|
324
|
+
return JSON.stringify(value) ?? 'null';
|
|
325
|
+
}
|
|
326
|
+
if (Array.isArray(value)) {
|
|
327
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
328
|
+
}
|
|
329
|
+
const entries = Object.entries(value)
|
|
330
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
331
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
332
|
+
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
|
333
|
+
return `{${entries.join(',')}}`;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* derivedManifestId produces a stable id for a manifest whose title this
|
|
337
|
+
* command replaced, shaped as a UUID so it reads like every other manifest id.
|
|
338
|
+
*/
|
|
339
|
+
function derivedManifestId(originalId, title) {
|
|
340
|
+
const digest = (0, node_crypto_1.createHash)('sha256').update(`${originalId}\n${title}`).digest('hex');
|
|
341
|
+
return [
|
|
342
|
+
digest.slice(0, 8),
|
|
343
|
+
digest.slice(8, 12),
|
|
344
|
+
digest.slice(12, 16),
|
|
345
|
+
digest.slice(16, 20),
|
|
346
|
+
digest.slice(20, 32),
|
|
347
|
+
].join('-');
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* enrichPlaylistManifests attaches an inline Ref Manifest to every item that
|
|
351
|
+
* lacks one and can be resolved on chain.
|
|
352
|
+
*
|
|
353
|
+
* The playlist is mutated in place and returned; callers that need the
|
|
354
|
+
* original should read it twice. Items are looked up in one batch so the
|
|
355
|
+
* indexer sees a single burst rather than a request per item — warming
|
|
356
|
+
* previously-unseen tokens is the slow path, and it parallelizes internally.
|
|
357
|
+
*
|
|
358
|
+
* Signatures are dropped when anything changed, in both the DP-1 v1.1.0
|
|
359
|
+
* `signatures[]` form and the legacy flat `signature` this repo still verifies.
|
|
360
|
+
* A signature covers the document bytes, and an enriched document is a
|
|
361
|
+
* different document; leaving a stale envelope in place would produce a file
|
|
362
|
+
* that fails verification at the device with no explanation of why.
|
|
363
|
+
*/
|
|
364
|
+
async function enrichPlaylistManifests(playlist, lookup, options = {}) {
|
|
365
|
+
const items = Array.isArray(playlist.items) ? playlist.items : [];
|
|
366
|
+
const skipped = [];
|
|
367
|
+
const pending = [];
|
|
368
|
+
// One lookup per distinct artwork, not per item. getNFTTokenInfoBatch runs a
|
|
369
|
+
// lookup for each token it is handed, and a token the indexer has not seen
|
|
370
|
+
// triggers an indexing job and a poll that can take minutes. A playlist that
|
|
371
|
+
// deliberately repeats a work — the same piece at two points in a loop —
|
|
372
|
+
// would otherwise submit that job once per appearance.
|
|
373
|
+
const coordinates = new Map();
|
|
374
|
+
items.forEach((item, index) => {
|
|
375
|
+
// An item with a `ref` is already labelled, by a manifest this command
|
|
376
|
+
// cannot see. Ref outranks inlineManifest in the resolution order, so
|
|
377
|
+
// enriching one writes a manifest the device will ignore while still
|
|
378
|
+
// invalidating the signature: damage with no visible change.
|
|
379
|
+
//
|
|
380
|
+
// --force does not override this. Making the inline manifest win would
|
|
381
|
+
// mean deleting the curator's `ref` and `refHash`, which is a different
|
|
382
|
+
// and larger decision than filling in missing metadata — and one that
|
|
383
|
+
// silently discards the integrity hash the remote manifest is checked
|
|
384
|
+
// against. If that conversion is ever wanted it should be its own flag,
|
|
385
|
+
// named for what it does.
|
|
386
|
+
if (typeof item.ref === 'string' && item.ref.trim().length > 0) {
|
|
387
|
+
skipped.push({ index, title: describeItem(item, index), reason: 'external-ref' });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (item.inlineManifest && !options.force) {
|
|
391
|
+
skipped.push({ index, title: describeItem(item, index), reason: 'already-labelled' });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const coordinate = coordinateFor(item);
|
|
395
|
+
const key = coordinateKeyOf(item.provenance);
|
|
396
|
+
if (!coordinate || !key) {
|
|
397
|
+
skipped.push({ index, title: describeItem(item, index), reason: 'no-provenance' });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
// `evm` names a family and the response cannot narrow it, so enriching one
|
|
401
|
+
// on the assumption of Ethereum can attach a different artwork's artist,
|
|
402
|
+
// description, and still — into a document the curator then signs. The
|
|
403
|
+
// operator has to assert the network; the command will not assume it.
|
|
404
|
+
if (key.startsWith(`${AMBIGUOUS_CHAIN}:`) && !options.assumeEthereum) {
|
|
405
|
+
skipped.push({ index, title: describeItem(item, index), reason: 'ambiguous-chain' });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
pending.push({ index, key });
|
|
409
|
+
if (!coordinates.has(key)) {
|
|
410
|
+
coordinates.set(key, coordinate);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
const assumedEthereum = [...coordinates.keys()].filter((key) => key.startsWith(`${AMBIGUOUS_CHAIN}:`)).length;
|
|
414
|
+
if (pending.length === 0) {
|
|
415
|
+
return { playlist, enriched: 0, skipped, signatureInvalidated: false, assumedEthereum: 0 };
|
|
416
|
+
}
|
|
417
|
+
const resolved = await lookup([...coordinates.values()], options.onProgress);
|
|
418
|
+
// Index what came back by its own coordinate. A response carrying no usable
|
|
419
|
+
// provenance cannot be attributed to any request, so it is dropped rather
|
|
420
|
+
// than guessed at; the affected item then reports as not-indexed, which is
|
|
421
|
+
// exactly what happened.
|
|
422
|
+
const byCoordinate = new Map();
|
|
423
|
+
for (const entry of resolved) {
|
|
424
|
+
const key = coordinateKeyOf(entry.provenance);
|
|
425
|
+
if (key && !byCoordinate.has(key)) {
|
|
426
|
+
byCoordinate.set(key, entry);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
let enriched = 0;
|
|
430
|
+
for (const entry of pending) {
|
|
431
|
+
const item = items[entry.index];
|
|
432
|
+
const match = byCoordinate.get(entry.key);
|
|
433
|
+
const manifest = match ? manifestFor(item, match) : undefined;
|
|
434
|
+
if (!manifest) {
|
|
435
|
+
// The indexer resolving a token and the indexer having anything worth
|
|
436
|
+
// attaching are different outcomes. buildInlineManifestForToken returns
|
|
437
|
+
// undefined when it has only a title, because a title-only manifest adds
|
|
438
|
+
// payload to every transfer without adding information. Reporting that
|
|
439
|
+
// as "the indexer returned nothing" would be false.
|
|
440
|
+
skipped.push({
|
|
441
|
+
index: entry.index,
|
|
442
|
+
title: describeItem(item, entry.index),
|
|
443
|
+
reason: match ? 'no-metadata' : 'not-indexed',
|
|
444
|
+
});
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
item.inlineManifest = manifest;
|
|
448
|
+
enriched += 1;
|
|
449
|
+
}
|
|
450
|
+
const hadSignature = playlist.signatures !== undefined || playlist.signature !== undefined;
|
|
451
|
+
const signatureInvalidated = enriched > 0 && hadSignature;
|
|
452
|
+
if (signatureInvalidated) {
|
|
453
|
+
delete playlist.signatures;
|
|
454
|
+
delete playlist.signature;
|
|
455
|
+
logger.debug('[Enrich] Dropped signatures: the enriched document must be re-signed.');
|
|
456
|
+
}
|
|
457
|
+
skipped.sort((a, b) => a.index - b.index);
|
|
458
|
+
return { playlist, enriched, skipped, signatureInvalidated, assumedEthereum };
|
|
459
|
+
}
|
|
@@ -505,7 +505,7 @@ async function buildPlaylistDirect(params, options = {}) {
|
|
|
505
505
|
console.log(chalk.cyan('\nPublishing to feed server...'));
|
|
506
506
|
try {
|
|
507
507
|
const { publishPlaylist } = require('./playlist-publisher');
|
|
508
|
-
publishResult = await publishPlaylist(savedPath, playlistSettings.feedServer.baseUrl
|
|
508
|
+
publishResult = await publishPlaylist(savedPath, playlistSettings.feedServer.baseUrl);
|
|
509
509
|
if (publishResult.success) {
|
|
510
510
|
console.log(chalk.green(`✓ Published to feed server`));
|
|
511
511
|
if (publishResult.playlistId) {
|
|
@@ -4,10 +4,19 @@
|
|
|
4
4
|
* to retrieve comprehensive token information.
|
|
5
5
|
*/
|
|
6
6
|
const { PlaylistItemBuilder, ProvenanceBuilder, ContractBuilder } = require('dp1-js');
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
7
|
+
// The production endpoint is hardcoded, as PROJECT_SPEC states, and stays
|
|
8
|
+
// that way. INDEXER_API_URL is a test hook, not configuration: the
|
|
9
|
+
// find-command suite spawns the CLI as a child process, so the only way to
|
|
10
|
+
// point it at a local mock is through the environment.
|
|
11
|
+
//
|
|
12
|
+
// It is gated on NODE_ENV=test because index.ts loads dotenv at startup. An
|
|
13
|
+
// ungated read would mean a stray INDEXER_API_URL in someone's .env, or an
|
|
14
|
+
// inherited shell value, silently redirecting every token lookup and indexing
|
|
15
|
+
// mutation — a configuration surface nobody documented and no one would think
|
|
16
|
+
// to check. The gate keeps the hook usable without creating that surface.
|
|
17
|
+
const GRAPHQL_ENDPOINT = process.env.NODE_ENV === 'test' && process.env.INDEXER_API_URL
|
|
18
|
+
? process.env.INDEXER_API_URL
|
|
19
|
+
: 'https://indexer.feralfile.com/graphql';
|
|
11
20
|
const { fetchWithTimeout } = require('./http');
|
|
12
21
|
const logger = require('../logger');
|
|
13
22
|
const { applyTimingToItemBuilder, buildDefaultDisplay } = require('./playlist-builder');
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const { PlaylistBuilder, PlaylistItemBuilder, DisplayPrefsBuilder, ProvenanceBuilder, ContractBuilder, ValidatePlaylist, slugify: dp1Slugify, generateId, } = require('dp1-js');
|
|
11
11
|
const { getPlaylistConfig, getConfig } = require('../config');
|
|
12
12
|
const { signPlaylist } = require('./playlist-signer');
|
|
13
|
+
const { playlistSigningDidKey } = require('./signing-identity');
|
|
13
14
|
const { buildInlineManifestForToken } = require('./ref-manifest');
|
|
14
15
|
/** Default display background that passes DP-1 hex color validation. */
|
|
15
16
|
const DEFAULT_BACKGROUND = '#111111';
|
|
@@ -482,9 +483,20 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
|
|
|
482
483
|
}
|
|
483
484
|
const playlist = playlistBuilder.build();
|
|
484
485
|
// Sign the playlist if private key is configured.
|
|
486
|
+
//
|
|
487
|
+
// The curator declaration has to be written BEFORE signing. The feed accepts a create only when a
|
|
488
|
+
// signature's kid matches a key in the document's own curators[], and a signature covers curators[],
|
|
489
|
+
// so declaring it afterwards would invalidate the signature we just produced. A playlist built without
|
|
490
|
+
// this entry is signed but unpublishable, which is how `find --publish` used to fail.
|
|
485
491
|
try {
|
|
486
492
|
const playlistConfig = getPlaylistConfig();
|
|
487
493
|
if (playlistConfig.privateKey) {
|
|
494
|
+
const key = playlistSigningDidKey(playlistConfig.privateKey);
|
|
495
|
+
const declared = Array.isArray(playlist.curators) ? playlist.curators : [];
|
|
496
|
+
// Respect a curator the caller already declared for this key; only add what is missing.
|
|
497
|
+
if (!declared.some((curator) => curator && curator.key === key)) {
|
|
498
|
+
playlist.curators = [...declared, { name: playlistConfig.curatorName || 'ff-cli', key }];
|
|
499
|
+
}
|
|
488
500
|
playlist.signatures = [await signPlaylist(playlist, playlistConfig.privateKey)];
|
|
489
501
|
delete playlist.signature;
|
|
490
502
|
}
|
|
@@ -18,17 +18,16 @@ const playlist_verifier_1 = require("./playlist-verifier");
|
|
|
18
18
|
*
|
|
19
19
|
* @param {string} filePath - Path to playlist JSON file
|
|
20
20
|
* @param {string} feedServerUrl - Feed server base URL
|
|
21
|
-
* @param {string} [apiKey] - Optional API key for authentication
|
|
22
21
|
* @returns {Promise<Object>} Result with success status, playlistId, or error
|
|
23
22
|
* @example
|
|
24
|
-
* const result = await publishPlaylist('playlist.json', 'http://localhost:8787/api/v1'
|
|
23
|
+
* const result = await publishPlaylist('playlist.json', 'http://localhost:8787/api/v1');
|
|
25
24
|
* if (result.success) {
|
|
26
25
|
* console.log(`Published with ID: ${result.playlistId}`);
|
|
27
26
|
* } else {
|
|
28
27
|
* console.error(`Failed: ${result.error}`);
|
|
29
28
|
* }
|
|
30
29
|
*/
|
|
31
|
-
async function publishPlaylist(filePath, feedServerUrl
|
|
30
|
+
async function publishPlaylist(filePath, feedServerUrl) {
|
|
32
31
|
try {
|
|
33
32
|
// Step 1: Read and parse playlist file
|
|
34
33
|
if (!fs_1.default.existsSync(filePath)) {
|
|
@@ -48,7 +47,31 @@ async function publishPlaylist(filePath, feedServerUrl, apiKey) {
|
|
|
48
47
|
error: `Invalid JSON in playlist file: ${filePath}`,
|
|
49
48
|
};
|
|
50
49
|
}
|
|
51
|
-
// Step 2:
|
|
50
|
+
// Step 2: refuse legacy flat-signature documents outright, before verification.
|
|
51
|
+
//
|
|
52
|
+
// A flat `signature` string carries no kid, so the curator rule below has nothing to match and such
|
|
53
|
+
// a document would reach the feed only to be refused as unauthenticated — verified against a running
|
|
54
|
+
// feed. This runs ahead of verification deliberately: the document is unpublishable whether or not
|
|
55
|
+
// its legacy signature is valid, and 're-sign as a v1.1 envelope' is the remedy either way, which
|
|
56
|
+
// 'signature verification failed' would not convey.
|
|
57
|
+
const hasLegacyFlatSignature = typeof playlist.signature === 'string' &&
|
|
58
|
+
playlist.signature.trim().length > 0;
|
|
59
|
+
const hasEnvelope = Array.isArray(playlist.signatures)
|
|
60
|
+
? (playlist.signatures ?? []).length > 0
|
|
61
|
+
: false;
|
|
62
|
+
if (!hasEnvelope && hasLegacyFlatSignature) {
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
error: 'Playlist carries a legacy flat signature, which the feed cannot authorize.',
|
|
66
|
+
message: `The feed authorizes a publish from a signatures[] envelope, matching a signature's kid\n` +
|
|
67
|
+
` against the playlist's own curators[]. A flat "signature" string carries no kid.\n` +
|
|
68
|
+
` Re-sign it as a DP-1 v1.1 envelope:\n` +
|
|
69
|
+
` 1. remove the "signature" field\n` +
|
|
70
|
+
` 2. declare your key: "curators": [{ "name": "Your name", "key": "<did:key from ff-cli status>" }]\n` +
|
|
71
|
+
` 3. ff-cli sign <file>`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// Step 3: Verify signature integrity before publishing.
|
|
52
75
|
const deliveryResult = await (0, playlist_verifier_1.verifyPlaylist)(playlist);
|
|
53
76
|
if (!deliveryResult.valid) {
|
|
54
77
|
return {
|
|
@@ -57,17 +80,42 @@ async function publishPlaylist(filePath, feedServerUrl, apiKey) {
|
|
|
57
80
|
message: deliveryResult.details?.map((d) => ` • ${d.path}: ${d.message}`).join('\n'),
|
|
58
81
|
};
|
|
59
82
|
}
|
|
60
|
-
// Step
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
83
|
+
// Step 4: fail here, not at the feed, when the signer is not declared as a curator.
|
|
84
|
+
//
|
|
85
|
+
// The feed accepts a create only when a signature's `kid` matches a key the document declares in
|
|
86
|
+
// `curators[]`. Signing alone does not satisfy that, and the server's answer — "no valid curator
|
|
87
|
+
// signature found" — reads as a signing problem, which sends people to check their key instead of
|
|
88
|
+
// their document. Checking locally turns a confusing 400 into an instruction, and costs no request.
|
|
89
|
+
const curatorKeys = new Set((Array.isArray(playlist.curators)
|
|
90
|
+
? (playlist.curators ?? [])
|
|
91
|
+
: [])
|
|
92
|
+
.map((c) => (typeof c?.key === 'string' ? c.key.trim() : ''))
|
|
93
|
+
.filter((k) => k.length > 0));
|
|
94
|
+
const signatures = Array.isArray(playlist.signatures)
|
|
95
|
+
? (playlist.signatures ?? [])
|
|
96
|
+
: [];
|
|
97
|
+
const signingKids = signatures
|
|
98
|
+
.map((sig) => (typeof sig?.kid === 'string' ? sig.kid.trim() : ''))
|
|
99
|
+
.filter((k) => k.length > 0);
|
|
100
|
+
if (signingKids.length > 0 && !signingKids.some((kid) => curatorKeys.has(kid))) {
|
|
101
|
+
return {
|
|
102
|
+
success: false,
|
|
103
|
+
error: 'Playlist is signed, but the signing key is not declared as a curator.',
|
|
104
|
+
message: `The feed accepts a publish when a signature's kid appears in the playlist's own curators[].\n` +
|
|
105
|
+
` Add this to the playlist before signing:\n` +
|
|
106
|
+
` "curators": [{ "name": "Your name", "key": "${signingKids[0]}" }]\n` +
|
|
107
|
+
` then sign again from the unsigned file — signing appends, so re-signing an already-signed\n` +
|
|
108
|
+
` playlist leaves the earlier signature covering a document that no longer exists.`,
|
|
109
|
+
};
|
|
68
110
|
}
|
|
111
|
+
// Step 5: Send validated playlist to feed server.
|
|
112
|
+
//
|
|
113
|
+
// No auth header. The feed authorizes a create from the document's own signatures: it requires a
|
|
114
|
+
// signature whose kid matches a key declared in the playlist's `curators[]`. An API key is neither
|
|
115
|
+
// sent nor accepted -- the feed removed that path entirely -- so a playlist that is not self-signed
|
|
116
|
+
// by a declared curator is rejected no matter what credentials accompany it.
|
|
69
117
|
const response = await axios_1.default.post(`${feedServerUrl}/playlists`, playlist, {
|
|
70
|
-
headers,
|
|
118
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
119
|
timeout: 30000,
|
|
72
120
|
});
|
|
73
121
|
const playlistId = response.data?.id || response.data?.uuid;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The `did:key` a signature from the configured playlist key will carry as its `kid`.
|
|
4
|
+
*
|
|
5
|
+
* The feed authorizes a create by matching a signature's `kid` against a key the document declares in
|
|
6
|
+
* `curators[]`. Until now that value existed nowhere a user could see: it appeared for the first time
|
|
7
|
+
* *inside* a signature, so learning it meant signing something first. That made the only correct
|
|
8
|
+
* publishing sequence — declare the curator, then sign — impossible to follow, and the obvious
|
|
9
|
+
* workaround is broken: signing appends to `signatures[]`, so signing to discover the kid, adding
|
|
10
|
+
* `curators[]`, then signing again leaves the first signature covering a document that no longer exists,
|
|
11
|
+
* and verification fails before upload.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.playlistSigningDidKey = playlistSigningDidKey;
|
|
15
|
+
const node_crypto_1 = require("node:crypto");
|
|
16
|
+
const ed25519_key_derive_1 = require("./ed25519-key-derive");
|
|
17
|
+
/**
|
|
18
|
+
* Derive the `did:key` for playlist signing key material.
|
|
19
|
+
*
|
|
20
|
+
* Accepts everything the signer accepts (PKCS#8 base64, raw seed hex/base64, PEM), because a user who
|
|
21
|
+
* can sign with a key must be able to see the identity that signing will assert.
|
|
22
|
+
*/
|
|
23
|
+
function playlistSigningDidKey(privateKeyMaterial) {
|
|
24
|
+
const privateKey = (0, ed25519_key_derive_1.parsePlaylistPrivateKeyToKeyObject)(privateKeyMaterial);
|
|
25
|
+
const raw = (0, node_crypto_1.createPublicKey)(privateKey).export({ format: 'jwk' });
|
|
26
|
+
if (!raw.x) {
|
|
27
|
+
throw new Error('Could not derive a public key from the configured playlist signing key');
|
|
28
|
+
}
|
|
29
|
+
// JWK `x` for Ed25519 is the 32-byte public key, base64url.
|
|
30
|
+
const publicKey = Buffer.from(raw.x, 'base64url');
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
32
|
+
const { Ed25519DIDKey } = require('dp1-js');
|
|
33
|
+
return Ed25519DIDKey(publicKey);
|
|
34
|
+
}
|