@feralfile/cli 2.6.0 → 2.8.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.
@@ -4,15 +4,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.publishPlaylist = publishPlaylist;
7
+ exports.replacePlaylist = replacePlaylist;
7
8
  const axios_1 = __importDefault(require("axios"));
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  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';
11
+ const config_1 = require("../config");
12
+ const feed_mutation_1 = require("./feed-mutation");
13
+ const signing_identity_1 = require("./signing-identity");
16
14
  /**
17
15
  * Publish a verified playlist to a DP-1 feed server
18
16
  *
@@ -34,149 +32,16 @@ const OWNER_ROLE = 'curator';
34
32
  * }
35
33
  */
36
34
  async function publishPlaylist(filePath, feedServerUrl) {
35
+ const loaded = readPlaylistFile(filePath);
36
+ if ('failure' in loaded) {
37
+ return loaded.failure;
38
+ }
39
+ const { playlist } = loaded;
40
+ const refusal = await preflightPlaylistForUpload(playlist);
41
+ if (refusal) {
42
+ return refusal;
43
+ }
37
44
  try {
38
- // Step 1: Read and parse playlist file
39
- if (!fs_1.default.existsSync(filePath)) {
40
- return {
41
- success: false,
42
- error: `Playlist file not found: ${filePath}`,
43
- };
44
- }
45
- let playlist;
46
- try {
47
- const content = fs_1.default.readFileSync(filePath, 'utf-8');
48
- playlist = JSON.parse(content);
49
- }
50
- catch (_parseError) {
51
- return {
52
- success: false,
53
- error: `Invalid JSON in playlist file: ${filePath}`,
54
- };
55
- }
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.
83
- const deliveryResult = await (0, playlist_verifier_1.verifyPlaylist)(playlist);
84
- if (!deliveryResult.valid) {
85
- return {
86
- success: false,
87
- error: `Playlist verification failed: ${deliveryResult.error}`,
88
- message: deliveryResult.details?.map((d) => ` • ${d.path}: ${d.message}`).join('\n'),
89
- };
90
- }
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
- };
179
- }
180
45
  // Step 5: Send validated playlist to feed server.
181
46
  //
182
47
  // No auth header. The feed authorizes a create from the document's own signatures: it requires a
@@ -213,3 +78,372 @@ async function publishPlaylist(filePath, feedServerUrl) {
213
78
  };
214
79
  }
215
80
  }
