@feralfile/cli 2.4.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/config.json.example +4 -5
- package/dist/package.json +3 -3
- package/dist/src/commands/enrich.js +23 -4
- 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 +88 -79
- package/dist/src/utilities/index.js +1 -1
- package/dist/src/utilities/nft-indexer.js +100 -18
- package/dist/src/utilities/playlist-builder.js +30 -1
- package/dist/src/utilities/playlist-cast.js +20 -1
- package/dist/src/utilities/playlist-publisher.js +130 -13
- package/dist/src/utilities/signing-identity.js +34 -0
- package/docs/CONFIGURATION.md +41 -1
- package/docs/EXAMPLES.md +157 -12
- package/docs/PROJECT_SPEC.md +28 -15
- package/docs/README.md +38 -8
- package/package.json +3 -3
|
@@ -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
|
|
@@ -10,9 +10,18 @@
|
|
|
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';
|
|
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';
|
|
16
25
|
/**
|
|
17
26
|
* isTimeBasedMedia reports whether an item's media has an intrinsic runtime
|
|
18
27
|
* (video or audio) per DP-1 §4.1 "time-based sources".
|
|
@@ -482,10 +491,30 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
|
|
|
482
491
|
}
|
|
483
492
|
const playlist = playlistBuilder.build();
|
|
484
493
|
// Sign the playlist if private key is configured.
|
|
494
|
+
//
|
|
495
|
+
// The curator declaration has to be written BEFORE signing. The feed accepts a create only when a
|
|
496
|
+
// signature's kid matches a key in the document's own curators[], and a signature covers curators[],
|
|
497
|
+
// so declaring it afterwards would invalidate the signature we just produced. A playlist built without
|
|
498
|
+
// this entry is signed but unpublishable, which is how `find --publish` used to fail.
|
|
485
499
|
try {
|
|
486
500
|
const playlistConfig = getPlaylistConfig();
|
|
487
501
|
if (playlistConfig.privateKey) {
|
|
488
|
-
|
|
502
|
+
const key = playlistSigningDidKey(playlistConfig.privateKey);
|
|
503
|
+
const declared = Array.isArray(playlist.curators) ? playlist.curators : [];
|
|
504
|
+
// Respect a curator the caller already declared for this key; only add what is missing.
|
|
505
|
+
if (!declared.some((curator) => curator && curator.key === key)) {
|
|
506
|
+
playlist.curators = [...declared, { name: playlistConfig.curatorName || 'ff-cli', key }];
|
|
507
|
+
}
|
|
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
|
+
];
|
|
489
518
|
delete playlist.signature;
|
|
490
519
|
}
|
|
491
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
|
*
|
|
@@ -18,17 +24,16 @@ const playlist_verifier_1 = require("./playlist-verifier");
|
|
|
18
24
|
*
|
|
19
25
|
* @param {string} filePath - Path to playlist JSON file
|
|
20
26
|
* @param {string} feedServerUrl - Feed server base URL
|
|
21
|
-
* @param {string} [apiKey] - Optional API key for authentication
|
|
22
27
|
* @returns {Promise<Object>} Result with success status, playlistId, or error
|
|
23
28
|
* @example
|
|
24
|
-
* const result = await publishPlaylist('playlist.json', 'http://localhost:8787/api/v1'
|
|
29
|
+
* const result = await publishPlaylist('playlist.json', 'http://localhost:8787/api/v1');
|
|
25
30
|
* if (result.success) {
|
|
26
31
|
* console.log(`Published with ID: ${result.playlistId}`);
|
|
27
32
|
* } else {
|
|
28
33
|
* console.error(`Failed: ${result.error}`);
|
|
29
34
|
* }
|
|
30
35
|
*/
|
|
31
|
-
async function publishPlaylist(filePath, feedServerUrl
|
|
36
|
+
async function publishPlaylist(filePath, feedServerUrl) {
|
|
32
37
|
try {
|
|
33
38
|
// Step 1: Read and parse playlist file
|
|
34
39
|
if (!fs_1.default.existsSync(filePath)) {
|
|
@@ -48,7 +53,33 @@ async function publishPlaylist(filePath, feedServerUrl, apiKey) {
|
|
|
48
53
|
error: `Invalid JSON in playlist file: ${filePath}`,
|
|
49
54
|
};
|
|
50
55
|
}
|
|
51
|
-
// Step 2:
|
|
56
|
+
// Step 2: refuse legacy flat-signature documents outright, before verification.
|
|
57
|
+
//
|
|
58
|
+
// A flat `signature` string carries no kid, so the curator rule below has nothing to match and such
|
|
59
|
+
// a document would reach the feed only to be refused as unauthenticated — verified against a running
|
|
60
|
+
// feed. This runs ahead of verification deliberately: the document is unpublishable whether or not
|
|
61
|
+
// its legacy signature is valid, and 're-sign as a v1.1 envelope' is the remedy either way, which
|
|
62
|
+
// 'signature verification failed' would not convey.
|
|
63
|
+
const hasLegacyFlatSignature = typeof playlist.signature === 'string' &&
|
|
64
|
+
playlist.signature.trim().length > 0;
|
|
65
|
+
const hasEnvelope = Array.isArray(playlist.signatures)
|
|
66
|
+
? (playlist.signatures ?? []).length > 0
|
|
67
|
+
: false;
|
|
68
|
+
if (!hasEnvelope && hasLegacyFlatSignature) {
|
|
69
|
+
return {
|
|
70
|
+
success: false,
|
|
71
|
+
error: 'Playlist carries a legacy flat signature, which the feed cannot authorize.',
|
|
72
|
+
message: `The feed authorizes a publish from a signatures[] envelope, matching a signature's kid\n` +
|
|
73
|
+
` against the playlist's own curators[]. A flat "signature" string carries no kid.\n` +
|
|
74
|
+
` Re-sign it as a DP-1 v1.1 envelope:\n` +
|
|
75
|
+
` 1. remove the "signature" field\n` +
|
|
76
|
+
` 2. declare your key: "curators": [{ "name": "Your name", "key": "<did:key from ff-cli status>" }]\n` +
|
|
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".`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Step 3: Verify signature integrity before publishing.
|
|
52
83
|
const deliveryResult = await (0, playlist_verifier_1.verifyPlaylist)(playlist);
|
|
53
84
|
if (!deliveryResult.valid) {
|
|
54
85
|
return {
|
|
@@ -57,17 +88,103 @@ async function publishPlaylist(filePath, feedServerUrl, apiKey) {
|
|
|
57
88
|
message: deliveryResult.details?.map((d) => ` • ${d.path}: ${d.message}`).join('\n'),
|
|
58
89
|
};
|
|
59
90
|
}
|
|
60
|
-
// Step
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
// Step 4: fail here, not at the feed, when the signer is not declared as a curator.
|
|
92
|
+
//
|
|
93
|
+
// The feed accepts a create only when a signature's `kid` matches a key the document declares in
|
|
94
|
+
// `curators[]`. Signing alone does not satisfy that, and the server's answer — "no valid curator
|
|
95
|
+
// signature found" — reads as a signing problem, which sends people to check their key instead of
|
|
96
|
+
// their document. Checking locally turns a confusing 400 into an instruction, and costs no request.
|
|
97
|
+
const curatorKeys = new Set((Array.isArray(playlist.curators)
|
|
98
|
+
? (playlist.curators ?? [])
|
|
99
|
+
: [])
|
|
100
|
+
.map((c) => (typeof c?.key === 'string' ? c.key.trim() : ''))
|
|
101
|
+
.filter((k) => k.length > 0));
|
|
102
|
+
const signatures = Array.isArray(playlist.signatures)
|
|
103
|
+
? (playlist
|
|
104
|
+
.signatures ?? [])
|
|
105
|
+
: [];
|
|
106
|
+
const signingKids = signatures
|
|
107
|
+
.map((sig) => (typeof sig?.kid === 'string' ? sig.kid.trim() : ''))
|
|
108
|
+
.filter((k) => k.length > 0);
|
|
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) {
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
error: 'Playlist is signed, but the signing key is not declared as a curator.',
|
|
115
|
+
message: `The feed accepts a publish when a signature's kid appears in the playlist's own curators[].\n` +
|
|
116
|
+
` Add this to the playlist before signing:\n` +
|
|
117
|
+
` "curators": [{ "name": "Your name", "key": "${signingKids[0]}" }]\n` +
|
|
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.`,
|
|
178
|
+
};
|
|
68
179
|
}
|
|
180
|
+
// Step 5: Send validated playlist to feed server.
|
|
181
|
+
//
|
|
182
|
+
// No auth header. The feed authorizes a create from the document's own signatures: it requires a
|
|
183
|
+
// signature whose kid matches a key declared in the playlist's `curators[]`. An API key is neither
|
|
184
|
+
// sent nor accepted -- the feed removed that path entirely -- so a playlist that is not self-signed
|
|
185
|
+
// by a declared curator is rejected no matter what credentials accompany it.
|
|
69
186
|
const response = await axios_1.default.post(`${feedServerUrl}/playlists`, playlist, {
|
|
70
|
-
headers,
|
|
187
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
188
|
timeout: 30000,
|
|
72
189
|
});
|
|
73
190
|
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
|
+
}
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -50,7 +50,20 @@ Used for signing DP‑1 playlists.
|
|
|
50
50
|
|
|
51
51
|
ff-cli normalizes whichever form you supply to base64 PKCS#8 DER before handing it to **`dp1-js`** (`SignMultiEd25519`). A malformed key fails with an actionable message (e.g. _"Invalid Ed25519 signing key … run `ff-cli setup` to generate one"_) instead of dp1-js's cryptic OpenSSL ASN.1 error (`header too long` / `wrong tag`).
|
|
52
52
|
|
|
53
|
-
- `playlist.
|
|
53
|
+
- `playlist.curatorName` (string): Display name recorded alongside your signing key in the `curators[]`
|
|
54
|
+
of playlists built by `find` and `build`. Defaults to `ff-cli`; you can also set it via
|
|
55
|
+
`PLAYLIST_CURATOR_NAME`. The feed authorizes a create by matching a signature's `kid` against a key
|
|
56
|
+
declared in the document's own `curators[]`, so those commands write that declaration **before**
|
|
57
|
+
signing — a name is required next to the key by DP-1, and the value is public and covered by the
|
|
58
|
+
signature. The sample `YOUR_CURATOR_NAME` in `config.json.example` is treated as unset, so an untouched
|
|
59
|
+
config falls through to `PLAYLIST_CURATOR_NAME` or the default rather than publishing under a
|
|
60
|
+
placeholder.
|
|
61
|
+
|
|
62
|
+
To read the `did:key` your signatures will carry, run `ff-cli status`. It reports the identity from
|
|
63
|
+
`PLAYLIST_PRIVATE_KEY` even when no config file exists yet, and `ff-cli status --key <privateKey>`
|
|
64
|
+
reports it for an explicit key — the one `sign --key` would use.
|
|
65
|
+
|
|
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).
|
|
54
67
|
|
|
55
68
|
### Generate an Ed25519 private key
|
|
56
69
|
|
|
@@ -75,6 +88,33 @@ Paste the value into `playlist.privateKey`. Any of these are accepted:
|
|
|
75
88
|
|
|
76
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`.
|
|
77
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
|
+
|
|
78
118
|
If you already have a base64 key and want hex, convert it:
|
|
79
119
|
|
|
80
120
|
```bash
|