@feralfile/cli 2.9.0 → 2.10.1

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.
@@ -141,7 +141,11 @@ async function fetchStoredPlaylist(feedServerUrl, identifier) {
141
141
  return stored;
142
142
  }
143
143
  /**
144
- * Resolve the key that signs an owner-bound intent, honouring an explicit `--key`.
144
+ * Resolve the key that signs an owner-bound intent, honouring an explicit key from the command line.
145
+ *
146
+ * `--key-file` material arrives here already read and trimmed by `signing-key-source`, which rejects an
147
+ * empty file there rather than here: the message below is about shell expansion, which is the right
148
+ * diagnosis for a value typed at the prompt and the wrong one for a file that turned out to be empty.
145
149
  *
146
150
  * **Presence, never truthiness.** `--key ""` — the shape an unset shell variable takes,
147
151
  * `--key "$SIGNING_KEY"` with nothing in it — is an override that FAILED, not an absent one. Treating it
@@ -168,7 +172,8 @@ function resolveMutationSigningKey(override, action) {
168
172
  const configured = (0, config_1.getPlaylistConfig)().privateKey;
169
173
  if (!configured) {
170
174
  throw new Error(`No playlist signing key is configured. A ${action} is authorized by a signature, so one is ` +
171
- 'required: run "ff-cli setup", set playlist.privateKey in config.json, or pass --key.');
175
+ 'required: run "ff-cli setup", set playlist.privateKey in config.json, or pass --key / ' +
176
+ '--key-file for this run.');
172
177
  }
173
178
  return configured;
174
179
  }
@@ -235,20 +240,26 @@ async function storedOwnership(stored) {
235
240
  }
236
241
  return { declared, proven };
237
242
  }
243
+ /** How a private key is named as the argument to the flag it arrived on. */
244
+ function flagArgument(keySource) {
245
+ return keySource === '--key-file' ? 'file holding the private key' : 'private key';
246
+ }
238
247
  /** How to describe the identity in a refusal, given where it came from. */
239
248
  function identityLabel(keySource) {
240
- return keySource === 'supplied'
241
- ? 'The identity you passed with --key:'
242
- : 'Your configured identity:';
249
+ return keySource === 'configured'
250
+ ? 'Your configured identity:'
251
+ : `The identity you passed with ${keySource}:`;
243
252
  }
244
253
  /** What to do about it, given where it came from. */
245
254
  function retryAdvice(keySource, plural) {
246
255
  const which = plural ? 'one of the keys listed above' : 'the key listed above';
247
- return keySource === 'supplied'
248
- ? ` Run it again with an owner key: --key <private key for ${which}>.\n` +
249
- ` (Confirm which identity a key carries with "ff-cli status --key <private key>".)`
250
- : ` Point playlist.privateKey at ${which}, or pass one for this run with --key (confirm any\n` +
251
- ` key's identity with "ff-cli status --key <private key>").`;
256
+ if (keySource === 'configured') {
257
+ return (` Point playlist.privateKey at ${which}, or pass one for this run with --key (confirm any\n` +
258
+ ` key's identity with "ff-cli status --key <private key>").`);
259
+ }
260
+ const argument = flagArgument(keySource);
261
+ return (` Run it again with an owner key: ${keySource} <${argument} for ${which}>.\n` +
262
+ ` (Confirm which identity a key carries with "ff-cli status ${keySource} <${argument}>".)`);
252
263
  }
