@feralfile/cli 2.0.2 → 2.1.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.
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HANDOFF_ALGORITHM = void 0;
4
+ exports.canonicalJson = canonicalJson;
5
+ exports.handoffVerificationCode = handoffVerificationCode;
6
+ exports.generateHandoffKeyPair = generateHandoffKeyPair;
7
+ exports.exportHandoffPublicJwk = exportHandoffPublicJwk;
8
+ exports.encryptHandoffMessage = encryptHandoffMessage;
9
+ exports.decryptHandoffMessage = decryptHandoffMessage;
10
+ exports.randomHandoffMessageId = randomHandoffMessageId;
11
+ const node_crypto_1 = require("node:crypto");
12
+ /** Wire algorithm required by the handoff broker protocol. */
13
+ exports.HANDOFF_ALGORITHM = 'P256-HKDF-SHA256-AES-256-GCM';
14
+ /** canonicalJson serializes JSON with recursively sorted object keys. */
15
+ function canonicalJson(value) {
16
+ if (value === null || typeof value !== 'object') {
17
+ return JSON.stringify(value);
18
+ }
19
+ if (Array.isArray(value)) {
20
+ return `[${value.map((item) => canonicalJson(item)).join(',')}]`;
21
+ }
22
+ const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
23
+ return `{${entries
24
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
25
+ .join(',')}}`;
26
+ }
27
+ /** Create the out-of-band key commitment shown by both CLI and mobile. */
28
+ function handoffVerificationCode(input) {
29
+ const commitment = (0, node_crypto_1.createHash)('sha256')
30
+ .update(canonicalJson({
31
+ v: 1,
32
+ algorithm: exports.HANDOFF_ALGORITHM,
33
+ channelId: input.channelId,
34
+ minterPublicKeyJwk: input.minterPublicKeyJwk,
35
+ }))
36
+ .digest('hex')
37
+ .slice(0, 12)
38
+ .toUpperCase();
39
+ return `${commitment.slice(0, 4)}-${commitment.slice(4, 8)}-${commitment.slice(8, 12)}`;
40
+ }
41
+ /** Generate an ephemeral P-256 ECDH key pair for one broker channel. */
42
+ async function generateHandoffKeyPair() {
43
+ return (await node_crypto_1.webcrypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, [
44
+ 'deriveBits',
45
+ ]));
46
+ }
47
+ /** Export the public half of an ephemeral handoff key as JWK. */
48
+ async function exportHandoffPublicJwk(publicKey) {
49
+ return node_crypto_1.webcrypto.subtle.exportKey('jwk', publicKey);
50
+ }
51
+ function base64UrlEncode(bytes) {
52
+ return Buffer.from(bytes).toString('base64url');
53
+ }
54
+ function base64UrlDecode(value) {
55
+ return new Uint8Array(Buffer.from(value, 'base64url'));
56
+ }
57
+ function handoffAAD(input) {
58
+ return {
59
+ v: 1,
60
+ channelId: input.channelId,
61
+ messageId: input.messageId,
62
+ seq: input.seq,
63
+ sender: input.sender,
64
+ recipient: input.recipient,
65
+ algorithm: exports.HANDOFF_ALGORITHM,
66
+ };
67
+ }
68
+ async function deriveAesKey(input) {
69
+ const peer = await node_crypto_1.webcrypto.subtle.importKey('jwk', input.peerPublicKeyJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
70
+ const sharedBits = await node_crypto_1.webcrypto.subtle.deriveBits({ name: 'ECDH', public: peer }, input.privateKey, 256);
71
+ const hkdfKey = await node_crypto_1.webcrypto.subtle.importKey('raw', sharedBits, 'HKDF', false, ['deriveKey']);
72
+ const saltInput = canonicalJson({
73
+ algorithm: exports.HANDOFF_ALGORITHM,
74
+ channelId: input.channelId,
75
+ v: 1,
76
+ });
77
+ const salt = await node_crypto_1.webcrypto.subtle.digest('SHA-256', Buffer.from(saltInput));
78
+ return node_crypto_1.webcrypto.subtle.deriveKey({
79
+ name: 'HKDF',
80
+ hash: 'SHA-256',
81
+ salt,
82
+ info: Buffer.from('ff-mint-pairing/v1/aes-gcm'),
83
+ }, hkdfKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
84
+ }
85
+ /** Encrypt a JSON payload into the broker's authenticated envelope format. */
86
+ async function encryptHandoffMessage(input) {
87
+ const aad = handoffAAD({ ...input, seq: 0 });
88
+ const aadBytes = Buffer.from(canonicalJson(aad));
89
+ const nonce = (0, node_crypto_1.randomBytes)(12);
90
+ const key = await deriveAesKey(input);
91
+ const ciphertext = await node_crypto_1.webcrypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData: aadBytes, tagLength: 128 }, key, Buffer.from(canonicalJson(input.plaintext)));
92
+ return {
93
+ messageId: input.messageId,
94
+ sender: input.sender,
95
+ recipient: input.recipient,
96
+ algorithm: exports.HANDOFF_ALGORITHM,
97
+ aad: base64UrlEncode(aadBytes),
98
+ nonce: base64UrlEncode(nonce),
99
+ ciphertext: base64UrlEncode(new Uint8Array(ciphertext)),
100
+ ...(input.senderPublicKeyJwk ? { senderPublicKeyJwk: input.senderPublicKeyJwk } : {}),
101
+ };
102
+ }
103
+ /** Decrypt and validate one channel-bound broker message. */
104
+ async function decryptHandoffMessage(input) {
105
+ const { message } = input;
106
+ if (message.algorithm !== exports.HANDOFF_ALGORITHM) {
107
+ throw new Error('encrypted message algorithm mismatch');
108
+ }
109
+ const aadBytes = base64UrlDecode(message.aad);
110
+ const aad = JSON.parse(Buffer.from(aadBytes).toString('utf8'));
111
+ const valid = aad.v === 1 &&
112
+ aad.channelId === input.channelId &&
113
+ aad.messageId === message.messageId &&
114
+ aad.sender === message.sender &&
115
+ aad.recipient === message.recipient &&
116
+ aad.algorithm === exports.HANDOFF_ALGORITHM &&
117
+ (aad.seq === 0 || aad.seq === input.seq);
118
+ if (!valid) {
119
+ throw new Error('encrypted message channel binding mismatch');
120
+ }
121
+ const nonce = base64UrlDecode(message.nonce);
122
+ if (nonce.length !== 12) {
123
+ throw new Error('AES-GCM nonce must be 12 bytes');
124
+ }
125
+ const key = await deriveAesKey(input);
126
+ const plaintext = await node_crypto_1.webcrypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, additionalData: aadBytes, tagLength: 128 }, key, base64UrlDecode(message.ciphertext));
127
+ return JSON.parse(Buffer.from(plaintext).toString('utf8'));
128
+ }
129
+ /** Create a collision-resistant broker message identifier. */
130
+ function randomHandoffMessageId() {
131
+ return `msg_${(0, node_crypto_1.randomBytes)(16).toString('base64url')}`;
132
+ }
@@ -487,7 +487,7 @@ async function buildPlaylistDirect(params, options = {}) {
487
487
  console.log(chalk.cyan('Shuffling items...'));
488
488
  finalItems = shuffleArray([...allItems]);
489
489
  }
