@feralfile/cli 2.10.1 → 4.0.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.
Files changed (59) hide show
  1. package/README.md +31 -23
  2. package/config.json.example +6 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +40 -0
  6. package/dist/src/commands/config.js +1 -0
  7. package/dist/src/commands/device.js +85 -40
  8. package/dist/src/commands/enrich.js +18 -23
  9. package/dist/src/commands/find.js +144 -106
  10. package/dist/src/commands/helpers/device-control.js +937 -0
  11. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  12. package/dist/src/commands/helpers/playlist-display.js +2 -15
  13. package/dist/src/commands/helpers/prompt.js +85 -10
  14. package/dist/src/commands/play.js +162 -84
  15. package/dist/src/commands/report.js +251 -0
  16. package/dist/src/commands/setup.js +82 -7
  17. package/dist/src/commands/status.js +4 -1
  18. package/dist/src/config.js +256 -6
  19. package/dist/src/main.js +14 -5
  20. package/dist/src/utilities/control/codec.js +578 -0
  21. package/dist/src/utilities/control/control-errors.js +157 -0
  22. package/dist/src/utilities/control/controller-store.js +964 -0
  23. package/dist/src/utilities/control/jwk.js +141 -0
  24. package/dist/src/utilities/control/pairing-channel.js +52 -0
  25. package/dist/src/utilities/control/pairing.js +416 -0
  26. package/dist/src/utilities/control/registration.js +391 -0
  27. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  28. package/dist/src/utilities/control/secure-transport.js +850 -0
  29. package/dist/src/utilities/control/strict-json.js +376 -0
  30. package/dist/src/utilities/device-lookup.js +36 -11
  31. package/dist/src/utilities/device-network.js +129 -0
  32. package/dist/src/utilities/device-upsert.js +1 -1
  33. package/dist/src/utilities/feral-file-artwork.js +2 -2
  34. package/dist/src/utilities/ff1-compatibility.js +87 -11
  35. package/dist/src/utilities/ff1-device.js +161 -78
  36. package/dist/src/utilities/ff1-relayer.js +34 -14
  37. package/dist/src/utilities/handoff-client.js +23 -9
  38. package/dist/src/utilities/index.js +270 -259
  39. package/dist/src/utilities/media-url.js +117 -0
  40. package/dist/src/utilities/neort-marketplace.js +3 -3
  41. package/dist/src/utilities/nft-indexer.js +12 -376
  42. package/dist/src/utilities/playlist-builder.js +11 -2
  43. package/dist/src/utilities/playlist-cast.js +6 -6
  44. package/dist/src/utilities/playlist-source.js +223 -34
  45. package/dist/src/utilities/raster-client.js +101 -14
  46. package/dist/src/utilities/relay-client.js +774 -0
  47. package/dist/src/utilities/ssh-access.js +192 -58
  48. package/dist/src/utilities/support-report.js +511 -0
  49. package/dist/src/utilities/temporary-channel.js +139 -0
  50. package/dist/src/utilities/token-item.js +428 -0
  51. package/dist/src/utilities/token-source-contract.js +55 -0
  52. package/dist/src/utilities/token-source-indexer.js +99 -0
  53. package/dist/src/utilities/token-source-raster.js +378 -0
  54. package/dist/src/utilities/token-source.js +48 -0
  55. package/docs/CONFIGURATION.md +331 -28
  56. package/docs/EXAMPLES.md +33 -33
  57. package/docs/PROJECT_SPEC.md +55 -36
  58. package/docs/README.md +145 -43
  59. package/package.json +5 -2
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ /**
3
+ * P-256 JWK handling for the FF1 control protocol v1 (§2).
4
+ *
5
+ * Every key the protocol carries (device, controller, ephemeral) is a P-256
6
+ * JWK with EXACTLY the members kty, crv, x, y. A private `d` in a public slot
7
+ * is an error, never ignored: a client that ships its private key has a bug
8
+ * this module must not paper over. The Go reference is control/jwk.go.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.parsePublicJwk = parsePublicJwk;
12
+ exports.parsePrivateJwk = parsePrivateJwk;
13
+ exports.publicOf = publicOf;
14
+ exports.canonicalJwkJson = canonicalJwkJson;
15
+ exports.thumbprint = thumbprint;
16
+ exports.isThumbprint = isThumbprint;
17
+ exports.generatePrivateJwk = generatePrivateJwk;
18
+ exports.sameKey = sameKey;
19
+ const node_crypto_1 = require("node:crypto");
20
+ const strict_json_1 = require("./strict-json");
21
+ const COORD_LEN = 32;
22
+ /**
23
+ * parsePublicJwk validates a parsed JWK object: exact member set, `EC`/`P-256`,
24
+ * 32-byte canonical base64url coordinates, and a point on the curve (Node's
25
+ * key import runs the SEC 1 on-curve check and rejects the identity).
26
+ *
27
+ * @param value - Parsed JSON value (from parseStrictJson or a trusted literal)
28
+ * @param what - Name used in error messages
29
+ * @returns The key in canonical member order
30
+ * @throws {ControlFormatError} When the key is malformed or off-curve
31
+ */
32
+ function parsePublicJwk(value, what = 'JWK') {
33
+ const obj = (0, strict_json_1.strictObject)(value, ['kty', 'crv', 'x', 'y'], [], what);
34
+ if ((0, strict_json_1.stringMember)(obj, 'kty') !== 'EC' || (0, strict_json_1.stringMember)(obj, 'crv') !== 'P-256') {
35
+ throw new strict_json_1.ControlFormatError(`${what} is not an EC P-256 key`);
36
+ }
37
+ const x = (0, strict_json_1.stringMember)(obj, 'x');
38
+ const y = (0, strict_json_1.stringMember)(obj, 'y');
39
+ if (!(0, strict_json_1.isB64uOfLength)(x, COORD_LEN) || !(0, strict_json_1.isB64uOfLength)(y, COORD_LEN)) {
40
+ throw new strict_json_1.ControlFormatError(`${what} coordinates must be 32-byte base64url`);
41
+ }
42
+ const jwk = { crv: 'P-256', kty: 'EC', x, y };
43
+ try {
44
+ (0, node_crypto_1.createPublicKey)({ key: { ...jwk }, format: 'jwk' });
45
+ }
46
+ catch {
47
+ throw new strict_json_1.ControlFormatError(`${what} is not a point on P-256`);
48
+ }
49
+ return jwk;
50
+ }
51
+ /**
52
+ * parsePrivateJwk validates a stored private JWK: exact members kty, crv, x,
53
+ * y, d, and `d` must reproduce `x`,`y`. A mismatch means the file was edited
54
+ * or corrupted, and trusting either half would sign with a key the device does
55
+ * not have on record.
56
+ *
57
+ * @param value - Parsed JSON value
58
+ * @param what - Name used in error messages
59
+ * @returns The private key
60
+ * @throws {ControlFormatError} When malformed or inconsistent
61
+ */
62
+ function parsePrivateJwk(value, what = 'private JWK') {
63
+ const obj = (0, strict_json_1.strictObject)(value, ['kty', 'crv', 'x', 'y', 'd'], [], what);
64
+ const pub = parsePublicJwk({ kty: obj.kty, crv: obj.crv, x: obj.x, y: obj.y }, what);
65
+ const d = (0, strict_json_1.stringMember)(obj, 'd');
66
+ if (!(0, strict_json_1.isB64uOfLength)(d, COORD_LEN)) {
67
+ throw new strict_json_1.ControlFormatError(`${what} d must be 32-byte base64url`);
68
+ }
69
+ let derived;
70
+ try {
71
+ derived = (0, node_crypto_1.createPublicKey)((0, node_crypto_1.createPrivateKey)({ key: { ...pub, d }, format: 'jwk' })).export({
72
+ format: 'jwk',
73
+ });
74
+ }
75
+ catch {
76
+ throw new strict_json_1.ControlFormatError(`${what} is not a valid P-256 private key`);
77
+ }
78
+ if (derived.x !== pub.x || derived.y !== pub.y) {
79
+ throw new strict_json_1.ControlFormatError(`${what} d does not match x,y`);
80
+ }
81
+ return { ...pub, d };
82
+ }
83
+ /**
84
+ * publicOf strips a private JWK down to its canonical public members.
85
+ *
86
+ * @param key - Private or public JWK
87
+ * @returns The public JWK in thumbprint member order
88
+ */
89
+ function publicOf(key) {
90
+ return { crv: 'P-256', kty: 'EC', x: key.x, y: key.y };
91
+ }
92
+ /**
93
+ * canonicalJwkJson is the RFC 7638 required-member serialization, which is
94
+ * also the wire form this CLI emits, so a JWK it writes is byte-identical to
95
+ * the input of its own thumbprint (§2 "SHOULD emit in thumbprint order").
96
+ *
97
+ * @param key - JWK
98
+ * @returns `{"crv":"P-256","kty":"EC","x":"…","y":"…"}`
99
+ */
100
+ function canonicalJwkJson(key) {
101
+ return `{"crv":"P-256","kty":"EC","x":${JSON.stringify(key.x)},"y":${JSON.stringify(key.y)}}`;
102
+ }
103
+ /**
104
+ * thumbprint is the RFC 7638 JWK thumbprint of §2: b64u(SHA-256) of the
105
+ * canonical member serialization. It is always 43 characters.
106
+ *
107
+ * @param key - JWK (private members are ignored)
108
+ * @returns The thumbprint
109
+ */
110
+ function thumbprint(key) {
111
+ return (0, strict_json_1.b64uEncode)((0, node_crypto_1.createHash)('sha256').update(canonicalJwkJson(key), 'utf8').digest());
112
+ }
113
+ /**
114
+ * isThumbprint reports whether value is thumbprint-shaped (b64u of 32 bytes).
115
+ *
116
+ * @param value - Candidate value
117
+ * @returns True for a 43-character base64url SHA-256
118
+ */
119
+ function isThumbprint(value) {
120
+ return typeof value === 'string' && value.length === 43 && (0, strict_json_1.isB64uOfLength)(value, 32);
121
+ }
122
+ /**
123
+ * generatePrivateJwk creates a fresh P-256 key pair as a private JWK.
124
+ *
125
+ * @returns A private JWK in canonical member order plus `d`
126
+ */
127
+ function generatePrivateJwk() {
128
+ const { privateKey } = (0, node_crypto_1.generateKeyPairSync)('ec', { namedCurve: 'P-256' });
129
+ const jwk = privateKey.export({ format: 'jwk' });
130
+ return { crv: 'P-256', kty: 'EC', x: jwk.x, y: jwk.y, d: jwk.d };
131
+ }
132
+ /**
133
+ * sameKey reports whether two JWKs are the same point.
134
+ *
135
+ * @param a - First key
136
+ * @param b - Second key
137
+ * @returns True when x and y match
138
+ */
139
+ function sameKey(a, b) {
140
+ return a.x === b.x && a.y === b.y;
141
+ }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ /**
3
+ * The pairing channel of owner-initiated registration
4
+ * (ffos-user docs/ff1-control-v1.md §5.6 step 1): a relay channel named by
5
+ * the random channelId in the controller QR, on which the owner's app sends
6
+ * the sealed registration result.
7
+ *
8
+ * ff-cli only consumes this interface. The relay v2 carrier
9
+ * (`/v2/channels/{channelId}/connections`) is implemented by
10
+ * relay-pairing-channel.ts, which plugs in with setPairingChannelOpener at
11
+ * startup when a v2 endpoint is configured. Without one (or with invalid
12
+ * relayer settings) the default opener
13
+ * fails with PairingTransportUnavailableError, so the CLI says so instead of
14
+ * waiting on nothing.
15
+ *
16
+ * The channel is untrusted: anyone who saw the QR can join it and send bytes.
17
+ * Every message is opened and checked by registration.ts before it means
18
+ * anything; the channel carries no authority of its own.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.PairingTransportUnavailableError = void 0;
22
+ exports.setPairingChannelOpener = setPairingChannelOpener;
23
+ exports.pairingChannelOpener = pairingChannelOpener;
24
+ /** PairingTransportUnavailableError is thrown when relay v2 is off, so no pairing channel is installed. */
25
+ class PairingTransportUnavailableError extends Error {
26
+ constructor() {
27
+ super('owner-initiated pairing needs the relay v2 pairing channel, and relay v2 is turned off (check ff1Relayer.v2Endpoint, FF1_RELAY_V2_ENDPOINT and ff1Relayer.baseUrl)');
28
+ this.name = 'PairingTransportUnavailableError';
29
+ }
30
+ }
31
+ exports.PairingTransportUnavailableError = PairingTransportUnavailableError;
32
+ const unavailableOpener = async () => {
33
+ throw new PairingTransportUnavailableError();
34
+ };
35
+ let currentOpener = unavailableOpener;
36
+ /**
37
+ * setPairingChannelOpener installs the relay adapter's opener; undefined
38
+ * restores the default (unavailable) one.
39
+ *
40
+ * @param opener - Opener to use for `ff-cli device pair`
41
+ */
42
+ function setPairingChannelOpener(opener) {
43
+ currentOpener = opener ?? unavailableOpener;
44
+ }
45
+ /**
46
+ * pairingChannelOpener returns the installed opener.
47
+ *
48
+ * @returns The opener `device pair` uses unless a caller injects one
49
+ */
50
+ function pairingChannelOpener() {
51
+ return currentOpener;
52
+ }
@@ -0,0 +1,416 @@
1
+ "use strict";
2
+ /**
3
+ * Controller pairing for ff-cli: owner-initiated registration
4
+ * (ffos-user docs/ff1-control-v1.md §5.6, §14.8; feral-file#3551).
5
+ *
6
+ * Sequence:
7
+ * 1. chooseControllerKeys: this computer's existing keys for the FF1 when it
8
+ * has a readable controller file (so a re-pair after revocation, a role
9
+ * change or a factory reset keeps one controllerId on the device instead
10
+ * of orphaning a grant), else the pairing keys saved before the QR was
11
+ * first shown (so a retry after a timeout or Ctrl-C completes a grant an
12
+ * owner already registered), else new pairing keys, saved before use.
13
+ * 2. The caller shows the controller QR (registration.ts) and waits on the
14
+ * pairing channel for a sealed result that passes every §5.6.3 check.
15
+ * 3. prepareRegisteredController pins the result's profile: on the existing
16
+ * record when it is the same keys and identity, else on a new record that
17
+ * replaces an existing one only once enrolled (the staging store).
18
+ * 4. enrollRegistered sends a protected op:enroll without inv (§5.3) and
19
+ * stores role, nk and expiry from the verified reply.
20
+ *
21
+ * State is always consistent on interruption: before a verified result only
22
+ * the pairing keys are written, the record is written atomically before the first
23
+ * request, every counter is persisted before it is sent, and the grant is
24
+ * written only from a verified enroll reply. A record with a verified result
25
+ * that has not enrolled yet is finished by `ff-cli device pair --resume`.
26
+ */
27
+ var __importDefault = (this && this.__importDefault) || function (mod) {
28
+ return (mod && mod.__esModule) ? mod : { "default": mod };
29
+ };
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.CONTROL_PROBE_NAME_TIMEOUT_MS = exports.CONTROL_PROBE_TIMEOUT_MS = exports.ENROLL_RETRY_MS = exports.ENROLL_ATTEMPTS = exports.MAX_PAIR_TIMEOUT_MS = exports.DEFAULT_PAIR_TIMEOUT_MS = void 0;
32
+ exports.defaultControllerLabel = defaultControllerLabel;
33
+ exports.chooseControllerKeys = chooseControllerKeys;
34
+ exports.lanReadTimeoutMs = lanReadTimeoutMs;
35
+ exports.probeControlSupport = probeControlSupport;
36
+ exports.registrationRecordOf = registrationRecordOf;
37
+ exports.prepareRegisteredController = prepareRegisteredController;
38
+ exports.enrollRegistered = enrollRegistered;
39
+ const node_os_1 = __importDefault(require("node:os"));
40
+ const http_1 = require("../http");
41
+ const codec_1 = require("./codec");
42
+ const jwk_1 = require("./jwk");
43
+ const controller_store_1 = require("./controller-store");
44
+ const secure_transport_1 = require("./secure-transport");
45
+ const control_errors_1 = require("./control-errors");
46
+ const strict_json_1 = require("./strict-json");
47
+ /** DEFAULT_PAIR_TIMEOUT_MS is how long `device pair` waits for an owner by default. */
48
+ exports.DEFAULT_PAIR_TIMEOUT_MS = 5 * 60_000;
49
+ /** MAX_PAIR_TIMEOUT_MS bounds `--timeout`: the relay keeps nothing, so a QR is a short-lived offer. */
50
+ exports.MAX_PAIR_TIMEOUT_MS = 10 * 60_000;
51
+ /** ENROLL_ATTEMPTS bounds the enroll retries after a verified result (network blips). */
52
+ exports.ENROLL_ATTEMPTS = 5;
53
+ /** ENROLL_RETRY_MS is the pause between enroll attempts. */
54
+ exports.ENROLL_RETRY_MS = 2_000;
55
+ /**
56
+ * defaultControllerLabel is the label the owner sees when adding ff-cli:
57
+ * this machine's hostname, trimmed to the protocol's 64 characters.
58
+ *
59
+ * @returns A 1-64 character label
60
+ */
61
+ function defaultControllerLabel() {
62
+ const cleaned = (0, codec_1.stripUnsafeLabelCharacters)(node_os_1.default.hostname()).trim();
63
+ const label = [...cleaned].slice(0, 64).join('').trim();
64
+ return label || 'ff-cli';
65
+ }
66
+ /**
67
+ * chooseControllerKeys picks the keys the controller QR shows, always keys
68
+ * already on disk: those of the device's controller file when it is readable
69
+ * (enrolled or not), else the saved pairing keys (created and saved now when
70
+ * there are none). A corrupt controller file is moved aside, never written
71
+ * over, once a result arrives. An unreadable file throws: its keys may be
72
+ * fine, so nothing may pair past it.
73
+ *
74
+ * @param deviceId - FF1 device ID
75
+ * @param options - Main store options
76
+ * @returns The keys, whether they were already saved, and whether they are
77
+ * the controller file's (else the pairing keys file's)
78
+ */
79
+ function chooseControllerKeys(deviceId, options = {}) {
80
+ let existing = null;
81
+ try {
82
+ existing = (0, controller_store_1.loadController)(deviceId, options);
83
+ }
84
+ catch (error) {
85
+ if (!(error instanceof controller_store_1.ControllerFileCorruptError)) {
86
+ throw error;
87
+ }
88
+ }
89
+ if (existing) {
90
+ return { keys: existing.keys, reused: true, fromRecord: true };
91
+ }
92
+ return { ...(0, controller_store_1.pairingKeys)(deviceId, options), fromRecord: false };
93
+ }
94
+ /** CONTROL_PROBE_TIMEOUT_MS bounds the LAN status read that picks the pairing path. */
95
+ exports.CONTROL_PROBE_TIMEOUT_MS = 5_000;
96
+ /**
97
+ * CONTROL_PROBE_NAME_TIMEOUT_MS bounds the read by a `.local` name. macOS answers such a lookup
98
+ * after about 5 s, the whole of CONTROL_PROBE_TIMEOUT_MS, so the name gets room for the lookup.
99
+ */
100
+ exports.CONTROL_PROBE_NAME_TIMEOUT_MS = 12_000;
101
+ /**
102
+ * lanReadTimeoutMs is the deadline for one LAN read of host: a `.local` name gets room for the
103
+ * macOS lookup (CONTROL_PROBE_NAME_TIMEOUT_MS), anything else shortMs.
104
+ *
105
+ * @param host - Device base URL, e.g. http://ff1-abcd1234.local:1111
106
+ * @param shortMs - Deadline for an address or a unicast name
107
+ * @returns Milliseconds
108
+ */
109
+ function lanReadTimeoutMs(host, shortMs = exports.CONTROL_PROBE_TIMEOUT_MS) {
110
+ try {
111
+ return new URL(host).hostname.toLowerCase().endsWith('.local')
112
+ ? exports.CONTROL_PROBE_NAME_TIMEOUT_MS
113
+ : shortMs;
114
+ }
115
+ catch {
116
+ return shortMs;
117
+ }
118
+ }
119
+ /**
120
+ * probeBases lists where to read the status: for a `.local` host, an address discovery
121
+ * recorded comes first (no name lookup; IPv4 preferred, since a first-listed IPv6 entry may be
122
+ * link-local or unreachable), then the name itself.
123
+ *
124
+ * Trade-off: unlike the name, which carries the device ID, a stale address may now belong to
125
+ * another FF1 in the same home, whose answer then picks the UI. That is a wrong prompt, never
126
+ * a grant: the status only chooses which pairing to offer, and both paths verify the device.
127
+ * The read carries the configured API key, so such a device would see it, as one answering on
128
+ * a reused name would.
129
+ * Worst case before the code shows: CONTROL_PROBE_TIMEOUT_MS + CONTROL_PROBE_NAME_TIMEOUT_MS.
130
+ */
131
+ function probeBases(device) {
132
+ let url = null;
133
+ try {
134
+ url = new URL(device.host);
135
+ }
136
+ catch {
137
+ url = null;
138
+ }
139
+ const byName = url !== null && url.hostname.toLowerCase().endsWith('.local');
140
+ const bases = [];
141
+ const recorded = byName ? (device.addresses ?? []).filter((value) => value.trim() !== '') : [];
142
+ const address = recorded.find((value) => !value.includes(':')) ?? recorded[0];
143
+ if (url && address) {
144
+ const host = address.includes(':') ? `[${address}]` : address;
145
+ bases.push({
146
+ base: `${url.protocol}//${host}${url.port ? `:${url.port}` : ''}`,
147
+ byName: false,
148
+ });
149
+ }
150
+ bases.push({ base: device.host, byName });
151
+ return bases;
152
+ }
153
+ /**
154
+ * probeControlSupport reads `getDeviceStatus` in plaintext over the LAN
155
+ * (§11: the `control` block is the capability signal). An owned FF1 refuses
156
+ * plaintext with a public `enrollment_required` (§7.2), which also means
157
+ * secure. The answer is unauthenticated and only picks the pairing UI; no
158
+ * profile is ever taken from it.
159
+ *
160
+ * A `.local` host is read at the address discovery recorded first, then by
161
+ * name (see probeBases); the first read that is not `unknown` decides.
162
+ *
163
+ * @param device - Device with host, optional addresses and API key
164
+ * @param fetchFn - Fetch (tests); defaults to a per-read deadline
165
+ * @returns The pairing path the status points to
166
+ */
167
+ async function probeControlSupport(device, fetchFn) {
168
+ let support = 'unknown';
169
+ for (const { base, byName } of probeBases(device)) {
170
+ const fetchAt = fetchFn ??
171
+ ((input, init) => (0, http_1.fetchWithTimeout)(input, init ?? {}, byName ? lanReadTimeoutMs(base) : exports.CONTROL_PROBE_TIMEOUT_MS));
172
+ support = await probeControlSupportAt(device, base, fetchAt);
173
+ if (support !== 'unknown') {
174
+ return support;
175
+ }
176
+ }
177
+ return support;
178
+ }
179
+ /** probeControlSupportAt is one status read at base (see probeControlSupport). */
180
+ async function probeControlSupportAt(device, base, fetchFn) {
181
+ const headers = { 'Content-Type': 'application/json' };
182
+ if (device.apiKey) {
183
+ headers['API-KEY'] = device.apiKey;
184
+ }
185
+ let body;
186
+ let ok;
187
+ try {
188
+ const response = await fetchFn(`${base}/api/cast`, {
189
+ method: 'POST',
190
+ headers,
191
+ body: JSON.stringify({ command: 'getDeviceStatus', request: {} }),
192
+ });
193
+ ok = response.ok;
194
+ body = JSON.parse(await response.text());
195
+ }
196
+ catch {
197
+ return 'unknown';
198
+ }
199
+ if (!(0, strict_json_1.isPlainObject)(body)) {
200
+ return 'unknown';
201
+ }
202
+ const error = body.error;
203
+ if ((0, strict_json_1.isPlainObject)(error) && typeof error.code === 'string') {
204
+ return ['enrollment_required', 'envelope_required', 'locked'].includes(error.code)
205
+ ? 'secure'
206
+ : 'unknown';
207
+ }
208
+ // The LAN hub answers with the bare status object (FF OS 2.0.12 and
209
+ // later); a relayer-shaped `{message: status}` is accepted too.
210
+ const status = (0, strict_json_1.isPlainObject)(body.message) ? body.message : body;
211
+ if (!ok || status.ok === false) {
212
+ return 'unknown';
213
+ }
214
+ if (status.control === undefined) {
215
+ return 'plaintext';
216
+ }
217
+ return (0, strict_json_1.isPlainObject)(status.control) && status.control.state === 'legacy'
218
+ ? 'plaintext'
219
+ : 'secure';
220
+ }
221
+ /**
222
+ * registrationRecordOf keeps the facts of a verified approved result.
223
+ *
224
+ * @param result - Verified approved result
225
+ * @param now - Unix seconds
226
+ * @returns The facts stored in the controller file
227
+ */
228
+ function registrationRecordOf(result, now) {
229
+ const registration = {
230
+ channelId: result.channelId,
231
+ deviceName: result.deviceName,
232
+ role: result.role,
233
+ receivedAt: now,
234
+ };
235
+ if (result.expiresAt !== undefined) {
236
+ registration.expiresAt = result.expiresAt;
237
+ }
238
+ if (result.topicId !== undefined) {
239
+ registration.topicId = result.topicId;
240
+ }
241
+ return registration;
242
+ }
243
+ /**
244
+ * prepareRegisteredController pins a verified approved result. The existing
245
+ * record keeps its file (and counter) when it is the same keys under the same
246
+ * identity. Otherwise the result goes to a new record: the main file when
247
+ * there is none (a corrupt one is moved aside first, it may hold the only
248
+ * copy of an owner key), else the staging store, which replaces the existing
249
+ * record only once enrolled, so a failed pairing never costs a grant.
250
+ *
251
+ * @param input - Pinned profile, the keys the owner registered, label and result facts
252
+ * @param options - Main store options
253
+ * @returns The record and whether it is staged
254
+ */
255
+ function prepareRegisteredController(input, options = {}) {
256
+ const deviceId = input.profile.dev;
257
+ let existing = null;
258
+ try {
259
+ existing = (0, controller_store_1.loadController)(deviceId, options);
260
+ }
261
+ catch (error) {
262
+ if (!(error instanceof controller_store_1.ControllerFileCorruptError)) {
263
+ throw error; // unreadable: its keys may be fine, never write over them
264
+ }
265
+ (0, controller_store_1.setAsideCorruptController)(deviceId, options);
266
+ }
267
+ if (existing &&
268
+ (0, jwk_1.sameKey)(existing.keys.sig, input.keys.sig) &&
269
+ (0, jwk_1.sameKey)(existing.keys.enc, input.keys.enc) &&
270
+ (0, controller_store_1.sameIdentity)(existing.profile, input.profile)) {
271
+ // A staged record left by an abandoned replacement pairing is superseded
272
+ // by this result, and --resume must not pick it up later.
273
+ (0, controller_store_1.dropStagedController)(deviceId, false, options);
274
+ const record = (0, controller_store_1.recordRegistration)(deviceId, { label: input.label, registration: input.registration }, options);
275
+ return { record, staged: false };
276
+ }
277
+ const staged = existing !== null;
278
+ const record = (0, controller_store_1.createController)({
279
+ profile: input.profile,
280
+ label: input.label,
281
+ keys: input.keys,
282
+ registration: input.registration,
283
+ exclusive: !staged,
284
+ }, staged ? (0, controller_store_1.stagingStore)(options) : options);
285
+ return { record, staged };
286
+ }
287
+ /** enrollObject builds the §5 enroll claim for this controller. */
288
+ function enrollObject(record) {
289
+ return {
290
+ sig: (0, jwk_1.publicOf)(record.keys.sig),
291
+ enc: (0, jwk_1.publicOf)(record.keys.enc),
292
+ label: record.label,
293
+ platform: 'cli',
294
+ };
295
+ }
296
+ function asRole(value) {
297
+ return value === 'owner' || value === 'controller' ? value : undefined;
298
+ }
299
+ /**
300
+ * parseGrant validates an enroll success `res` (§5.3): role, nk, optional
301
+ * expiresAt, and a profile that must be the pinned one (a device whose
302
+ * profile changed under the same keys is a bug or an attack; either way
303
+ * nothing is stored).
304
+ */
305
+ function parseGrant(res, pinned) {
306
+ if (!(0, strict_json_1.isPlainObject)(res)) {
307
+ return 'enroll reply has no grant';
308
+ }
309
+ const role = asRole(res.role);
310
+ if (!role || !(0, strict_json_1.isB64uOfLength)(res.nk, 32)) {
311
+ return 'enroll reply has an invalid role or nk';
312
+ }
313
+ const expiresAt = res.expiresAt;
314
+ if (expiresAt !== undefined && (!Number.isSafeInteger(expiresAt) || expiresAt < 0)) {
315
+ return 'enroll reply has an invalid expiresAt';
316
+ }
317
+ const profile = res.profile;
318
+ if (!profile ||
319
+ profile.gen !== pinned.gen ||
320
+ profile.dev !== pinned.dev ||
321
+ !profile.sig ||
322
+ !(0, jwk_1.sameKey)(profile.sig, pinned.sig) ||
323
+ !profile.enc ||
324
+ !(0, jwk_1.sameKey)(profile.enc, pinned.enc)) {
325
+ return 'enroll reply carries a different FF1 profile than the one the owner sent';
326
+ }
327
+ const grant = {
328
+ role,
329
+ nk: res.nk,
330
+ };
331
+ if (expiresAt !== undefined) {
332
+ grant.expiresAt = expiresAt;
333
+ }
334
+ return grant;
335
+ }
336
+ /** failureFrom maps a non-success outcome to a failure. */
337
+ function failureFrom(outcome, label) {
338
+ if (outcome.kind === 'failed') {
339
+ return { code: outcome.code ?? 'failed', error: outcome.error, details: outcome.details };
340
+ }
341
+ const code = outcome.kind === 'public' ? outcome.code : (outcome.reply.code ?? 'unknown');
342
+ return (0, control_errors_1.describeControlCode)(code, label);
343
+ }
344
+ /**
345
+ * enrollRegistered sends op:enroll (no inv) until the FF1 returns the grant
346
+ * an owner registered, a definite refusal arrives, the attempts run out, or
347
+ * the user cancels. Each attempt is a new request with a new counter; enroll
348
+ * is idempotent (§5.3), so a lost reply is simply asked again.
349
+ *
350
+ * @param device - Device with ID
351
+ * @param record - Controller record with a pinned profile
352
+ * @param deps - Transport seams; controllerStore is the store the record is in
353
+ * @param options - Attempt tuning
354
+ * @returns The enrolled record or why enrollment stopped
355
+ */
356
+ async function enrollRegistered(device, record, deps = {}, options = {}) {
357
+ const attempts = options.attempts ?? exports.ENROLL_ATTEMPTS;
358
+ const wait = deps.waitFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
359
+ const label = device.name || device.id;
360
+ let last = null;
361
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
362
+ if (options.cancelled?.()) {
363
+ return { status: 'cancelled' };
364
+ }
365
+ const outcome = await (0, secure_transport_1.sendSecureRequest)({ device, op: 'enroll', enroll: enrollObject(record), relayerFallback: true, retry: false }, record, { ...deps, noticeFn: () => undefined });
366
+ const step = interpretEnrollOutcome(outcome, record, label, options.storeOptions);
367
+ if (step.status !== 'transient') {
368
+ return step;
369
+ }
370
+ last = { status: 'failed', failure: step.failure };
371
+ if (attempt < attempts) {
372
+ await wait(options.retryMs ?? exports.ENROLL_RETRY_MS);
373
+ }
374
+ }
375
+ return last;
376
+ }
377
+ /** interpretEnrollOutcome returns a final result, or a transient failure worth asking again. */
378
+ function interpretEnrollOutcome(outcome, record, label, storeOptions) {
379
+ if (outcome.kind === 'reply' && outcome.reply.ok) {
380
+ const grant = parseGrant(outcome.reply.res, record.profile);
381
+ if (typeof grant === 'string') {
382
+ return { status: 'failed', failure: { code: 'invalid_grant', error: grant } };
383
+ }
384
+ return { status: 'enrolled', record: (0, controller_store_1.markEnrolled)(record.deviceId, grant, storeOptions) };
385
+ }
386
+ // busy (§4.4): the FF1 was authenticating too many envelopes and did not
387
+ // run this one; the next attempt is a new request anyway.
388
+ if (outcome.kind === 'public' && outcome.code === 'busy') {
389
+ return { status: 'transient', failure: failureFrom(outcome, label) };
390
+ }
391
+ if (outcome.kind === 'reply') {
392
+ const code = outcome.reply.code;
393
+ if (code === 'rate_limited' || code === 'internal_error') {
394
+ return { status: 'transient', failure: failureFrom(outcome, label) };
395
+ }
396
+ if (code === 'not_approved' || code === 'pending') {
397
+ return {
398
+ status: 'failed',
399
+ failure: {
400
+ code,
401
+ error: 'The FF1 has no grant for ff-cli, although an owner’s Feral File app reported adding it.',
402
+ details: `An owner may have removed it again. Run \`${(0, control_errors_1.pairCommand)(label)}\` to show a new code.`,
403
+ },
404
+ };
405
+ }
406
+ }
407
+ // The FF1 or the relayer may come back (Wi-Fi blip, relayer restart).
408
+ // Protocol answers (codes) are never transient.
409
+ if (outcome.kind === 'failed' &&
410
+ (outcome.unreachable ||
411
+ outcome.timedOut || // enroll is idempotent (§5.3): asking again is safe
412
+ (outcome.transport === 'relayer' && !outcome.code))) {
413
+ return { status: 'transient', failure: failureFrom(outcome, label) };
414
+ }
415
+ return { status: 'failed', failure: failureFrom(outcome, label) };
416
+ }