253
264
  /**
254
265
  * Refuse locally when the signing key cannot authorize a mutation on the stored playlist.
@@ -319,7 +330,9 @@ async function ownershipPreflight(stored, signerDidKey, action, keySource = 'con
319
330
  ` over the document as published.`,
320
331
  };
321
332
  }
322
- const source = keySource === 'supplied' ? 'The key you passed with --key is' : 'The configured signing key is';
333
+ const source = keySource === 'configured'
334
+ ? 'The configured signing key is'
335
+ : `The key you passed with ${keySource} is`;
323
336
  return {
324
337
  error: `${source} not an owner of this playlist, so it cannot ${verb} it.`,
325
338
  message: `Only a key the stored playlist names in curators[] AND that signed it as "${exports.OWNER_ROLE}" can\n` +
@@ -381,8 +394,8 @@ function describeFeedMutationError(error, action, keySource = 'configured') {
381
394
  return {
382
395
  error: `${verb} refused: the feed saw no signatures on the request.`,
383
396
  message: `Every mutating request is authorized by the signatures in its body — there is no API key.\n` +
384
- (keySource === 'supplied'
385
- ? ` The key passed with --key produced no signature the feed could read, which should not\n` +
397
+ (keySource !== 'configured'
398
+ ? ` The key passed with ${keySource} produced no signature the feed could read, which should not\n` +
386
399
  ` happen once it has been accepted locally — please report this.\n`
387
400
  : ` This usually means no signing key is configured: run "ff-cli status" to check, and\n` +
388
401
  ` "ff-cli setup" to generate one.\n`) +
@@ -395,9 +408,9 @@ function describeFeedMutationError(error, action, keySource = 'configured') {
395
408
  // remaining causes are the feed disagreeing about the stored document, or a replace touching the
396
409
  // owner set. Say that the feed refused, and that the local check disagreed.
397
410
  if (status === 403) {
398
- const which = keySource === 'supplied'
399
- ? 'the key you passed with --key is'
400
- : 'the configured signing key is';
411
+ const which = keySource === 'configured'
412
+ ? 'the configured signing key is'
413
+ : `the key you passed with ${keySource} is`;
401
414
  return {
402
415
  error: `${verb} refused by the feed: it did not accept the signing key as an owner.`,
403
416
  message: `The local check disagreed — ${which} named in the stored playlist's curators[]\n` +
@@ -127,7 +127,7 @@ async function replacePlaylist(filePath, feedServerUrl, options = {}) {
127
127
  // Where the identity came from, resolved before the first request so every refusal — local or from
128
128
  // the feed — names the key the operator actually used rather than a config file this run may not
129
129
  // have read.
130
- const keySource = options.keySource ?? (options.privateKey !== undefined ? 'supplied' : 'configured');
130
+ const keySource = options.keySource ?? (options.privateKey !== undefined ? '--key' : 'configured');
131
131
  // Presence, not truthiness: `--key ""` is an override that failed to expand, and falling back to the
132
132
  // configured key would sign someone else's replacement into place.
133
133
  let privateKey;
@@ -56,7 +56,7 @@ async function unpublishPlaylist(idOrUrl, feedServerUrl, options = {}) {
56
56
  // Where the identity came from, resolved before the first request so every refusal — local or from
57
57
  // the feed — names the key the operator actually used rather than a config file this run may not
58
58
  // have read.
59
- const keySource = options.keySource ?? (options.privateKey !== undefined ? 'supplied' : 'configured');
59
+ const keySource = options.keySource ?? (options.privateKey !== undefined ? '--key' : 'configured');
60
60
  // Resolve and validate together: `--key ""` is a failed override, not an absent one, and must never
61
61
  // fall through to the configured key on an operation that tombstones an id.
62
62
  let privateKey;
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+ /**
3
+ * Where an explicit signing key came from: `--key` on the command line, or `--key-file` on disk.
4
+ *
5
+ * `--key` puts a private key in two places nobody chose to put it: the shell history file, and the
6
+ * process list, where any local user can read it for as long as the command runs. Neither is something
7
+ * the operator can take back afterwards, and an Ed25519 signing key that has been disclosed is not
8
+ * repairable — the identity it asserts is the identity, and a playlist's owner set is immutable. So
9
+ * every command that accepts `--key` also accepts `--key-file <path>`, which reads the same material
10
+ * from a file the operator controls.
11
+ *
12
+ * The two flags are mutually exclusive rather than ordered. A precedence rule would have to pick a
13
+ * winner when they disagree, and the loser is silently ignored — which is exactly the class of failure
14
+ * the empty-`--key` fix removed: a key the operator believes was used and was not.
15
+ *
16
+ * Reading the file is deliberately *not* gated on its permissions. A mode check would refuse work on
17
+ * key files that are perfectly appropriate — a file inside a per-user directory, a path on a tmpfs
18
+ * mounted for one user, a Windows ACL the mode bits cannot describe — and it would still not prove the
19
+ * file is private, because the directory above it decides who can reach the name at all. The
20
+ * documentation says to keep the file private; the CLI does not pretend to be able to check it.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.explicitSigningKeyFlag = explicitSigningKeyFlag;
24
+ exports.readSigningKeyFile = readSigningKeyFile;
25
+ exports.resolveExplicitSigningKey = resolveExplicitSigningKey;
26
+ const node_fs_1 = require("node:fs");
27
+ /**
28
+ * Which key flag the command was given, without reading anything.
29
+ *
30
+ * Split out from the resolution below so a command that is going to *refuse* an explicit key — a plain
31
+ * `publish` signs nothing at request time — can name the flag the operator actually typed without
32
+ * opening their key file to do it.
33
+ *
34
+ * Presence, never truthiness: `--key ""` and `--key-file ""` are both what an unset shell variable
35
+ * expands to, and both are overrides that failed rather than overrides that are absent.
36
+ *
37
+ * @param options - The command's parsed options
38
+ * @returns The flag used, or `undefined` when neither was given
39
+ * @throws Error when both flags are present
40
+ */
41
+ function explicitSigningKeyFlag(options) {
42
+ const hasKey = options.key !== undefined;
43
+ const hasKeyFile = options.keyFile !== undefined;
44
+ if (hasKey && hasKeyFile) {
45
+ throw new Error('--key and --key-file both name a signing key, and nothing here can tell which one you meant. ' +
46
+ 'Pass exactly one: --key for material on the command line, --key-file for a path to read it from.');
47
+ }
48
+ if (hasKeyFile) {
49
+ return '--key-file';
50
+ }
51
+ return hasKey ? '--key' : undefined;
52
+ }
53
+ /**
54
+ * Read key material out of a key file.
55
+ *
56
+ * The contents are trimmed, so a file written by `printf`, by an editor that appends a newline, or by a
57
+ * password manager's export all behave the same. What comes back then goes through the *same*
58
+ * normalization `--key` does — base64 PKCS#8 DER, a 32-byte seed as hex or base64, or PEM — because a
59
+ * key file is a delivery mechanism, not a second key format.
60
+ *
61
+ * An empty or whitespace-only file is rejected here rather than passed on. Every other outcome would be
62
+ * a silent fallback to the configured key: `sign` and the feed mutations would refuse an empty `--key`
63
+ * with a message about shell expansion that is wrong for a file, and `status` would quietly report the
64
+ * configured identity as though it were the file's. That fallback is the bug the empty-`--key` fix
65
+ * removed, and it is worse from a file, because a file that reads as empty (truncated, still being
66
+ * written, the wrong path in a directory of similar names) looks nothing like a typo at the prompt.
67
+ *
68
+ * Every argument is opened. Deciding in advance that something "looks like a key" and refusing to try
69
+ * cost real paths — `/run/BEGIN/secrets/owner.key`, a content-addressed 64-hex filename — and bought
70
+ * nothing, because opening a wrong path is free. What is not free is *printing* one: a private key
71
+ * that reaches a terminal is in scrollback and in whatever ships those logs onward, and it cannot be
72
+ * rotated out of a document that already names it. So the guarantee lives entirely in what may be said
73
+ * about a failure; see `safePathLabel`. Existence is also the only thing separating a key typed into
74
+ * the path from a file genuinely named after a hex digest, and that cannot be known without looking.
75
+ *
76
+ * @param path - Value of `--key-file` exactly as the command received it
77
+ * @returns Trimmed key material
78
+ * @throws Error when the path is empty, cannot be read, or holds no key material
79
+ */
80
+ function readSigningKeyFile(path) {
81
+ if (path.trim().length === 0) {
82
+ throw new Error('The --key-file path is empty. This usually means a shell variable did not expand (for example ' +
83
+ '--key-file "$KEY_PATH" with KEY_PATH unset). Refusing rather than falling back to the ' +
84
+ 'configured key, which would sign under an identity you did not choose.');
85
+ }
86
+ // The path is used exactly as given, NOT trimmed. Trimming was only ever a convenience, and it is
87
+ // the wrong one here: it silently opens `owner.key` for `--key-file "owner.key "`, so a file that
88
+ // is not the one named decides what signs. The trim above answers a different question — whether
89
+ // anything was passed at all.
90
+ let contents;
91
+ try {
92
+ contents = (0, node_fs_1.readFileSync)(path, 'utf-8');
93
+ }
94
+ catch (error) {
95
+ throw new Error(describeKeyFileReadFailure(path, error));
96
+ }
97
+ const material = contents.trim();
98
+ if (material.length === 0) {
99
+ throw new Error(`The key file at ${safePathLabel(path)} holds no key material. Refusing rather than falling ` +
100
+ 'back to the configured key: a signature made under an identity you did not choose is not ' +
101
+ 'something a publish or a delete can be taken back from.');
102
+ }
103
+ return material;
104
+ }
105
+ /**
106
+ * Whether a value is, on its own, a complete Ed25519 key encoding.
107
+ *
108
+ * Complete is the whole point. An earlier version treated any `BEGIN` substring and any key-shaped
109
+ * last segment as reason enough to refuse before opening, which made `/run/BEGIN/secrets/owner.key`
110
+ * and a content-addressed 64-hex filename unusable — real paths, refused for looking like something
111
+ * they were not, with no way to say otherwise. The shapes below are the forms a whole key takes and
112
+ * nothing else: a PEM block carries both its header AND the line breaks a real block has, a hex seed
113
+ * is 64 characters of nothing but hex, and base64 is judged by what it decodes to.
114
+ */
115
+ function isWholeKeyEncoding(value) {
116
+ if (value.includes('BEGIN') && /[\r\n]/.test(value)) {
117
+ return true;
118
+ }
119
+ if (/^(0x)?[0-9a-fA-F]{64,}$/.test(value)) {
120
+ return true;
121
+ }
122
+ // Decided by DECODED LENGTH rather than by charset alone: `/` and `+` are legal base64 and most
123
+ // real PKCS#8 keys contain a `/`, so a charset test that read that as a directory separator would
124
+ // let the common case through. 32 bytes is a raw Ed25519 seed; 48 is PKCS#8 for one.
125
+ if (/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
126
+ const decodedLength = Buffer.from(value, 'base64').length;
127
+ if (decodedLength === 32 || decodedLength === 48) {
128
+ return true;
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+ /** Whether the argument is a bare key: a complete encoding with no path structure around it. */
134
+ function isBareKeyMaterial(value) {
135
+ return !/[/\\]/.test(value) && isWholeKeyEncoding(value);
136
+ }
137
+ /** Whether any path segment is a complete key encoding — a key with a directory prefix on it. */
138
+ function anySegmentIsKeyEncoding(value) {
139
+ return value.split(/[/\\]/).some((segment) => isWholeKeyEncoding(segment));
140
+ }
141
+ /**
142
+ * How to refer to a `--key-file` argument in a message, without ever printing key material.
143
+ *
144
+ * This is the guarantee, and it is deliberately separate from the decision to open. Opening a wrong
145
+ * path costs nothing; printing one can disclose a private key that cannot be rotated out of a document
146
+ * that already names it. So everything is opened, and this decides what may be said afterwards.
147
+ *
148
+ * Length carries most of it: no complete key encoding is under 40 characters — a 32-byte seed is 64
149
+ * hex or 44 base64 characters — so a short argument cannot be a whole key, and a partial one is not a
150
+ * key. The segment test then covers a key wearing a directory prefix, which length alone would pass
151
+ * only for something already too long to print.
152
+ *
153
+ * @param path - Value of `--key-file` exactly as the command received it
154
+ * @returns The path itself when it is provably safe to print, or a description of it
155
+ */
156
+ function safePathLabel(path) {
157
+ const value = path.trim();
158
+ if (value.length < 40 && !anySegmentIsKeyEncoding(value)) {
159
+ return path;
160
+ }
161
+ return 'the path given (not repeated here, in case it is key material)';
162
+ }
163
+ /**
164
+ * Turn a read failure into a sentence that says what to do about it.
165
+ *
166
+ * The raw errno text ("EISDIR: illegal operation on a directory, read") reads like a bug in the CLI
167
+ * rather than a wrong path, and it is the same three mistakes every time: the file is not there, it is
168
+ * not readable, or the path names a directory.
169
+ *
170
+ * The underlying `Error.message` is never interpolated — only its `code`. Node builds that message by
171
+ * appending the path it was given, so passing it through would echo the argument a second time, in a
172
+ * form no caller here controls.
173
+ */
174
+ function describeKeyFileReadFailure(path, error) {
175
+ const code = error?.code;
176
+ const value = path.trim();
177
+ // A bare, complete key with nothing of a path about it, and no file of that name. `--key-file
178
+ // "$SIGNING_KEY"` is one keystroke from `--key "$SIGNING_KEY"`, and it is the natural typo for
179
+ // someone moving off `--key`, so say what the mistake was. Existence is what separates this from a
180
+ // file someone genuinely named after a hex digest — which is why the open is attempted first rather
181
+ // than guessed at.
182
+ if (isBareKeyMaterial(value)) {
183
+ return ('The --key-file argument looks like a key rather than a path, and no file of that name exists. ' +
184
+ 'It is not repeated here. Point --key-file at the file that holds the key, or pass the key ' +
185
+ 'itself with --key.');
186
+ }
187
+ const label = safePathLabel(path);
188
+ // When the argument cannot be printed, the errno is the whole message. Naming which failure it was
189
+ // is still worth saying — "there is nothing there" and "you may not read it" send an operator to
190
+ // different places — but nothing about the value itself may appear.
191
+ if (label !== path) {
192
+ return (`The --key-file argument could not be read (${code ?? 'read failed'}) and is not repeated ` +
193
+ 'here: it may be key material rather than a path. Check the path you passed.');
194
+ }
195
+ if (code === 'ENOENT') {
196
+ return `No key file at ${label}.`;
197
+ }
198
+ if (code === 'EACCES' || code === 'EPERM') {
199
+ return `Cannot read the key file at ${label}: permission denied (${code}).`;
200
+ }
201
+ if (code === 'EISDIR') {
202
+ return `${label} is a directory, not a key file.`;
203
+ }
204
+ return `Cannot read the key file at ${label} (${code ?? 'read failed'}).`;
205
+ }
206
+ /**
207
+ * Resolve the explicit signing key a command was given, from either flag.
208
+ *
209
+ * Commands call this before anything else — before a feed is chosen, before a playlist is looked up,
210
+ * before a destructive action is confirmed — so an unreadable file fails while the only thing it can
211
+ * cost is the run itself.
212
+ *
213
+ * `--key` material is returned exactly as typed, including when it is empty: the commands already
214
+ * refuse an empty `--key` with a message about shell expansion, and that message is the right one for
215
+ * a value that came from the command line.
216
+ *
217
+ * @param options - The command's parsed options
218
+ * @returns The explicit key and its flag, or `undefined` when neither flag was given
219
+ * @throws Error when both flags are present, or when a key file cannot be read or is empty
220
+ */
221
+ function resolveExplicitSigningKey(options) {
222
+ const flag = explicitSigningKeyFlag(options);
223
+ if (flag === undefined) {
224
+ return undefined;
225
+ }
226
+ if (flag === '--key-file') {
227
+ return { material: readSigningKeyFile(options.keyFile), flag };
228
+ }
229
+ return { material: options.key, flag };
230
+ }
@@ -39,7 +39,7 @@ Optional settings used where headless/browser‑like behavior is needed.
39
39
 
40
40
  Used for signing DP‑1 playlists.
41
41
 
42
- - `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures, and as the default signing key for the owner-bound feed mutations (`publish --replace`, `unpublish`), which sign an authorization intent at request time. Those commands and `sign` all take `-k, --key` to override it for a single run. The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `play` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `play` and `publish` verify before delivery or upload and reject unsigned or broken playlists.
42
+ - `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures, and as the default signing key for the owner-bound feed mutations (`publish --replace`, `unpublish`), which sign an authorization intent at request time. Those commands and `sign` all take `-k, --key` or `--key-file <path>` to override it for a single run — see [Where the signing key can come from](#where-the-signing-key-can-come-from). The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `play` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `play` and `publish` verify before delivery or upload and reject unsigned or broken playlists.
43
43
 
44
44
  **Signing and key encoding:** Signing paths (`sign`, deterministic `build` when configured, and `-k/--key` overrides) accept the private key in any of these encodings:
45
45
 
@@ -61,10 +61,64 @@ Used for signing DP‑1 playlists.
61
61
 
62
62
  To read the `did:key` your signatures will carry, run `ff-cli status`. It reports the identity from
63
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.
64
+ (or `--key-file <path>`) reports it for an explicit key — the one `sign` would use with the same flag.
65
65
 
66
66
  - `playlist.role` (string): DP-1 signing role used by `ff-cli sign`. The shipped `config.json.example` sets `curator`, because a document signed as `agent` is ownerless to a role-aware feed and `ff-cli publish` refuses it. The code default when the key is omitted entirely is still `agent`. You can also set this via `PLAYLIST_ROLE` in `.env`. Guided `ff-cli setup`, `config validate`, and `sign --role` only accept the usual DP-1 signing roles (`agent`, `feed`, `curator`, `institution`, `licensor`). It does **not** apply to `find` and `build`, which always sign as `curator` — see [Signing role and ownership](#signing-role-and-ownership).
67
67
 
68
+ ### Where the signing key can come from
69
+
70
+ Three sources, in this order of precedence:
71
+
72
+ 1. **`-k, --key <privateKey>` or `--key-file <path>`** on the command line — `sign`, `status`,
73
+ `unpublish`, and `publish --replace` all take both.
74
+ 2. **`playlist.privateKey`** in `config.json`.
75
+ 3. **`PLAYLIST_PRIVATE_KEY`** in the environment or in `.env`.
76
+
77
+ The file beats the environment, matching the repo-wide `config.json` > `.env` > defaults rule — with
78
+ one exception: a `playlist.privateKey` that is empty or still holds a `YOUR_`/`your_` sample
79
+ placeholder is treated as **unset**, so `PLAYLIST_PRIVATE_KEY` is used instead. Without that, an
80
+ untouched `config init` would beat a perfectly good environment key and every signing path would fail
81
+ on a key you never chose.
82
+
83
+ An explicit key wins for that one run only; nothing is written back. All three accept the same
84
+ encodings listed above, and the CLI normalizes whichever you supply before signing, so a key file is a
85
+ delivery mechanism rather than a fourth key format.
86
+
87
+ **Prefer `--key-file` to `--key`.** `--key` writes your private key into your shell history file and
88
+ exposes it in the process list, where any local user can read it for as long as the command runs.
89
+ Neither disclosure can be undone: an Ed25519 signing key *is* the identity it asserts, and a playlist's
90
+ `curators[]` owner set is immutable, so a leaked key cannot be rotated out of a document that already
91
+ names it. `--key-file` keeps the material in a file you control:
92
+
93
+ ```bash
94
+ ff-cli status --key-file ~/.config/ff-cli/signing.key # whose key is this?
95
+ ff-cli sign playlist.json -r curator --key-file ~/.config/ff-cli/signing.key
96
+ ```
97
+
98
+ **Keep the key file private** — `chmod 600` it, in a directory only you can enter. The CLI does not
99
+ check its permissions and will not refuse a world-readable one: a mode check cannot see the directory
100
+ above the file, cannot describe a Windows ACL, and would refuse paths that are perfectly appropriate,
101
+ so it would be ceremony rather than protection.
102
+
103
+ The rules on both flags:
104
+
105
+ - **Passing both is an error**, not a precedence rule. A precedence rule has to silently ignore one of
106
+ the two keys you supplied, and a key you believe was used and was not is the failure this CLI keeps
107
+ removing.
108
+ - **An empty `--key`** — what `--key "$SIGNING_KEY"` becomes when the variable is unset — is rejected,
109
+ never treated as absent.
110
+ - **A `--key-file` that is missing, unreadable, or holds no key material** is rejected the same way.
111
+ Neither ever falls back to the configured key: signing under an identity you did not choose is not
112
+ something a publish or a delete can be taken back from.
113
+ - **A `--key-file` argument is always opened, whatever it looks like.** A directory really can be
114
+ called `BEGIN`, and a key file really can be named after a content hash. What is gated is not the
115
+ open but the *message*: a failure names the path only when it is short and no part of it is a
116
+ complete key encoding, and otherwise says it could not be read without repeating it. Typing the key
117
+ where the path goes — one keystroke from `--key` — is reported as that mistake, by name, with the
118
+ value withheld.
119
+ - **The key is never echoed**, in any error, in any encoding — nor is a `--key-file` argument that may
120
+ be one.
121
+
68
122
  ### Generate an Ed25519 private key
69
123
 
70
124
  The simplest path is `ff-cli setup`, which generates a key for you (base64 PKCS#8 DER) and writes it to your config. To generate one yourself:
@@ -143,8 +197,8 @@ signatures in the request body, and an `apiKey` left in an existing config is ig
143
197
 
144
198
  `playlist.privateKey` is needed only where the CLI itself has to sign at request time — `publish
145
199
  --replace` and `unpublish`, which sign the owner-bound intent that authorizes the write. Both accept
146
- `-k, --key` to use a different key for one run, so a second owner identity does not require editing this
147
- file. A plain
200
+ `-k, --key` or `--key-file <path>` to use a different key for one run, so a second owner identity does
201
+ not require editing this file. A plain
148
202
  `publish` signs nothing: it uploads the `signatures[]` envelope the document already carries, so it
149
203
  works with no key configured at all (the document must have been signed at some point, by
150
204
  `ff-cli sign` or by whoever produced it, but not by this command).
package/docs/EXAMPLES.md CHANGED
@@ -64,6 +64,9 @@ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --publish
64
64
 
65
65
  # Build, play on a named device, and skip interactive prompts
66
66
  npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play -d "Living Room Display" -y
67
+
68
+ # -y alone also plays (the scripted default); add --no-play to build and save without casting
69
+ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 -y --no-play
67
70
  ```
68
71
 
69
72
  OpenSea and Verse accept both item and collection/series URLs:
@@ -789,9 +792,9 @@ a new playlist instead of trying to recreate it.
789
792
  ### Signing with a key other than the configured one
790
793
 
791
794
  The configured `playlist.privateKey` is the default, not the only option. `unpublish` and
792
- `publish --replace` take `-k, --key`, like `ff-cli sign` and `ff-cli status`, and it overrides that
793
- default both for the intent signature and for the local ownership check — so holding a second owner key
794
- no longer means editing `config.json`:
795
+ `publish --replace` take `-k, --key` and `--key-file <path>`, like `ff-cli sign` and `ff-cli status`,
796
+ and either one overrides that default both for the intent signature and for the local ownership check —
797
+ so holding a second owner key no longer means editing `config.json`:
795
798
 
796
799
  ```bash
797
800
  ff-cli unpublish <id> -s 0 --key <private key for a stored owner>
@@ -804,8 +807,36 @@ confirmation and under `-y`, so the identity that performs an irreversible delet
804
807
  record. It resolves that key once, before the lookup and before the prompt, and signs with the same
805
808
  material — editing `config.json` while a confirmation is open cannot change who the delete runs as.
806
809
 
807
- A refusal names the key you actually used. If it came from `--key`, the retry says `--key`; it will not
808
- send you to `playlist.privateKey` for a run that never read it.
810
+ #### Keeping the key off the command line
811
+
812
+ `--key` puts the private key in your shell history file and in the process list, where any local user
813
+ can read it while the command runs. `--key-file <path>` reads the same material from a file instead, on
814
+ all four commands, with the same key encodings:
815
+
816
+ ```bash
817
+ ff-cli status --key-file ~/.config/ff-cli/owner.key # whose key is this?
818
+ ff-cli sign playlist.json -r curator --key-file ~/.config/ff-cli/owner.key
819
+ ff-cli unpublish <id> -s 0 --key-file ~/.config/ff-cli/owner.key
820
+ ff-cli publish playlist.json --replace -s 0 --key-file ~/.config/ff-cli/owner.key
821
+ ```
822
+
823
+ Keep the file private (`chmod 600`, in a directory only you can enter) — the CLI does not check its
824
+ permissions. Passing `--key` and `--key-file` together is an error rather than a precedence rule, and a
825
+ key file that is missing, unreadable, or empty is refused rather than quietly falling back to the
826
+ configured key:
827
+
828
+ ```
829
+ $ ff-cli unpublish <id> -y --key-file ./owner.key # file was truncated
830
+
831
+ Cannot sign the delete
832
+ The key file at ./owner.key holds no key material. Refusing rather than falling back to
833
+ the configured key: a signature made under an identity you did not choose is not something
834
+ a publish or a delete can be taken back from.
835
+ ```
836
+
837
+ A refusal names the key you actually used. If it came from `--key`, the retry says `--key`; if it came
838
+ from `--key-file`, the retry says `--key-file`; it will not send you to `playlist.privateKey` for a run
839
+ that never read it.
809
840
 
810
841
  An empty `--key` is **rejected**, not ignored:
811
842
 
@@ -819,8 +850,9 @@ Cannot sign the delete
819
850
  delete can be taken back from.
820
851
  ```
821
852
 
822
- A plain `publish` **refuses** `--key` rather than ignoring it: it signs nothing at request time, so the
823
- flag would do nothing. `ff-cli fetch` has no `--key` for the same reason — it is a read.
853
+ A plain `publish` **refuses** `--key` and `--key-file` rather than ignoring them: it signs nothing at
854
+ request time, so the flag would do nothing. `ff-cli fetch` has neither for the same reason — it is a
855
+ read.
824
856
 
825
857
  ### When ownership cannot be proved
826
858
 
@@ -192,7 +192,9 @@ created, signatures }`, with a `payloadHash` for replace — whose `created` mus
192
192
  freshness window; the intent exists because a document's own signatures are public via `GET` and could
193
193
  otherwise be replayed to roll a resource back. The CLI proves ownership before it signs anything: it
194
194
  `GET`s the stored playlist and requires the signing key — the configured `playlist.privateKey` unless
195
- `-k, --key` overrides it for that command — to be both named in the stored `curators[]` and the signer of
195
+ `-k, --key` or `--key-file <path>` overrides it for that command, the latter keeping the key out of the
196
+ shell history and the process list (`CONFIGURATION.md`, "Where the signing key can come from") — to be
197
+ both named in the stored `curators[]` and the signer of
196
198
  a cryptographically valid `curator`-role signature over that stored document. Being
197
199
  named is a claim; the owner-role signature is the proof, and checking only the claim is what would let a
198
200
  legacy `agent`-signed document pass preflight and come back as a `403` reported as a missing
@@ -392,10 +394,17 @@ and nothing else. `enrich` is the repair path for those.
392
394
  a curator-restricted playlist is never briefly world-readable mid-write. The
393
395
  destination's owner and group are carried across too, since a replacement is
394
396
  a new inode and would otherwise take this process's ownership — silently
395
- reassigning a shared playlist. Where that cannot be done, the in-place
396
- replacement is refused and `--output` is offered instead. Extended ACLs are
397
- not preserved, because Node exposes no portable way to read them; a playlist
398
- carrying them should be enriched through `--output`.
397
+ reassigning a shared playlist. Two things clear set-user-ID and set-group-ID
398
+ — the chown, and an unprivileged write to a regular file — so the mode is
399
+ restored and verified against the inode after each of them, the second time
400
+ with nothing left before the rename that could change it. Where the ownership
401
+ or the mode cannot be reproduced, the in-place replacement is refused and
402
+ `--output` is offered instead. Access-control
403
+ lists are not preserved: a replacement is a new inode, so its ACL comes from
404
+ the directory's default rather than from the file being replaced, and mode
405
+ bits cannot carry named entries across on any platform — a playlist whose
406
+ access depends on an ACL should be enriched through `--output` to a fresh
407
+ name in a directory whose access is what the result should have.
399
408
  - `--output` names a file the caller expects to exist afterwards, so it is
400
409
  written even when nothing was enriched. A no-op without `--output` writes
401
410
  nothing rather than rewriting the input for no gain.