490
- console.log(chalk.cyan(`Creating playlist with ${finalItems.length} items...`));
490
+ console.log(chalk.cyan(`Creating playlist with ${finalItems.length} ${finalItems.length === 1 ? 'item' : 'items'}...`));
491
491
  // Build DP1 playlist
492
492
  const playlist = await buildDP1Playlist(finalItems, playlistSettings.title, playlistSettings.slug);
493
493
  // Save playlist to file
@@ -48,16 +48,18 @@ function isInteractiveWeb(mimeType, sourceUrl) {
48
48
  * - a time-based source (video/audio) gets NO duration and
49
49
  * `display.loop: false`, so a conformant player MUST advance at
50
50
  * end-of-stream — the media plays its natural length;
51
- * - an interactive web page (HTML) gets NO duration either, but for the
52
- * opposite reason: it has no end-of-stream event, so a conformant player
53
- * parks on it indefinitely (the generative/interactive art keeps running);
51
+ * - an interactive web page (HTML) — generative art with no intrinsic
52
+ * runtime and no end-of-stream event — gets the configured
53
+ * `generativeDuration` (default 60s) so the playlist rotates. Set
54
+ * `generativeDuration` to 0 to omit it and have a conformant player park
55
+ * on the work open-ended instead;
54
56
  * - static and code-based sources fall back to the configured
55
57
  * `defaultDuration` because they have no intrinsic runtime to play out.
56
58
  *
57
- * Do not re-introduce an unconditional duration here: stamping a default on
58
- * video items silently truncates or pads them, and stamping one on an HTML
59
- * page cuts the artwork off and restarts it every `defaultDuration` seconds
60
- * (the bugs this guards against).
59
+ * Do not stamp a duration on video/audio: it silently truncates or pads them.
60
+ * (Earlier this also omitted duration for HTML works, which left them with no
61
+ * duration field. Players that require the field — notably FF1 — then rejected
62
+ * the entire playlist, so generative works now carry an explicit duration.)
61
63
  *
62
64
  * @param {Object} item - DP1 playlist item (mutated in place)
63
65
  * @param {Object} mediaHints - Media signals for the time-based check
@@ -76,8 +78,13 @@ function applyItemTiming(item, mediaHints = {}, duration) {
76
78
  return item;
77
79
  }
78
80
  if (isInteractiveWeb(mediaHints.mimeType, mediaHints.sourceUrl)) {
79
- // No duration: an open-ended interactive page should play until the
80
- // playlist advances for another reason, not on a fixed timer.
81
+ // Generative/interactive works have no intrinsic runtime, so stamp the
82
+ // configured generative duration to drive playlist rotation. A configured
83
+ // value of 0 means "omit" — a conformant player then parks open-ended.
84
+ const generativeDuration = getGenerativeDuration();
85
+ if (generativeDuration > 0) {
86
+ item.duration = generativeDuration;
87
+ }
81
88
  return item;
82
89
  }
83
90
  item.duration = getDefaultStaticDuration();
@@ -98,6 +105,23 @@ function getDefaultStaticDuration() {
98
105
  return 10;
99
106
  }
100
107
  }