81
+ /**
82
+ * Replace a playlist already stored on a DP-1 feed with a re-signed document.
83
+ *
84
+ * A `POST` of an id the feed already holds is a `409`; editing an existing playlist goes through `PUT`,
85
+ * which is owner-bound, owner-immutable and replay-bound (dp1-feed-v2 `docs/api_design.md`, "PUT
86
+ * (replace)"). The body is not the document alone but `{ document, authorization }`: an owner's document
87
+ * signatures are public via `GET`, so a document on its own could be replayed to roll a playlist back to
88
+ * an earlier version. The authorization intent carries a `payloadHash` binding it to these exact bytes
89
+ * and a `created` inside the freshness window, both inside a signed payload, which is what a replayed
90
+ * body cannot forge.
91
+ *
92
+ * Identity is validated, not substituted: `id`, `slug`, and `created` must equal the stored document's,
93
+ * and the owner set may not change. Those three are checked locally first — the feed's answer is a bare
94
+ * `400`, which does not say which field moved, and the usual cause is a rebuilt playlist that minted a
95
+ * fresh `created` rather than an edited one.
96
+ *
97
+ * Flow:
98
+ * 1. Read, verify, and preflight the document exactly as a create would.
99
+ * 2. `GET` the stored playlist by the document's own id.
100
+ * 3. Refuse locally on an identity change, an owner-set change, or a non-owner signing key.
101
+ * 4. Sign an authorization intent bound to the document's payload hash.
102
+ * 5. `PUT` both halves and report the outcome.
103
+ *
104
+ * @param filePath - Path to the fully re-signed playlist JSON file
105
+ * @param feedServerUrl - Feed server base URL, including `/api/v1`
106
+ * @param options - Optional signing-key override for the authorization intent
107
+ * @returns Result with the replaced playlist id, or a diagnosis and a remedy
108
+ * @example
109
+ * const result = await replacePlaylist('playlist.json', 'https://feed.example.com/api/v1');
110
+ */
111
+ async function replacePlaylist(filePath, feedServerUrl, options = {}) {
112
+ const loaded = readPlaylistFile(filePath);
113
+ if ('failure' in loaded) {
114
+ return loaded.failure;
115
+ }
116
+ const { playlist } = loaded;
117
+ const refusal = await preflightPlaylistForUpload(playlist);
118
+ if (refusal) {
119
+ return refusal;
120
+ }
121
+ const documentId = typeof playlist.id === 'string' ? playlist.id.trim() : '';
122
+ if (!documentId) {
123
+ return {
124
+ success: false,
125
+ error: 'The playlist has no id, so there is nothing on the feed for it to replace.',
126
+ message: 'A replace targets an existing resource by its id. Publish it without --replace to create it.',
127
+ };
128
+ }
129
+ let privateKey;
130
+ let signerDidKey;
131
+ try {
132
+ privateKey = resolveIntentSigningKey(options.privateKey);
133
+ signerDidKey = (0, signing_identity_1.playlistSigningDidKey)(privateKey);
134
+ }
135
+ catch (error) {
136
+ return { success: false, error: error.message };
137
+ }
138
+ let stored;
139
+ try {
140
+ stored = await (0, feed_mutation_1.fetchStoredPlaylist)(feedServerUrl, documentId);
141
+ }
142
+ catch (error) {
143
+ const described = (0, feed_mutation_1.describeFeedMutationError)(error, 'replace');
144
+ const missing = error.response?.status === 404;
145
+ return {
146
+ success: false,
147
+ ...described,
148
+ // A 404 here is not the same failure as a 404 on the PUT itself: the operator asked to replace a
149
+ // playlist this feed has never held, and the fix is to publish it instead of guessing at ids.
150
+ ...(missing
151
+ ? {
152
+ error: `This feed has no playlist with id ${documentId}, so there is nothing to replace.`,
153
+ message: 'Publish it without --replace to create it. If it was deleted, the id is tombstoned and\n' +
154
+ ' cannot be recreated — build the playlist again so it gets a new id.',
155
+ }
156
+ : {}),
157
+ feedServer: feedServerUrl,
158
+ };
159
+ }
160
+ const mismatch = replaceIdentityMismatch(playlist, stored);
161
+ if (mismatch) {
162
+ return { success: false, ...mismatch, feedServer: feedServerUrl };
163
+ }
164
+ const ownership = await (0, feed_mutation_1.ownershipPreflight)(stored, signerDidKey, 'replace');
165
+ if (ownership) {
166
+ return { success: false, ...ownership, feedServer: feedServerUrl };
167
+ }
168
+ try {
169
+ const created = (0, feed_mutation_1.intentTimestamp)();
170
+ const payloadHash = await (0, feed_mutation_1.documentPayloadHash)(playlist);
171
+ const storedSlug = typeof stored.slug === 'string' ? stored.slug : '';
172
+ const intent = (0, feed_mutation_1.buildReplaceIntent)(documentId, storedSlug, payloadHash, created);
173
+ const signature = await (0, feed_mutation_1.signIntent)(intent, privateKey);
174
+ const response = await axios_1.default.put(`${feedServerUrl}/playlists/${encodeURIComponent(documentId)}`, { document: playlist, authorization: { ...intent, signatures: [signature] } }, { headers: { 'Content-Type': 'application/json' }, timeout: 30000 });
175
+ if (response.status !== 200) {
176
+ return {
177
+ success: false,
178
+ error: `Unexpected response status: ${response.status}`,
179
+ feedServer: feedServerUrl,
180
+ };
181
+ }
182
+ return {
183
+ success: true,
184
+ playlistId: response.data?.id || documentId,
185
+ message: 'Replaced on feed server',
186
+ feedServer: feedServerUrl,
187
+ };
188
+ }
189
+ catch (error) {
190
+ return {
191
+ success: false,
192
+ ...(0, feed_mutation_1.describeFeedMutationError)(error, 'replace'),
193
+ feedServer: feedServerUrl,
194
+ };
195
+ }
196
+ }
197
+ /**
198
+ * replaceIdentityMismatch reports the immutable fields a replace would have changed.
199
+ *
200
+ * The feed compares `id`, `slug` and `created` against the stored row and holds the owner set fixed; any
201
+ * disagreement is a `400` or `403` that names none of them. The common cause is a rebuilt document rather
202
+ * than an edited one — `find`/`build` mint a fresh id, slug and `created` every run — so the remedy is to
203
+ * edit the published document, not to rebuild it.
204
+ *
205
+ * `created` is compared as an instant because the feed does: two spellings of the same moment are equal.
206
+ *
207
+ * @returns The failure to report, or `null` when identity and ownership are unchanged
208
+ */
209
+ function replaceIdentityMismatch(playlist, stored) {
210
+ const differences = [];
211
+ const documentSlug = typeof playlist.slug === 'string' ? playlist.slug : '';
212
+ const storedSlug = typeof stored.slug === 'string' ? stored.slug : '';
213
+ if (documentSlug !== storedSlug) {
214
+ differences.push(` slug: stored "${storedSlug}", document "${documentSlug}"`);
215
+ }
216
+ const documentCreated = typeof playlist.created === 'string' ? playlist.created : '';
217
+ const storedCreated = typeof stored.created === 'string' ? stored.created : '';
218
+ if (!sameInstant(documentCreated, storedCreated)) {
219
+ differences.push(` created: stored "${storedCreated}", document "${documentCreated}"`);
220
+ }
221
+ const documentOwners = (0, feed_mutation_1.storedOwnerKeys)(playlist);
222
+ const owners = (0, feed_mutation_1.storedOwnerKeys)(stored);
223
+ if (documentOwners.join('') !== owners.join('')) {
224
+ differences.push(` curators: stored [${owners.join(', ')}], document [${documentOwners.join(', ')}]`);
225
+ }
226
+ if (differences.length === 0) {
227
+ return null;
228
+ }
229
+ // The remedy has to hand back a runnable sequence, and every step of it has to produce the input the
230
+ // next one needs. `verify` was named here before and does not: it validates and prints a summary, so
231
+ // someone following it literally ends up with no document to edit and a second dead end.
232
+ const storedId = typeof stored.id === 'string' && stored.id ? stored.id : '<id>';
233
+ return {
234
+ error: 'The document changes fields a replace may not change.',
235
+ message: `A replace keeps identity and ownership fixed: id, slug, and created must equal the stored\n` +
236
+ ` document's, and the curators[] owner set is immutable.\n` +
237
+ `${differences.join('\n')}\n` +
238
+ ` Start from the published document, not a rebuilt one — "find" and "build" mint a new id, slug,\n` +
239
+ ` and created every run, which is a new playlist rather than a replacement:\n` +
240
+ ` ff-cli fetch ${storedId} -o playlist.json\n` +
241
+ ` (edit the fields you meant to change)\n` +
242
+ ` ff-cli sign playlist.json -r ${feed_mutation_1.OWNER_ROLE} --replace-signatures\n` +
243
+ ` ff-cli publish playlist.json --replace\n` +
244
+ ` --replace-signatures is required after an edit: the fetched document's signatures cover the\n` +
245
+ ` content as published, and signing appends rather than replacing.`,
246
+ };
247
+ }
248
+ /** True when two RFC 3339 strings name the same instant, whatever their spelling. */
249
+ function sameInstant(left, right) {
250
+ if (left === right) {
251
+ return true;
252
+ }
253
+ const a = Date.parse(left);
254
+ const b = Date.parse(right);
255
+ return Number.isFinite(a) && Number.isFinite(b) && a === b;
256
+ }
257
+ /**
258
+ * resolveIntentSigningKey resolves the key that signs the authorization intent.
259
+ *
260
+ * The intent is signed separately from the document, and by whoever is running the command: the document
261
+ * may legitimately carry several curators' signatures, while the intent proves that *this* operator holds
262
+ * one of those keys right now.
263
+ */
264
+ function resolveIntentSigningKey(override) {
265
+ if (override && override.trim().length > 0) {
266
+ return override;
267
+ }
268
+ const configured = (0, config_1.getPlaylistConfig)().privateKey;
269
+ if (!configured) {
270
+ throw new Error('No playlist signing key is configured. A replace is authorized by a signed intent, so one is ' +
271
+ 'required: run "ff-cli setup" or set playlist.privateKey in config.json.');
272
+ }
273
+ return configured;
274
+ }
275
+ /**
276
+ * readPlaylistFile reads and parses a playlist document from disk.
277
+ *
278
+ * Split out because both `publishPlaylist` and `replacePlaylist` start here and must answer identically:
279
+ * a missing or malformed file is a local problem, and neither should reach the network to learn that.
280
+ *
281
+ * @param filePath - Path to a playlist JSON file
282
+ * @returns The parsed playlist, or the failure to report
283
+ */
284
+ function readPlaylistFile(filePath) {
285
+ if (!fs_1.default.existsSync(filePath)) {
286
+ return { failure: { success: false, error: `Playlist file not found: ${filePath}` } };
287
+ }
288
+ let parsed;
289
+ try {
290
+ parsed = JSON.parse(fs_1.default.readFileSync(filePath, 'utf-8'));
291
+ }
292
+ catch (_parseError) {
293
+ return { failure: { success: false, error: `Invalid JSON in playlist file: ${filePath}` } };
294
+ }
295
+ // Well-formed JSON is not yet a document. `null`, `[]`, `"text"` and numbers all parse cleanly, and
296
+ // casting them straight to Playlist made the very next step — reading `playlist.signature` — throw a
297
+ // TypeError from inside a helper that sits outside both public entry points' try/catch. The caller
298
+ // asked for a PublishResult and got an exception instead, so a stray file answered with a stack
299
+ // trace rather than a diagnosis. Both verbs must fail here, structurally, as they do for bad JSON.
300
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
301
+ const found = parsed === null ? 'null' : Array.isArray(parsed) ? 'an array' : typeof parsed;
302
+ return {
303
+ failure: {
304
+ success: false,
305
+ error: `Playlist file does not contain a playlist document: ${filePath}`,
306
+ message: `The file is valid JSON but holds ${found}, not an object. A DP-1 playlist is a JSON object\n` +
307
+ ` with dpVersion, id, title, and items at the top level.`,
308
+ },
309
+ };
310
+ }
311
+ return { playlist: parsed };
312
+ }
313
+ /**
314
+ * preflightPlaylistForUpload runs every local gate a document must pass before it is sent to a feed.
315
+ *
316
+ * Create and replace share this entirely: both are authorized by the document's own signatures, and both
317
+ * need a `curator`-role signature from a declared key. Keeping one implementation is what stops the two
318
+ * verbs from drifting into different diagnoses for the same broken document — a replace that accepted a
319
+ * document `publish` refuses would let exactly the unauthorizable state this guards against back in.
320
+ *
321
+ * @param playlist - Parsed playlist document
322
+ * @returns The failure to report, or `null` when the document may be sent
323
+ */
324
+ async function preflightPlaylistForUpload(playlist) {
325
+ // Step 2: refuse legacy flat-signature documents outright, before verification.
326
+ //
327
+ // A flat `signature` string carries no kid, so the curator rule below has nothing to match and such
328
+ // a document would reach the feed only to be refused as unauthenticated — verified against a running
329
+ // feed. This runs ahead of verification deliberately: the document is unpublishable whether or not
330
+ // its legacy signature is valid, and 're-sign as a v1.1 envelope' is the remedy either way, which
331
+ // 'signature verification failed' would not convey.
332
+ const hasLegacyFlatSignature = typeof playlist.signature === 'string' &&
333
+ playlist.signature.trim().length > 0;
334
+ const hasEnvelope = Array.isArray(playlist.signatures)
335
+ ? (playlist.signatures ?? []).length > 0
336
+ : false;
337
+ if (!hasEnvelope && hasLegacyFlatSignature) {
338
+ return {
339
+ success: false,
340
+ error: 'Playlist carries a legacy flat signature, which the feed cannot authorize.',
341
+ message: `The feed authorizes a publish from a signatures[] envelope, matching a signature's kid\n` +
342
+ ` against the playlist's own curators[]. A flat "signature" string carries no kid.\n` +
343
+ ` Re-sign it as a DP-1 v1.1 envelope:\n` +
344
+ ` 1. remove the "signature" field\n` +
345
+ ` 2. declare your key: "curators": [{ "name": "Your name", "key": "<did:key from ff-cli status>" }]\n` +
346
+ ` 3. ff-cli sign <file> -r ${feed_mutation_1.OWNER_ROLE}\n` +
347
+ ` The role matters: a declared key counts as an owner only when it also signed as "${feed_mutation_1.OWNER_ROLE}",\n` +
348
+ ` and plain "ff-cli sign" uses playlist.role, which defaults to "agent".`,
349
+ };
350
+ }
351
+ // Step 3: Verify signature integrity before publishing.
352
+ const deliveryResult = await (0, playlist_verifier_1.verifyPlaylist)(playlist);
353
+ if (!deliveryResult.valid) {
354
+ return {
355
+ success: false,
356
+ error: `Playlist verification failed: ${deliveryResult.error}`,
357
+ message: deliveryResult.details?.map((d) => ` • ${d.path}: ${d.message}`).join('\n'),
358
+ };
359
+ }
360
+ // Step 4: fail here, not at the feed, when the signer is not declared as a curator.
361
+ //
362
+ // The feed accepts a create only when a signature's `kid` matches a key the document declares in
363
+ // `curators[]`. Signing alone does not satisfy that, and the server's answer — "no valid curator
364
+ // signature found" — reads as a signing problem, which sends people to check their key instead of
365
+ // their document. Checking locally turns a confusing 400 into an instruction, and costs no request.
366
+ const curatorKeys = new Set((Array.isArray(playlist.curators)
367
+ ? (playlist.curators ?? [])
368
+ : [])
369
+ .map((c) => (typeof c?.key === 'string' ? c.key.trim() : ''))
370
+ .filter((k) => k.length > 0));
371
+ const signatures = Array.isArray(playlist.signatures)
372
+ ? (playlist
373
+ .signatures ?? [])
374
+ : [];
375
+ const signingKids = signatures
376
+ .map((sig) => (typeof sig?.kid === 'string' ? sig.kid.trim() : ''))
377
+ .filter((k) => k.length > 0);
378
+ const declaredSignatures = signatures.filter((sig) => curatorKeys.has(typeof sig?.kid === 'string' ? sig.kid.trim() : ''));
379
+ // Same predicate as `no declared signature`, expressed through the set the next gate also needs.
380
+ if (signingKids.length > 0 && declaredSignatures.length === 0) {
381
+ return {
382
+ success: false,
383
+ error: 'Playlist is signed, but the signing key is not declared as a curator.',
384
+ message: `The feed accepts a publish when a signature's kid appears in the playlist's own curators[].\n` +
385
+ ` Add this to the playlist before signing:\n` +
386
+ ` "curators": [{ "name": "Your name", "key": "${signingKids[0]}" }]\n` +
387
+ ` then sign again from the unsigned file, with the same key you just declared:\n` +
388
+ ` ff-cli sign <file> -r ${feed_mutation_1.OWNER_ROLE} --key <private key for ${signingKids[0]}>\n` +
389
+ ` Drop --key if that key is your configured one; "sign" uses the configured key otherwise, and a\n` +
390
+ ` signature from an undeclared key would not satisfy the feed. The role is ff-cli's requirement\n` +
391
+ ` rather than every feed's: see the owner-role check for why publishing without it is refused.\n` +
392
+ ` Start from the unsigned file\n` +
393
+ ` because declaring curators[] changes the signed payload: signing appends, so the earlier\n` +
394
+ ` signature would be left covering a document that no longer exists.`,
395
+ };
396
+ }
397
+ // Step 4b: a declared key must also have signed AS curator.
398
+ //
399
+ // Being named in curators[] is a claim; signing in the owner role is the proof, and the feed requires
400
+ // both before it treats a key as an owner. A document that is declared but signed under another role
401
+ // (ff-cli's own default was `agent`) verifies cleanly and is still refused, and the server answers in
402
+ // terms of ownership — which reads as "fix curators[]", the one part that is already correct. Naming
403
+ // the role and the remedy here is the difference between a one-flag fix and a wrong-end search.
404
+ //
405
+ // This is deliberately stricter than a feed that ignores the role: such a feed accepts the document
406
+ // today and freezes it the moment role-aware ownership lands, because a replace and a delete both
407
+ // need an owner signature the document does not carry. Refusing now keeps that document from being
408
+ // created at all.
409
+ if (declaredSignatures.length > 0 && !declaredSignatures.some((sig) => sig.role === feed_mutation_1.OWNER_ROLE)) {
410
+ const rolesUsed = [
411
+ ...new Set(declaredSignatures
412
+ .map((sig) => (typeof sig.role === 'string' ? sig.role.trim() : ''))
413
+ .filter((role) => role.length > 0)),
414
+ ];
415
+ const seen = rolesUsed.length > 0 ? rolesUsed.map((role) => `"${role}"`).join(', ') : 'no role';
416
+ // List every key eligible to repair this, not the one that happens to have signed.
417
+ //
418
+ // The gate above accepts an owner-role signature from ANY declared key, so on a playlist declaring
419
+ // several curators the holder of any one of them can append it. Naming only the key that signed
420
+ // under the wrong role describes an impossible fix to everyone else — they may not hold it — while
421
+ // a key they do hold would have worked. `sign` still defaults to the configured key, which need not
422
+ // be declared at all, so the eligible set has to be stated rather than assumed.
423
+ const eligibleKeys = [...curatorKeys];
424
+ const eligibleList = eligibleKeys.length > 0 ? eligibleKeys.join(', ') : 'a key declared in curators[]';
425
+ return {
426
+ success: false,
427
+ error: `Playlist is signed by a declared curator, but under a non-owner role (${seen}).`,
428
+ message: `This is ff-cli's check, not a feed's answer. Feeds are moving to role-aware ownership, where\n` +
429
+ ` a key in curators[] counts as an owner only if it also signed as "${feed_mutation_1.OWNER_ROLE}". A feed that\n` +
430
+ ` does not check the role yet will accept this document, and then be unable to authorize a\n` +
431
+ ` replace or a delete for it: both need an owner signature it does not carry. Publishing it is\n` +
432
+ ` what makes that permanent, so ff-cli refuses here instead.\n` +
433
+ `\n` +
434
+ ` curators[] is already correct — only the role is missing, so add that signature to this file:\n` +
435
+ ` ff-cli sign <file> -r ${feed_mutation_1.OWNER_ROLE} --key <private key for a declared curator>\n` +
436
+ ` Any key this playlist declares will do — you do not need the one that signed under the wrong\n` +
437
+ ` role. Declared: ${eligibleList}\n` +
438
+ ` It must be one of those: a ${feed_mutation_1.OWNER_ROLE} signature from an undeclared key satisfies neither\n` +
439
+ ` this check nor role-aware ownership, since both read roles only from declared keys. "sign"\n` +
440
+ ` uses the configured key unless --key says otherwise; drop --key if a declared key is already\n` +
441
+ ` your configured one, and confirm which identity a key carries with\n` +
442
+ ` "ff-cli status --key <private key>".\n` +
443
+ ` Signing appends, and the payload hash excludes signatures, so the existing entry stays valid\n` +
444
+ ` and the document ends up carrying both. No unsigned copy is needed: you only have to start\n` +
445
+ ` from one when changing signed content such as curators[] itself.`,
446
+ };
447
+ }
448
+ return null;
449
+ }
@@ -1,37 +1,4 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  var __importDefault = (this && this.__importDefault) || function (mod) {
36
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
4
  };
@@ -46,7 +13,7 @@ const playlist_source_1 = require("./playlist-source");
46
13
  */
47
14
  async function getAvailableDevices() {
48
15
  try {
49
- const configModule = (await Promise.resolve().then(() => __importStar(require('../config'))));
16
+ const configModule = (await import('../config.js'));
50
17
  const getFF1DeviceConfig = configModule.getFF1DeviceConfig ||
51
18
  (configModule.default && configModule.default.getFF1DeviceConfig) ||
52
19
  configModule.default;
@@ -146,7 +113,7 @@ async function confirmPlaylistForSending(filePath, deviceName) {
146
113
  // Parse / structure validation only (same as `validate`; use `verify` CLI for signatures)
147
114
  console.log(chalk_1.default.cyan('Validation'));
148
115
  // Dynamic import to avoid circular dependency
149
- const { validatePlaylist } = await Promise.resolve().then(() => __importStar(require('./playlist-verifier')));
116
+ const { validatePlaylist } = await import('./playlist-verifier.js');
150
117
  const validateResult = await validatePlaylist(playlist);
151
118
  if (!validateResult.valid) {
152
119
  console.log(chalk_1.default.red('Playlist validation failed'));