@feralfile/cli 2.0.3 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.json.example +5 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/device.js +111 -7
- package/dist/src/commands/find.js +165 -98
- package/dist/src/commands/helpers/device-discovery.js +23 -3
- package/dist/src/commands/helpers/device-removal.js +40 -0
- package/dist/src/commands/play.js +3 -3
- package/dist/src/commands/sign.js +1 -1
- package/dist/src/config.js +55 -1
- package/dist/src/utilities/device-lookup.js +3 -2
- package/dist/src/utilities/device-rename.js +50 -0
- package/dist/src/utilities/device-upsert.js +19 -5
- package/dist/src/utilities/ff1-device.js +127 -25
- package/dist/src/utilities/ff1-relayer.js +105 -0
- package/dist/src/utilities/functions.js +2 -2
- package/dist/src/utilities/handoff-client.js +135 -0
- package/dist/src/utilities/handoff-crypto.js +132 -0
- package/dist/src/utilities/index.js +1 -1
- package/dist/src/utilities/playlist-builder.js +2 -2
- package/dist/src/utilities/ssh-access.js +1 -5
- package/dist/src/utilities/topic-store.js +33 -0
- package/docs/CONFIGURATION.md +32 -2
- package/docs/EXAMPLES.md +3 -0
- package/docs/PROJECT_SPEC.md +6 -0
- package/docs/README.md +8 -3
- package/package.json +5 -2
- package/dist/src/utilities/marketplace-url.js +0 -443
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TopicHandoffReceiver = void 0;
|
|
4
|
+
const handoff_crypto_1 = require("./handoff-crypto");
|
|
5
|
+
const DEFAULT_HANDOFF_URL = 'https://handoff.feralfile.com';
|
|
6
|
+
function brokerBaseUrl(value) {
|
|
7
|
+
return (value || process.env.FF1_HANDOFF_URL || DEFAULT_HANDOFF_URL).replace(/\/$/, '');
|
|
8
|
+
}
|
|
9
|
+
async function responseJson(response, operation) {
|
|
10
|
+
if (!response.ok) {
|
|
11
|
+
throw new Error(`${operation} failed with HTTP ${response.status}`);
|
|
12
|
+
}
|
|
13
|
+
return response.json();
|
|
14
|
+
}
|
|
15
|
+
function parseCreated(value) {
|
|
16
|
+
const record = value;
|
|
17
|
+
if (!record ||
|
|
18
|
+
typeof record.channelId !== 'string' ||
|
|
19
|
+
!record.channelId.startsWith('ch_') ||
|
|
20
|
+
typeof record.minterToken !== 'string' ||
|
|
21
|
+
typeof record.shortCode !== 'string' ||
|
|
22
|
+
!/^\d{6}$/.test(record.shortCode) ||
|
|
23
|
+
typeof record.expiresAt !== 'string') {
|
|
24
|
+
throw new Error('handoff channel response is invalid');
|
|
25
|
+
}
|
|
26
|
+
return record;
|
|
27
|
+
}
|
|
28
|
+
function parsePoll(value, channelId) {
|
|
29
|
+
const record = value;
|
|
30
|
+
if (record?.channelId !== channelId || !Array.isArray(record.messages)) {
|
|
31
|
+
throw new Error('handoff poll response is invalid');
|
|
32
|
+
}
|
|
33
|
+
return record;
|
|
34
|
+
}
|
|
35
|
+
function parseTopicPayload(value, channelId, expectedDeviceId) {
|
|
36
|
+
const payload = value;
|
|
37
|
+
if (payload?.v !== 1 ||
|
|
38
|
+
payload.type !== 'ff1_topic_handoff' ||
|
|
39
|
+
payload.channelId !== channelId ||
|
|
40
|
+
typeof payload.deviceId !== 'string' ||
|
|
41
|
+
payload.deviceId.trim().toUpperCase() !== expectedDeviceId.trim().toUpperCase() ||
|
|
42
|
+
typeof payload.topicId !== 'string' ||
|
|
43
|
+
payload.topicId.trim().length === 0 ||
|
|
44
|
+
typeof payload.sentAt !== 'string' ||
|
|
45
|
+
Number.isNaN(Date.parse(payload.sentAt))) {
|
|
46
|
+
throw new Error('received topic handoff does not match the selected FF1 device');
|
|
47
|
+
}
|
|
48
|
+
return payload;
|
|
49
|
+
}
|
|
50
|
+
/** One short-lived CLI-side channel waiting for a mobile topic transfer. */
|
|
51
|
+
class TopicHandoffReceiver {
|
|
52
|
+
baseUrl;
|
|
53
|
+
channelId;
|
|
54
|
+
shortCode;
|
|
55
|
+
verificationCode;
|
|
56
|
+
expiresAt;
|
|
57
|
+
minterToken;
|
|
58
|
+
privateKey;
|
|
59
|
+
fetchFn;
|
|
60
|
+
constructor(baseUrl, channelId, shortCode, verificationCode, expiresAt, minterToken, privateKey, fetchFn) {
|
|
61
|
+
this.baseUrl = baseUrl;
|
|
62
|
+
this.channelId = channelId;
|
|
63
|
+
this.shortCode = shortCode;
|
|
64
|
+
this.verificationCode = verificationCode;
|
|
65
|
+
this.expiresAt = expiresAt;
|
|
66
|
+
this.minterToken = minterToken;
|
|
67
|
+
this.privateKey = privateKey;
|
|
68
|
+
this.fetchFn = fetchFn;
|
|
69
|
+
}
|
|
70
|
+
/** Ask the handoff broker for a one-time six-digit pairing channel. */
|
|
71
|
+
static async create(options = {}) {
|
|
72
|
+
const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
73
|
+
const baseUrl = brokerBaseUrl(options.baseUrl);
|
|
74
|
+
const keyPair = await (0, handoff_crypto_1.generateHandoffKeyPair)();
|
|
75
|
+
const publicKey = await (0, handoff_crypto_1.exportHandoffPublicJwk)(keyPair.publicKey);
|
|
76
|
+
const response = await fetchFn(new URL('/v1/channels', baseUrl), {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
algorithm: handoff_crypto_1.HANDOFF_ALGORITHM,
|
|
81
|
+
minterPublicKeyJwk: publicKey,
|
|
82
|
+
idleTtlSeconds: 300,
|
|
83
|
+
shortCodeRequested: true,
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
const created = parseCreated(await responseJson(response, 'handoff channel creation'));
|
|
87
|
+
const verificationCode = (0, handoff_crypto_1.handoffVerificationCode)({
|
|
88
|
+
channelId: created.channelId,
|
|
89
|
+
minterPublicKeyJwk: publicKey,
|
|
90
|
+
});
|
|
91
|
+
return new TopicHandoffReceiver(baseUrl, created.channelId, created.shortCode, verificationCode, created.expiresAt, created.minterToken, keyPair.privateKey, fetchFn);
|
|
92
|
+
}
|
|
93
|
+
/** Poll until mobile sends a valid encrypted topic for the selected device. */
|
|
94
|
+
async receiveTopic(options) {
|
|
95
|
+
const deadline = Date.now() + (options.maxWaitMs ?? 300_000);
|
|
96
|
+
let afterSeq = 0;
|
|
97
|
+
while (Date.now() <= deadline) {
|
|
98
|
+
const url = new URL(`/v1/channels/${encodeURIComponent(this.channelId)}/messages`, this.baseUrl);
|
|
99
|
+
url.searchParams.set('afterSeq', String(afterSeq));
|
|
100
|
+
const response = await this.fetchFn(url, {
|
|
101
|
+
headers: { Authorization: `Bearer ${this.minterToken}` },
|
|
102
|
+
});
|
|
103
|
+
const poll = parsePoll(await responseJson(response, 'handoff poll'), this.channelId);
|
|
104
|
+
for (const message of poll.messages) {
|
|
105
|
+
afterSeq = Math.max(afterSeq, message.seq);
|
|
106
|
+
if (message.sender !== 'browser' ||
|
|
107
|
+
message.recipient !== 'minter' ||
|
|
108
|
+
!message.senderPublicKeyJwk) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const plaintext = await (0, handoff_crypto_1.decryptHandoffMessage)({
|
|
112
|
+
privateKey: this.privateKey,
|
|
113
|
+
peerPublicKeyJwk: message.senderPublicKeyJwk,
|
|
114
|
+
channelId: this.channelId,
|
|
115
|
+
seq: message.seq,
|
|
116
|
+
message,
|
|
117
|
+
});
|
|
118
|
+
return parseTopicPayload(plaintext, this.channelId, options.expectedDeviceId);
|
|
119
|
+
}
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 1000));
|
|
121
|
+
}
|
|
122
|
+
throw new Error('Timed out waiting for the mobile app topic transfer');
|
|
123
|
+
}
|
|
124
|
+
/** Close the broker channel and invalidate its participant credentials. */
|
|
125
|
+
async close() {
|
|
126
|
+
const response = await this.fetchFn(new URL(`/v1/channels/${encodeURIComponent(this.channelId)}`, this.baseUrl), {
|
|
127
|
+
method: 'DELETE',
|
|
128
|
+
headers: { Authorization: `Bearer ${this.minterToken}` },
|
|
129
|
+
});
|
|
130
|
+
if (!response.ok && response.status !== 404 && response.status !== 410) {
|
|
131
|
+
throw new Error(`handoff channel close failed with HTTP ${response.status}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
exports.TopicHandoffReceiver = TopicHandoffReceiver;
|
|
@@ -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
|
|
@@ -317,7 +317,7 @@ function convertTokensToDP1Items(tokensInfo, duration) {
|
|
|
317
317
|
*/
|
|
318
318
|
function generatePlaylistTitle(items) {
|
|
319
319
|
if (!items || items.length === 0) {
|
|
320
|
-
return '
|
|
320
|
+
return 'DP-1 Playlist';
|
|
321
321
|
}
|
|
322
322
|
// Check if all items have provenance (likely NFT playlist)
|
|
323
323
|
const hasProvenance = items.some((item) => item.provenance?.type === 'onChain');
|
|
@@ -337,7 +337,7 @@ function generatePlaylistTitle(items) {
|
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
339
|
// Fallback: use item count
|
|
340
|
-
return `
|
|
340
|
+
return `DP-1 Playlist (${items.length} ${items.length === 1 ? 'item' : 'items'})`;
|
|
341
341
|
}
|
|
342
342
|
/**
|
|
343
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
|
-
|
|
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
|
+
}
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -120,6 +120,9 @@ Configure devices you want to play content on.
|
|
|
120
120
|
|
|
121
121
|
- `ff1Devices.devices` (array of objects):
|
|
122
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.
|
|
123
126
|
- `host` (string): Device base URL. For LAN devices, use `http://<ip>:1111`. The device typically listens on port `1111`.
|
|
124
127
|
|
|
125
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.
|
|
@@ -127,15 +130,21 @@ During `ff-cli setup`, the CLI will attempt local discovery via mDNS (`_ff1._tcp
|
|
|
127
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:
|
|
128
131
|
>
|
|
129
132
|
> ```bash
|
|
130
|
-
> 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
|
|
131
134
|
> ```
|
|
132
135
|
|
|
133
136
|
You can also manage devices independently with:
|
|
134
137
|
|
|
135
|
-
- `ff-cli device add` – Add a device interactively (with mDNS discovery), or
|
|
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.
|
|
136
141
|
- `ff-cli device list` – Show all configured devices.
|
|
142
|
+
- `ff-cli device rename <name> <new-name>` – Rename a device without touching its host, API key, or default status. Works offline; also accepts the host URL as the identifier, so unnamed legacy entries can be given a name.
|
|
137
143
|
- `ff-cli device remove <name>` – Remove a device by name.
|
|
138
144
|
- `ff-cli device default <name>` – Promote a device to the top of the list so it is used when `-d` is omitted.
|
|
145
|
+
- `ff-cli device pair [name]` – Display a one-time handoff code and key-bound
|
|
146
|
+
security check, then securely receive that device's relayer topic from Feral
|
|
147
|
+
File mobile. Confirm both values match before approving in mobile.
|
|
139
148
|
|
|
140
149
|
Setup and `device add` both preserve existing devices. Adding a device with the same host as an existing one updates it in place.
|
|
141
150
|
|
|
@@ -164,6 +173,26 @@ Content-Type: application/json
|
|
|
164
173
|
|
|
165
174
|
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.
|
|
166
175
|
|
|
176
|
+
When LAN delivery is unreachable, `play` prints an explicit notice and retries
|
|
177
|
+
the same command through `ff1Relayer.baseUrl`. The per-device topic is read from
|
|
178
|
+
the operating system credential vault, never `config.json`. Pair it from the
|
|
179
|
+
mobile app first:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
ff-cli device pair office
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The CLI and mobile confirmation both show the same 12-hex-digit security check.
|
|
186
|
+
Comparing it authenticates the CLI public key out of band, so an active broker
|
|
187
|
+
cannot silently substitute its own encryption key.
|
|
188
|
+
|
|
189
|
+
The default relayer is
|
|
190
|
+
`https://tv-cast-coordination.autonomy-system.workers.dev`. Override it with
|
|
191
|
+
`ff1Relayer.baseUrl` or `FF1_RELAYER_URL`. Signed topic-only relayer deployments
|
|
192
|
+
need no additional key. Deployments that retain the cloud-edge compatibility
|
|
193
|
+
gate may receive `ff1Relayer.apiKey` or `FF1_RELAYER_API_KEY`; ff-cli never
|
|
194
|
+
bundles that shared key.
|
|
195
|
+
|
|
167
196
|
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.
|
|
168
197
|
|
|
169
198
|
Compatibility checks:
|
|
@@ -207,6 +236,7 @@ Minimal `config.json` example (selected fields):
|
|
|
207
236
|
"devices": [
|
|
208
237
|
{
|
|
209
238
|
"name": "Living Room Display",
|
|
239
|
+
"id": "FF1-ABCD1234",
|
|
210
240
|
"host": "http://192.168.1.100:1111"
|
|
211
241
|
}
|
|
212
242
|
]
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -385,6 +385,9 @@ npm run dev -- device add
|
|
|
385
385
|
# Add a device non-interactively
|
|
386
386
|
npm run dev -- device add --host 192.168.1.100 --name kitchen
|
|
387
387
|
|
|
388
|
+
# Rename a device (host, API key, and default status are untouched)
|
|
389
|
+
npm run dev -- device rename kitchen gallery
|
|
390
|
+
|
|
388
391
|
# Remove a device by name
|
|
389
392
|
npm run dev -- device remove kitchen
|
|
390
393
|
|
package/docs/PROJECT_SPEC.md
CHANGED
|
@@ -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,10 @@ 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
|
|
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
|
|
123
|
+
- `device rename <name> <new-name>` – Rename a configured FF1 device (host, API key, and default status are untouched)
|
|
122
124
|
- `device remove <name>` – Remove a configured FF1 device
|
|
123
125
|
- `device default <name>` – Set the default FF1 device (used when `-d` is omitted)
|
|
124
126
|
- `config <init|show|validate>` – Manage configuration
|
|
@@ -271,7 +273,10 @@ ff-cli device list
|
|
|
271
273
|
ff-cli device add
|
|
272
274
|
|
|
273
275
|
# Add a device non-interactively
|
|
274
|
-
ff-cli device add --host 192.168.1.100 --name kitchen
|
|
276
|
+
ff-cli device add --host 192.168.1.100 --name kitchen --id FF1-XXXXXXXX
|
|
277
|
+
|
|
278
|
+
# Rename a device (host, API key, and default status are untouched)
|
|
279
|
+
ff-cli device rename kitchen gallery
|
|
275
280
|
|
|
276
281
|
# Remove a device by name
|
|
277
282
|
ff-cli device remove kitchen
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feralfile/cli",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.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",
|