108
+ /**
109
+ * getGenerativeDuration returns the configured display seconds for
110
+ * generative/interactive (HTML) works, which have no intrinsic runtime.
111
+ * Defaults to 60 when config is unavailable. A configured 0 is honored (the
112
+ * caller omits the duration so the work plays open-ended).
113
+ *
114
+ * @returns {number} Display duration in seconds (0 means "omit / open-ended")
115
+ */
116
+ function getGenerativeDuration() {
117
+ try {
118
+ const configured = getConfig().generativeDuration;
119
+ return typeof configured === 'number' ? configured : 60;
120
+ }
121
+ catch (_error) {
122
+ return 60;
123
+ }
124
+ }
101
125
  /**
102
126
  * Convert a string to a URL-friendly slug
103
127
  *
@@ -293,7 +317,7 @@ function convertTokensToDP1Items(tokensInfo, duration) {
293
317
  */
294
318
  function generatePlaylistTitle(items) {
295
319
  if (!items || items.length === 0) {
296
- return 'DP1 Playlist';
320
+ return 'DP-1 Playlist';
297
321
  }
298
322
  // Check if all items have provenance (likely NFT playlist)
299
323
  const hasProvenance = items.some((item) => item.provenance?.type === 'onChain');
@@ -313,7 +337,7 @@ function generatePlaylistTitle(items) {
313
337
  }
314
338
  }
315
339
  // Fallback: use item count
316
- return `DP1 Playlist (${items.length} ${items.length === 1 ? 'item' : 'items'})`;
340
+ return `DP-1 Playlist (${items.length} ${items.length === 1 ? 'item' : 'items'})`;
317
341
  }
318
342
  /**
319
343
  * Build complete DP1 v1.0.0 compliant playlist
@@ -86,11 +86,7 @@ async function sendSshAccessCommand({ enabled, deviceName, publicKey, ttlSeconds
86
86
  details: compatibility.version ? `Detected version ${compatibility.version}` : undefined,
87
87
  };
88
88
  }
89
- let apiUrl = `${device.host}/api/cast`;
90
- if (device.topicID && device.topicID.trim() !== '') {
91
- apiUrl += `?topicID=${encodeURIComponent(device.topicID)}`;
92
- logger.debug(`Using topicID: ${device.topicID}`);
93
- }
89
+ const apiUrl = `${device.host}/api/cast`;
94
90
  const request = {
95
91
  enabled,
96
92
  };
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.topicStoreAccount = topicStoreAccount;
4
+ exports.storeTopicId = storeTopicId;
5
+ exports.getTopicId = getTopicId;
6
+ exports.deleteTopicId = deleteTopicId;
7
+ const keyring_1 = require("@napi-rs/keyring");
8
+ const TOPIC_STORE_SERVICE = 'com.feralfile.ff-cli.topic';
9
+ const defaultEntryFactory = (service, account) => new keyring_1.Entry(service, account);
10
+ /** Normalize a physical FF1 identifier into its one keyring account name. */
11
+ function topicStoreAccount(deviceId) {
12
+ const normalized = deviceId.trim().toUpperCase();
13
+ if (!normalized) {
14
+ throw new Error('FF1 device ID is required for secure topic storage');
15
+ }
16
+ return normalized;
17
+ }
18
+ /** Store a device topic in the operating system credential vault. */
19
+ function storeTopicId(deviceId, topicId, entryFactory = defaultEntryFactory) {
20
+ const normalizedTopic = topicId.trim();
21
+ if (!normalizedTopic) {
22
+ throw new Error('FF1 topic ID is required for secure topic storage');
23
+ }
24
+ entryFactory(TOPIC_STORE_SERVICE, topicStoreAccount(deviceId)).setPassword(normalizedTopic);
25
+ }
26
+ /** Read a device topic from the operating system credential vault. */
27
+ function getTopicId(deviceId, entryFactory = defaultEntryFactory) {
28
+ return entryFactory(TOPIC_STORE_SERVICE, topicStoreAccount(deviceId)).getPassword();
29
+ }
30
+ /** Delete a device topic from the operating system credential vault. */
31
+ function deleteTopicId(deviceId, entryFactory = defaultEntryFactory) {
32
+ return entryFactory(TOPIC_STORE_SERVICE, topicStoreAccount(deviceId)).deletePassword();
33
+ }
@@ -22,8 +22,11 @@ npm run dev -- config show
22
22
  ## Top‑level fields
23
23
 
24
24
  - **defaultDuration** (number, seconds)
25
- - Default per‑item display duration for media without an intrinsic runtime (images, code-based works). Falls back to 10s when unset.
25
+ - Default per‑item display duration for static media without an intrinsic runtime (images). Falls back to 10s when unset.
26
26
  - Does NOT apply to video/audio items: with no explicit duration those are emitted without `duration` and with `display.loop: false`, so a DP-1 player advances at end-of-stream (§4.1) — the media plays its natural length.
27
+ - **generativeDuration** (number, seconds)
28
+ - Display duration stamped on generative/interactive (HTML) works, which have no intrinsic runtime and no end-of-stream event. Defaults to **60s** (override via `DEFAULT_GENERATIVE_DURATION`).
29
+ - Set to **0** to omit the duration entirely, so a conformant player parks on the work open-ended instead of rotating. Note: some players (including FF1) require every item to carry a `duration` and will reject a playlist whose items omit it — keep this non-zero unless you know your player tolerates an absent duration.
27
30
 
28
31
  ## browser
29
32
 
@@ -117,6 +120,9 @@ Configure devices you want to play content on.
117
120
 
118
121
  - `ff1Devices.devices` (array of objects):
119
122
  - `name` (string): Friendly device label. Free‑form; pick anything memorable.
123
+ - `id` (string): Stable physical ID discovered from FF1 (for example
124
+ `FF1-SKYZ2E3A`). Relayer pairing stores the topic under this ID in the OS
125
+ credential vault.
120
126
  - `host` (string): Device base URL. For LAN devices, use `http://<ip>:1111`. The device typically listens on port `1111`.
121
127
 
122
128
  During `ff-cli setup`, the CLI will attempt local discovery via mDNS (`_ff1._tcp`). If devices are found, you can pick one and the host will be filled in automatically. If discovery returns nothing, setup falls back to manual entry.
@@ -124,15 +130,20 @@ During `ff-cli setup`, the CLI will attempt local discovery via mDNS (`_ff1._tcp
124
130
  > **mDNS is best-effort.** It frequently does not cross subnets — a common case with mesh routers (e.g. eero) that put wired and Wi-Fi clients on different `/24`s. If discovery comes up empty even though the device is reachable by IP, skip it entirely and add the device directly:
125
131
  >
126
132
  > ```bash
127
- > ff-cli device add --host http://<device-ip>:1111 --name <name>
133
+ > ff-cli device add --host http://<device-ip>:1111 --name <name> --id FF1-XXXXXXXX
128
134
  > ```
129
135
 
130
136
  You can also manage devices independently with:
131
137
 
132
- - `ff-cli device add` – Add a device interactively (with mDNS discovery), or non-interactively with `--host` and `--name`.
138
+ - `ff-cli device add` – Add a device interactively (with mDNS discovery), or
139
+ non-interactively with `--host`, `--name`, and the physical `--id` shown in
140
+ Feral File mobile. Manual devices need the ID before relayer pairing.
133
141
  - `ff-cli device list` – Show all configured devices.
134
142
  - `ff-cli device remove <name>` – Remove a device by name.
135
143
  - `ff-cli device default <name>` – Promote a device to the top of the list so it is used when `-d` is omitted.
144
+ - `ff-cli device pair [name]` – Display a one-time handoff code and key-bound
145
+ security check, then securely receive that device's relayer topic from Feral
146
+ File mobile. Confirm both values match before approving in mobile.
136
147
 
137
148
  Setup and `device add` both preserve existing devices. Adding a device with the same host as an existing one updates it in place.
138
149
 
@@ -161,6 +172,26 @@ Content-Type: application/json
161
172
 
162
173
  The `intent` field is **required** — without it the device returns `{"message":{"ok":false}}` with no further explanation. `dp1_call` may be the playlist inline (as above) or a hosted playlist URL string; ff-cli sends the resolved playlist object.
163
174
 
175
+ When LAN delivery is unreachable, `play` prints an explicit notice and retries
176
+ the same command through `ff1Relayer.baseUrl`. The per-device topic is read from
177
+ the operating system credential vault, never `config.json`. Pair it from the
178
+ mobile app first:
179
+
180
+ ```bash
181
+ ff-cli device pair office
182
+ ```
183
+
184
+ The CLI and mobile confirmation both show the same 12-hex-digit security check.
185
+ Comparing it authenticates the CLI public key out of band, so an active broker
186
+ cannot silently substitute its own encryption key.
187
+
188
+ The default relayer is
189
+ `https://tv-cast-coordination.autonomy-system.workers.dev`. Override it with
190
+ `ff1Relayer.baseUrl` or `FF1_RELAYER_URL`. Signed topic-only relayer deployments
191
+ need no additional key. Deployments that retain the cloud-edge compatibility
192
+ gate may receive `ff1Relayer.apiKey` or `FF1_RELAYER_API_KEY`; ff-cli never
193
+ bundles that shared key.
194
+
164
195
  On the first cast after boot the device may return an empty body. ff-cli retries this automatically and, if it persists, reports a clear "accepted the request but returned no usable response" error rather than crashing on a JSON parse.
165
196
 
166
197
  Compatibility checks:
@@ -204,6 +235,7 @@ Minimal `config.json` example (selected fields):
204
235
  "devices": [
205
236
  {
206
237
  "name": "Living Room Display",
238
+ "id": "FF1-ABCD1234",
207
239
  "host": "http://192.168.1.100:1111"
208
240
  }
209
241
  ]
@@ -110,6 +110,12 @@ Based on the code today, the CLI is responsible for:
110
110
  - signing playlists when a private key is configured
111
111
  - publishing validated playlists to configured feed servers
112
112
  - discovering configured FF1 devices and sending playlists or direct media playback requests
113
+ - pairing a device's signed relayer topic through a short-lived end-to-end
114
+ encrypted mobile handoff, authenticating the CLI key with a user-compared
115
+ security check, and storing it in the operating system credential vault
116
+ - falling back from unreachable LAN delivery to the shared FF1 relayer with an
117
+ explicit terminal notice, so sandboxed and cross-subnet control remains
118
+ observable to the mobile app
113
119
  - performing FF1 OS compatibility preflight checks before display and SSH flows
114
120
 
115
121
  ## Architecture boundaries
package/docs/README.md CHANGED
@@ -117,8 +117,9 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1
117
117
  - `ssh <enable|disable>` – Manage SSH access on an FF1 device
118
118
  - Options: `-d, --device <name>`, `--pubkey <path>`, `--ttl <duration>`
119
119
  - `device list` – List all configured FF1 devices
120
- - `device add` – Add a new FF1 device (with mDNS discovery)
121
- - Options: `--host <host>`, `--name <name>`
120
+ - `device add` – Add a device with mDNS or manual `--host`, `--name`, and `--id`
121
+ - Options: `--host <host>`, `--name <name>`, `--id <id>`
122
+ - `device pair [name]` – Securely receive relayer access from Feral File mobile
122
123
  - `device remove <name>` – Remove a configured FF1 device
123
124
  - `device default <name>` – Set the default FF1 device (used when `-d` is omitted)
124
125
  - `config <init|show|validate>` – Manage configuration
@@ -271,7 +272,7 @@ ff-cli device list
271
272
  ff-cli device add
272
273
 
273
274
  # Add a device non-interactively
274
- ff-cli device add --host 192.168.1.100 --name kitchen
275
+ ff-cli device add --host 192.168.1.100 --name kitchen --id FF1-XXXXXXXX
275
276
 
276
277
  # Remove a device by name
277
278
  ff-cli device remove kitchen
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,7 +32,8 @@
32
32
  "start": "node dist/index.js",
33
33
  "dev": "tsx index.ts",
34
34
  "smoke": "npm run build && node dist/index.js validate examples/sample-playlist-v11.json && node dist/index.js config validate",
35
- "check": "npm run format:check && npm run lint && npm run test",
35
+ "check": "npm run format:check && npm run lint && npm run check:copy && npm run test",
36
+ "check:copy": "node scripts/check-copy.js",
36
37
  "verify": "npm run check && npm run smoke",
37
38
  "prepublishOnly": "npm run verify",
38
39
  "test": "node scripts/run-tests.js",
@@ -57,6 +58,8 @@
57
58
  "author": "",
58
59
  "license": "MIT",
59
60
  "dependencies": {
61
+ "@napi-rs/keyring": "1.3.0",
62
+ "@feralfile/source-resolver": "git+https://github.com/feral-file/ff-source-resolver.git#54be943014c8cf7860114510257cdd713bbf4e79",
60
63
  "axios": "^1.6.2",
61
64
  "bonjour-service": "^1.3.0",
62
65
  "chalk": "^4.1.2",