@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,964 @@
1
+ "use strict";
2
+ /**
3
+ * Controller key store: one JSON file per FF1 at
4
+ * `<config dir>/controllers/<deviceId>.json` (feral-file#3551 M6).
5
+ *
6
+ * The file holds this CLI's private controller keys (ctlSig, ctlEnc), the
7
+ * device profile it pinned from an owner's sealed registration result during
8
+ * `ff-cli device pair` (the ONLY trust root for replies), the verified
9
+ * registration facts, the grant (role, nk, expiry) once enrolled, and the
10
+ * request counter.
11
+ *
12
+ * Invariants a later change must keep:
13
+ * - Mode 0600 in a 0700 directory on POSIX. Windows has no mode bits; there
14
+ * the file relies on the per-user profile ACL (documented caveat).
15
+ * - `ctr` only ever increases, and is persisted BEFORE a request carrying it
16
+ * is sent (nextCounter). A crash after the write and before the send just
17
+ * skips a number, which the device's window tolerates; sending first and
18
+ * crashing before the write would reuse a counter, which the device rejects
19
+ * as `replay`. Allocation is serialized across processes by a lock file.
20
+ * - Every write is tmp + fsync + rename, so a crash never leaves a truncated
21
+ * file (and a truncated file is the one way to lose `ctr`).
22
+ * - Private JWKs are never logged or put into an error message.
23
+ *
24
+ * The OS keyring was rejected as the store: headless Linux has no secret
25
+ * service, and the counter must be written on every request.
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.ControllerFileCorruptError = void 0;
32
+ exports.controllersDir = controllersDir;
33
+ exports.controllerFilePath = controllerFilePath;
34
+ exports.normalizeDeviceId = normalizeDeviceId;
35
+ exports.sameIdentity = sameIdentity;
36
+ exports.isEnrolled = isEnrolled;
37
+ exports.parseControllerRecord = parseControllerRecord;
38
+ exports.loadController = loadController;
39
+ exports.setAsideCorruptController = setAsideCorruptController;
40
+ exports.saveController = saveController;
41
+ exports.createController = createController;
42
+ exports.stagingStore = stagingStore;
43
+ exports.promoteStagedController = promoteStagedController;
44
+ exports.dropStagedController = dropStagedController;
45
+ exports.hasPairingKeys = hasPairingKeys;
46
+ exports.loadPairingKeys = loadPairingKeys;
47
+ exports.pairingKeys = pairingKeys;
48
+ exports.deletePairingKeys = deletePairingKeys;
49
+ exports.deleteController = deleteController;
50
+ exports.recordRegistration = recordRegistration;
51
+ exports.nextCounter = nextCounter;
52
+ exports.skipCounterAhead = skipCounterAhead;
53
+ exports.markEnrolled = markEnrolled;
54
+ exports.recordRelayV2 = recordRelayV2;
55
+ exports.describeController = describeController;
56
+ const node_fs_1 = __importDefault(require("node:fs"));
57
+ const node_crypto_1 = require("node:crypto");
58
+ const node_path_1 = __importDefault(require("node:path"));
59
+ const config_1 = require("../../config");
60
+ const codec_1 = require("./codec");
61
+ const jwk_1 = require("./jwk");
62
+ const strict_json_1 = require("./strict-json");
63
+ const FILE_MODE = 0o600;
64
+ const DIR_MODE = 0o700;
65
+ const DEVICE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
66
+ const LOCK_RETRY_MS = 25;
67
+ const LOCK_TIMEOUT_MS = 5_000;
68
+ // A lock is held for one small synchronous file write. A lock whose recorded
69
+ // holder PID is dead AND that is at least LOCK_MIN_STALE_MS old is stale; the
70
+ // age floor keeps a breaker from racing a holder that has only just written
71
+ // its token. A lock without a readable PID (the holder died between create
72
+ // and write) is stale after LOCK_UNOWNED_STALE_MS. A lock whose PID is alive
73
+ // (or was reused) is never broken automatically: the timeout error names the
74
+ // file for the user to remove.
75
+ const LOCK_MIN_STALE_MS = 1_000;
76
+ const LOCK_UNOWNED_STALE_MS = 30_000;
77
+ /**
78
+ * controllersDir returns `<XDG_CONFIG_HOME or ~/.config>/ff-cli/controllers`,
79
+ * next to the user config.json that getConfigPaths resolves.
80
+ *
81
+ * @param options - Optional directory override
82
+ * @returns Absolute directory path
83
+ */
84
+ function controllersDir(options = {}) {
85
+ return options.dir ?? node_path_1.default.join(node_path_1.default.dirname((0, config_1.getConfigPaths)().userPath), 'controllers');
86
+ }
87
+ /**
88
+ * controllerFilePath returns the file for one device. The device ID becomes
89
+ * a file name, so it is restricted to a safe alphabet (FF1-XXXXXXXX fits).
90
+ *
91
+ * @param deviceId - FF1 device ID
92
+ * @param options - Optional directory override
93
+ * @returns Absolute file path
94
+ * @throws {Error} When the device ID is not file-name safe
95
+ */
96
+ function controllerFilePath(deviceId, options = {}) {
97
+ const normalized = normalizeDeviceId(deviceId);
98
+ if (!DEVICE_ID_PATTERN.test(normalized)) {
99
+ throw new Error(`FF1 device ID "${deviceId}" cannot name a controller file`);
100
+ }
101
+ return node_path_1.default.join(controllersDir(options), `${normalized}.json`);
102
+ }
103
+ /**
104
+ * normalizeDeviceId is the one spelling of a device ID the store uses: trimmed
105
+ * and upper case, matching the topic store and the device's own hostname
106
+ * (FF1-XXXXXXXX). A hand-edited config.json may carry `ff1-abc`; without this
107
+ * the file written under the profile's upper-case `dev` would not be found.
108
+ *
109
+ * @param deviceId - Configured or profile device ID
110
+ * @returns Normalized ID
111
+ */
112
+ function normalizeDeviceId(deviceId) {
113
+ return deviceId.trim().toUpperCase();
114
+ }
115
+ /**
116
+ * sameIdentity reports whether two profiles are the same control identity.
117
+ *
118
+ * @param a - First profile
119
+ * @param b - Second profile
120
+ * @returns True when dev, gen and both keys match
121
+ */
122
+ function sameIdentity(a, b) {
123
+ return a.dev === b.dev && a.gen === b.gen && (0, jwk_1.sameKey)(a.sig, b.sig) && (0, jwk_1.sameKey)(a.enc, b.enc);
124
+ }
125
+ /**
126
+ * isEnrolled reports whether a record has completed enrollment.
127
+ *
128
+ * @param record - Controller record
129
+ * @returns True when role and nk are present
130
+ */
131
+ function isEnrolled(record) {
132
+ return record.role !== undefined && record.nk !== undefined;
133
+ }
134
+ /**
135
+ * parseControllerRecord validates a controller file strictly. A corrupt file
136
+ * is an error, never "no file": silently treating it as absent would send
137
+ * plaintext to a device the user secured.
138
+ *
139
+ * @param text - File contents
140
+ * @returns The record
141
+ * @throws {ControlFormatError} When malformed
142
+ */
143
+ function parseControllerRecord(text) {
144
+ const obj = (0, strict_json_1.strictObject)((0, strict_json_1.parseStrictJson)(text), ['v', 'deviceId', 'gen', 'pins', 'profile', 'keys', 'label', 'ctr', 'createdAt'], ['role', 'nk', 'enrolledAt', 'expiresAt', 'registration', 'relayV2'], 'controller file');
145
+ if ((0, strict_json_1.uintMember)(obj, 'v') !== 1) {
146
+ throw new strict_json_1.ControlFormatError('controller file v must be 1');
147
+ }
148
+ const keysObj = (0, strict_json_1.strictObject)(obj.keys, ['sig', 'enc'], [], 'controller keys');
149
+ const record = {
150
+ v: 1,
151
+ deviceId: (0, strict_json_1.stringMember)(obj, 'deviceId'),
152
+ profile: (0, codec_1.parseProfile)(obj.profile),
153
+ keys: {
154
+ sig: (0, jwk_1.parsePrivateJwk)(keysObj.sig, 'controller sig key'),
155
+ enc: (0, jwk_1.parsePrivateJwk)(keysObj.enc, 'controller enc key'),
156
+ },
157
+ label: (0, strict_json_1.stringMember)(obj, 'label'),
158
+ ctr: (0, strict_json_1.uintMember)(obj, 'ctr'),
159
+ createdAt: (0, strict_json_1.uintMember)(obj, 'createdAt'),
160
+ };
161
+ if (record.profile.dev !== record.deviceId) {
162
+ throw new strict_json_1.ControlFormatError('controller file profile is for another device');
163
+ }
164
+ // gen and pins are redundant with the profile; a mismatch means the file
165
+ // was hand-edited, and which half to believe cannot be decided.
166
+ const pins = (0, strict_json_1.strictObject)(obj.pins, ['sig', 'enc'], [], 'controller pins');
167
+ if (obj.gen !== record.profile.gen ||
168
+ pins.sig !== (0, jwk_1.thumbprint)(record.profile.sig) ||
169
+ pins.enc !== (0, jwk_1.thumbprint)(record.profile.enc)) {
170
+ throw new strict_json_1.ControlFormatError('controller file pins do not match its profile');
171
+ }
172
+ if ('role' in obj) {
173
+ const role = (0, strict_json_1.stringMember)(obj, 'role');
174
+ if (role !== 'owner' && role !== 'controller') {
175
+ throw new strict_json_1.ControlFormatError('controller file role is invalid');
176
+ }
177
+ record.role = role;
178
+ }
179
+ if ('nk' in obj) {
180
+ if (!(0, strict_json_1.isB64uOfLength)(obj.nk, 32)) {
181
+ throw new strict_json_1.ControlFormatError('controller file nk is invalid');
182
+ }
183
+ record.nk = obj.nk;
184
+ }
185
+ if ('enrolledAt' in obj) {
186
+ record.enrolledAt = (0, strict_json_1.uintMember)(obj, 'enrolledAt');
187
+ }
188
+ if ('expiresAt' in obj) {
189
+ record.expiresAt = (0, strict_json_1.uintMember)(obj, 'expiresAt');
190
+ }
191
+ if ('registration' in obj) {
192
+ record.registration = parseRegistrationRecord(obj.registration);
193
+ }
194
+ if ('relayV2' in obj) {
195
+ if (typeof obj.relayV2 !== 'boolean') {
196
+ throw new strict_json_1.ControlFormatError('controller file relayV2 is invalid');
197
+ }
198
+ record.relayV2 = obj.relayV2;
199
+ }
200
+ return record;
201
+ }
202
+ /** parseRegistrationRecord validates the stored registration facts. */
203
+ function parseRegistrationRecord(value) {
204
+ const obj = (0, strict_json_1.strictObject)(value, ['channelId', 'deviceName', 'role', 'receivedAt'], ['expiresAt', 'topicId'], 'controller file registration');
205
+ const role = (0, strict_json_1.stringMember)(obj, 'role');
206
+ if (role !== 'owner' && role !== 'controller') {
207
+ throw new strict_json_1.ControlFormatError('controller file registration role is invalid');
208
+ }
209
+ const registration = {
210
+ channelId: (0, strict_json_1.stringMember)(obj, 'channelId'),
211
+ deviceName: (0, strict_json_1.stringMember)(obj, 'deviceName'),
212
+ role,
213
+ receivedAt: (0, strict_json_1.uintMember)(obj, 'receivedAt'),
214
+ };
215
+ if ('expiresAt' in obj) {
216
+ registration.expiresAt = (0, strict_json_1.uintMember)(obj, 'expiresAt');
217
+ }
218
+ if ('topicId' in obj) {
219
+ const topicId = (0, strict_json_1.stringMember)(obj, 'topicId');
220
+ if (!topicId) {
221
+ throw new strict_json_1.ControlFormatError('controller file registration topicId is empty');
222
+ }
223
+ registration.topicId = topicId;
224
+ }
225
+ return registration;
226
+ }
227
+ /**
228
+ * serializeControllerRecord renders the file. gen and pins are derived from
229
+ * the profile and written for inspection; load checks they still agree.
230
+ */
231
+ function serializeControllerRecord(record) {
232
+ const body = {
233
+ v: 1,
234
+ deviceId: record.deviceId,
235
+ gen: record.profile.gen,
236
+ pins: { sig: (0, jwk_1.thumbprint)(record.profile.sig), enc: (0, jwk_1.thumbprint)(record.profile.enc) },
237
+ profile: record.profile,
238
+ keys: record.keys,
239
+ label: record.label,
240
+ ctr: record.ctr,
241
+ createdAt: record.createdAt,
242
+ };
243
+ if (record.role !== undefined) {
244
+ body.role = record.role;
245
+ }
246
+ if (record.nk !== undefined) {
247
+ body.nk = record.nk;
248
+ }
249
+ if (record.enrolledAt !== undefined) {
250
+ body.enrolledAt = record.enrolledAt;
251
+ }
252
+ if (record.expiresAt !== undefined) {
253
+ body.expiresAt = record.expiresAt;
254
+ }
255
+ if (record.registration !== undefined) {
256
+ body.registration = record.registration;
257
+ }
258
+ if (record.relayV2 !== undefined) {
259
+ body.relayV2 = record.relayV2;
260
+ }
261
+ return `${JSON.stringify(body, null, 2)}\n`;
262
+ }
263
+ /**
264
+ * ControllerFileCorruptError is thrown by loadController for a file that was
265
+ * read but does not parse. A file that could not be read at all (EACCES, EIO,
266
+ * a Windows lock) throws a plain Error instead: its keys may be fine, so no
267
+ * caller may treat it as absent and write over it.
268
+ */
269
+ class ControllerFileCorruptError extends Error {
270
+ constructor(message) {
271
+ super(message);
272
+ this.name = 'ControllerFileCorruptError';
273
+ }
274
+ }
275
+ exports.ControllerFileCorruptError = ControllerFileCorruptError;
276
+ /**
277
+ * loadController reads the controller file for a device, or null when none.
278
+ *
279
+ * @param deviceId - FF1 device ID
280
+ * @param options - Optional directory override
281
+ * @returns The record, or null when the file does not exist
282
+ * @throws {ControllerFileCorruptError} When the file exists but is corrupt
283
+ * @throws {Error} When the file exists but cannot be read
284
+ */
285
+ function loadController(deviceId, options = {}) {
286
+ const file = controllerFilePath(deviceId, options);
287
+ let text;
288
+ try {
289
+ text = node_fs_1.default.readFileSync(file, 'utf8');
290
+ }
291
+ catch (error) {
292
+ if (error.code === 'ENOENT') {
293
+ return null;
294
+ }
295
+ throw new Error(`Cannot read controller file ${file}: ${error.message}. Its keys were not changed; fix the file's permissions (or close the program holding it) and try again.`);
296
+ }
297
+ try {
298
+ const record = parseControllerRecord(text);
299
+ if (record.deviceId !== normalizeDeviceId(deviceId)) {
300
+ throw new strict_json_1.ControlFormatError('controller file is for another device');
301
+ }
302
+ return record;
303
+ }
304
+ catch (error) {
305
+ // The message comes from the validator and never echoes key material.
306
+ throw new ControllerFileCorruptError(`Controller file ${file} is corrupt (${error.message}). Run \`ff-cli device pair\` again; it moves the file aside before pairing.`);
307
+ }
308
+ }
309
+ /**
310
+ * renameCorruptAside renames a corrupt controller file to
311
+ * `<id>.json.corrupt-<unix seconds>` so a new pairing never writes over it
312
+ * (it may still be the only copy of an owner key). The caller holds the
313
+ * file's lock.
314
+ *
315
+ * @param file - Controller file path
316
+ * @returns The new path
317
+ */
318
+ function renameCorruptAside(file) {
319
+ const aside = `${file}.corrupt-${Math.floor(Date.now() / 1000)}`;
320
+ node_fs_1.default.renameSync(file, aside);
321
+ return aside;
322
+ }
323
+ /**
324
+ * setAsideCorruptController moves the device's controller file aside when it
325
+ * is (still) corrupt; a readable or missing file is left alone.
326
+ *
327
+ * @param deviceId - FF1 device ID
328
+ * @param options - Optional directory override
329
+ * @returns The new path, or null when nothing was moved
330
+ * @throws {Error} When the file cannot be read
331
+ */
332
+ function setAsideCorruptController(deviceId, options = {}) {
333
+ const file = controllerFilePath(deviceId, options);
334
+ return withControllerLock(file, () => {
335
+ try {
336
+ loadController(deviceId, options);
337
+ return null;
338
+ }
339
+ catch (error) {
340
+ if (!(error instanceof ControllerFileCorruptError)) {
341
+ throw error;
342
+ }
343
+ return renameCorruptAside(file);
344
+ }
345
+ });
346
+ }
347
+ /** ensurePrivateDir creates the controllers directory 0700 (tightening an existing one). */
348
+ function ensurePrivateDir(dir) {
349
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
350
+ if (process.platform !== 'win32') {
351
+ node_fs_1.default.chmodSync(dir, DIR_MODE);
352
+ }
353
+ }
354
+ /**
355
+ * writeAtomic writes a file as tmp + fsync + rename + best-effort dir fsync.
356
+ * The tmp file is created exclusively with mode 0600, so the keys are never
357
+ * readable by others even for an instant (umask can only narrow it).
358
+ */
359
+ function writeAtomic(file, contents) {
360
+ const dir = node_path_1.default.dirname(file);
361
+ ensurePrivateDir(dir);
362
+ const tmp = node_path_1.default.join(dir, `.${node_path_1.default.basename(file)}.${process.pid}.${Date.now()}.tmp`);
363
+ const fd = node_fs_1.default.openSync(tmp, 'wx', FILE_MODE);
364
+ // Any failure before the rename (ENOSPC on write, fsync, close, rename)
365
+ // removes the temp file: it holds private keys and must not linger.
366
+ try {
367
+ try {
368
+ node_fs_1.default.writeFileSync(fd, contents, 'utf8');
369
+ node_fs_1.default.fsyncSync(fd);
370
+ }
371
+ finally {
372
+ node_fs_1.default.closeSync(fd);
373
+ }
374
+ node_fs_1.default.renameSync(tmp, file);
375
+ }
376
+ catch (error) {
377
+ node_fs_1.default.rmSync(tmp, { force: true });
378
+ throw error;
379
+ }
380
+ if (process.platform !== 'win32') {
381
+ node_fs_1.default.chmodSync(file, FILE_MODE);
382
+ // Directory fsync makes the rename durable; Windows rejects it, and on
383
+ // POSIX a failure here only weakens durability, never consistency.
384
+ try {
385
+ const dfd = node_fs_1.default.openSync(dir, 'r');
386
+ try {
387
+ node_fs_1.default.fsyncSync(dfd);
388
+ }
389
+ finally {
390
+ node_fs_1.default.closeSync(dfd);
391
+ }
392
+ }
393
+ catch {
394
+ // best effort
395
+ }
396
+ }
397
+ }
398
+ /**
399
+ * saveController persists a record atomically with private permissions.
400
+ *
401
+ * @param record - Record to save
402
+ * @param options - Optional directory override
403
+ */
404
+ function saveController(record, options = {}) {
405
+ writeAtomic(controllerFilePath(record.deviceId, options), serializeControllerRecord(record));
406
+ }
407
+ /**
408
+ * createController saves a pending (not yet enrolled) record with ctr 0 for a
409
+ * pinned profile: with the given keys (the ones the owner registered), else
410
+ * freshly generated ones.
411
+ *
412
+ * @param input - Pinned profile, label, and optionally keys and registration facts
413
+ * @param options - Optional directory override
414
+ * @returns The saved record
415
+ */
416
+ function createController(input, options = {}) {
417
+ const record = {
418
+ v: 1,
419
+ deviceId: input.profile.dev,
420
+ profile: input.profile,
421
+ keys: input.keys ?? { sig: (0, jwk_1.generatePrivateJwk)(), enc: (0, jwk_1.generatePrivateJwk)() },
422
+ label: input.label,
423
+ ctr: 0,
424
+ createdAt: input.now ?? Math.floor(Date.now() / 1000),
425
+ };
426
+ if (input.registration) {
427
+ record.registration = input.registration;
428
+ }
429
+ const file = controllerFilePath(record.deviceId, options);
430
+ withControllerLock(file, () => {
431
+ // Checked under the lock: a record another process created meanwhile
432
+ // may already have spent counters, and must never be written over.
433
+ if (input.exclusive && node_fs_1.default.existsSync(file)) {
434
+ throw new Error(`A controller file for ${record.deviceId} appeared while pairing (${file}); it was kept. Run \`ff-cli device pair\` again.`);
435
+ }
436
+ saveController(record, options);
437
+ });
438
+ return record;
439
+ }
440
+ /**
441
+ * stagingStore is where `ff-cli device pair` keeps a new controller while an
442
+ * existing record for the same FF1 stays in place: the old keys (possibly the
443
+ * last reachable owner grant) are replaced only once the new pairing is
444
+ * enrolled (promoteStagedController), never by a pairing that fails.
445
+ *
446
+ * @param options - Main store options
447
+ * @returns Store options for the staging directory
448
+ */
449
+ function stagingStore(options = {}) {
450
+ return { dir: node_path_1.default.join(controllersDir(options), 'pairing') };
451
+ }
452
+ /**
453
+ * promoteStagedController moves an enrolled staged record over the device's
454
+ * controller file. Both locks are held (main, then staged; nothing takes them
455
+ * in the other order), so no counter allocated on either file is lost.
456
+ *
457
+ * @param deviceId - FF1 device ID
458
+ * @param options - Main store options
459
+ * @returns The promoted record
460
+ * @throws {Error} When the staged file is missing or corrupt
461
+ */
462
+ function promoteStagedController(deviceId, options = {}) {
463
+ const staged = stagingStore(options);
464
+ const from = controllerFilePath(deviceId, staged);
465
+ const to = controllerFilePath(deviceId, options);
466
+ return withControllerLock(to, () => withControllerLock(from, () => {
467
+ const record = loadController(deviceId, staged);
468
+ if (!record) {
469
+ throw new Error(`No staged controller file for ${deviceId}`);
470
+ }
471
+ // Rewritten rather than renamed: the staging directory is a different
472
+ // directory, and writeAtomic gives the same durability as every write.
473
+ // An earlier promotion that crashed before the remove already wrote
474
+ // these keys; the main file may have advanced its counter since, so it
475
+ // is kept as is.
476
+ let current;
477
+ try {
478
+ current = loadController(deviceId, options);
479
+ }
480
+ catch (error) {
481
+ if (!(error instanceof ControllerFileCorruptError)) {
482
+ throw error; // unreadable: its keys may be fine, keep both files
483
+ }
484
+ // Corrupt: kept aside, and the enrolled staged record replaces it.
485
+ renameCorruptAside(to);
486
+ current = null;
487
+ }
488
+ if (!current || !sameControl(current, record)) {
489
+ saveController(record, options);
490
+ node_fs_1.default.rmSync(from, { force: true });
491
+ return record;
492
+ }
493
+ node_fs_1.default.rmSync(from, { force: true });
494
+ return current;
495
+ }));
496
+ }
497
+ /**
498
+ * dropStagedController removes the device's staged record when it is no
499
+ * longer the pairing to continue: `onlyIfPromoted` drops it only when the
500
+ * main file already holds its keys (a promotion that crashed before the
501
+ * remove), otherwise it is dropped unconditionally (a same-identity pairing
502
+ * superseded it).
503
+ *
504
+ * @param deviceId - FF1 device ID
505
+ * @param onlyIfPromoted - Drop only a staged record whose keys the main file holds
506
+ * @param options - Main store options
507
+ * @returns True when a staged file was removed
508
+ */
509
+ function dropStagedController(deviceId, onlyIfPromoted, options = {}) {
510
+ const staged = stagingStore(options);
511
+ const from = controllerFilePath(deviceId, staged);
512
+ const to = controllerFilePath(deviceId, options);
513
+ return withControllerLock(to, () => withControllerLock(from, () => {
514
+ if (!node_fs_1.default.existsSync(from)) {
515
+ return false;
516
+ }
517
+ if (onlyIfPromoted) {
518
+ let record;
519
+ let current;
520
+ try {
521
+ record = loadController(deviceId, staged);
522
+ current = loadController(deviceId, options);
523
+ }
524
+ catch {
525
+ return false; // corrupt: leave both for the user to see
526
+ }
527
+ if (!record || !current || !sameControl(current, record)) {
528
+ return false;
529
+ }
530
+ }
531
+ node_fs_1.default.rmSync(from, { force: true });
532
+ return true;
533
+ }));
534
+ }
535
+ /**
536
+ * sameControl reports whether two records are the same keys under the same
537
+ * control identity. Keys alone are not enough: a pairing after a factory
538
+ * reset may register this computer's existing keys with the new identity,
539
+ * and that staged record must still replace the old one.
540
+ */
541
+ function sameControl(a, b) {
542
+ return (a.keys.sig.d === b.keys.sig.d &&
543
+ a.keys.enc.d === b.keys.enc.d &&
544
+ sameIdentity(a.profile, b.profile));
545
+ }
546
+ /**
547
+ * pairingKeysPath is where `ff-cli device pair` keeps the keys it shows in
548
+ * the controller QR before any owner answered: `pairing/<id>.keys.json`.
549
+ * The QR's keys must survive an interrupted pairing, because an owner may
550
+ * already have registered them (§5.6 step 5: a later retry with the same
551
+ * keys completes it), and a controller record cannot exist yet without the
552
+ * profile the owner's answer carries.
553
+ *
554
+ * @param deviceId - FF1 device ID
555
+ * @param options - Main store options
556
+ * @returns Absolute file path
557
+ */
558
+ function pairingKeysPath(deviceId, options = {}) {
559
+ const file = controllerFilePath(deviceId, stagingStore(options));
560
+ return file.replace(/\.json$/, '.keys.json');
561
+ }
562
+ /** parsePairingKeys validates the pairing keys file strictly. */
563
+ function parsePairingKeys(text, deviceId) {
564
+ const obj = (0, strict_json_1.strictObject)((0, strict_json_1.parseStrictJson)(text), ['v', 'deviceId', 'keys', 'createdAt'], [], 'pairing keys file');
565
+ if ((0, strict_json_1.uintMember)(obj, 'v') !== 1 || obj.deviceId !== deviceId) {
566
+ throw new strict_json_1.ControlFormatError('pairing keys file is for another device or version');
567
+ }
568
+ (0, strict_json_1.uintMember)(obj, 'createdAt');
569
+ const keysObj = (0, strict_json_1.strictObject)(obj.keys, ['sig', 'enc'], [], 'pairing keys');
570
+ return {
571
+ sig: (0, jwk_1.parsePrivateJwk)(keysObj.sig, 'pairing sig key'),
572
+ enc: (0, jwk_1.parsePrivateJwk)(keysObj.enc, 'pairing enc key'),
573
+ };
574
+ }
575
+ /** readPairingKeysText reads the pairing keys file, null when absent. */
576
+ function readPairingKeysText(file) {
577
+ try {
578
+ return node_fs_1.default.readFileSync(file, 'utf8');
579
+ }
580
+ catch (error) {
581
+ if (error.code === 'ENOENT') {
582
+ return null;
583
+ }
584
+ throw new Error(`Cannot read pairing keys file ${file}: ${error.message}. Fix its permissions and try again.`);
585
+ }
586
+ }
587
+ /**
588
+ * hasPairingKeys reports whether a pairing keys file exists for the device
589
+ * (readable, corrupt or not), without creating one.
590
+ *
591
+ * @param deviceId - FF1 device ID
592
+ * @param options - Main store options
593
+ * @returns True when the file exists
594
+ */
595
+ function hasPairingKeys(deviceId, options = {}) {
596
+ return node_fs_1.default.existsSync(pairingKeysPath(normalizeDeviceId(deviceId), options));
597
+ }
598
+ /**
599
+ * loadPairingKeys returns the saved pairing keys without creating any.
600
+ *
601
+ * @param deviceId - FF1 device ID
602
+ * @param options - Main store options
603
+ * @returns The keys, or null when there is no readable, valid file
604
+ * @throws {Error} When the file exists but cannot be read
605
+ */
606
+ function loadPairingKeys(deviceId, options = {}) {
607
+ const id = normalizeDeviceId(deviceId);
608
+ const text = readPairingKeysText(pairingKeysPath(id, options));
609
+ if (text === null) {
610
+ return null;
611
+ }
612
+ try {
613
+ return parsePairingKeys(text, id);
614
+ }
615
+ catch {
616
+ return null;
617
+ }
618
+ }
619
+ /**
620
+ * pairingKeys returns the keys an earlier interrupted pairing showed for the
621
+ * device, or creates and saves new ones (0600, atomic) before they are shown.
622
+ * A corrupt file is moved aside, never written over; an unreadable one
623
+ * throws, its keys may be fine.
624
+ *
625
+ * @param deviceId - FF1 device ID
626
+ * @param options - Main store options
627
+ * @returns The keys and whether they were already saved
628
+ */
629
+ function pairingKeys(deviceId, options = {}) {
630
+ const id = normalizeDeviceId(deviceId);
631
+ const file = pairingKeysPath(id, options);
632
+ return withControllerLock(file, () => {
633
+ const text = readPairingKeysText(file);
634
+ if (text !== null) {
635
+ try {
636
+ return { keys: parsePairingKeys(text, id), reused: true };
637
+ }
638
+ catch {
639
+ renameCorruptAside(file);
640
+ }
641
+ }
642
+ const keys = { sig: (0, jwk_1.generatePrivateJwk)(), enc: (0, jwk_1.generatePrivateJwk)() };
643
+ const body = { v: 1, deviceId: id, keys, createdAt: Math.floor(Date.now() / 1000) };
644
+ writeAtomic(file, `${JSON.stringify(body, null, 2)}\n`);
645
+ return { keys, reused: false };
646
+ });
647
+ }
648
+ /**
649
+ * deletePairingKeys removes the pairing keys file once its keys are in a
650
+ * controller record (or the controller is unpaired).
651
+ *
652
+ * @param deviceId - FF1 device ID
653
+ * @param options - Main store options
654
+ * @returns True when a file was removed
655
+ */
656
+ function deletePairingKeys(deviceId, options = {}) {
657
+ const file = pairingKeysPath(normalizeDeviceId(deviceId), options);
658
+ return withControllerLock(file, () => {
659
+ if (!node_fs_1.default.existsSync(file)) {
660
+ return false;
661
+ }
662
+ node_fs_1.default.rmSync(file);
663
+ return true;
664
+ });
665
+ }
666
+ /**
667
+ * deleteController removes a device's controller file.
668
+ *
669
+ * @param deviceId - FF1 device ID
670
+ * @param options - Optional directory override
671
+ * @returns True when a file was removed
672
+ */
673
+ function deleteController(deviceId, options = {}) {
674
+ const file = controllerFilePath(deviceId, options);
675
+ // Under the lock, so a concurrent nextCounter cannot re-create the file
676
+ // (resurrecting it) after the delete.
677
+ return withControllerLock(file, () => {
678
+ if (!node_fs_1.default.existsSync(file)) {
679
+ return false;
680
+ }
681
+ node_fs_1.default.rmSync(file);
682
+ return true;
683
+ });
684
+ }
685
+ /**
686
+ * recordRegistration stores a verified registration result on an existing
687
+ * record (same keys, same identity) as a locked re-read + save, so a counter
688
+ * allocated concurrently by another process is never rolled back.
689
+ *
690
+ * @param deviceId - FF1 device ID
691
+ * @param update - The label the owner registered and the result's facts
692
+ * @param options - Optional directory override
693
+ * @returns The updated record
694
+ * @throws {Error} When the file is missing or corrupt
695
+ */
696
+ function recordRegistration(deviceId, update, options = {}) {
697
+ const file = controllerFilePath(deviceId, options);
698
+ return withControllerLock(file, () => {
699
+ const record = loadController(deviceId, options);
700
+ if (!record) {
701
+ throw new Error(`No controller file for ${deviceId}`);
702
+ }
703
+ record.label = update.label;
704
+ record.registration = update.registration;
705
+ saveController(record, options);
706
+ return record;
707
+ });
708
+ }
709
+ /** sleepSync blocks briefly without spinning the CPU (lock retry only). */
710
+ function sleepSync(ms) {
711
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
712
+ }
713
+ /**
714
+ * withControllerLock runs fn while holding `<file>.lock`, created with O_EXCL
715
+ * so two ff-cli processes cannot allocate the same counter. The lock records
716
+ * the holder's PID and a unique token; see isStaleLock for when it is broken.
717
+ * fn must be synchronous, so a JS-level signal handler can never run while
718
+ * the lock is held.
719
+ */
720
+ function withControllerLock(file, fn) {
721
+ const lock = `${file}.lock`;
722
+ ensurePrivateDir(node_path_1.default.dirname(file));
723
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
724
+ // The token makes the lock ours: release removes it only if it still holds
725
+ // this token, so a process can never delete another process's live lock.
726
+ const token = `${process.pid}:${(0, node_crypto_1.randomUUID)()}`;
727
+ for (;;) {
728
+ try {
729
+ const fd = node_fs_1.default.openSync(lock, 'wx', FILE_MODE);
730
+ try {
731
+ node_fs_1.default.writeFileSync(fd, token);
732
+ }
733
+ finally {
734
+ node_fs_1.default.closeSync(fd);
735
+ }
736
+ break;
737
+ }
738
+ catch (error) {
739
+ if (error.code !== 'EEXIST') {
740
+ throw error;
741
+ }
742
+ const stale = readStaleLock(lock);
743
+ if (stale !== null) {
744
+ breakStaleLock(lock, stale);
745
+ continue;
746
+ }
747
+ if (Date.now() > deadline) {
748
+ throw new Error(`Timed out waiting for controller lock ${lock}. If no other ff-cli command is running, delete that file and retry.`);
749
+ }
750
+ sleepSync(LOCK_RETRY_MS);
751
+ }
752
+ }
753
+ try {
754
+ return fn();
755
+ }
756
+ finally {
757
+ releaseLock(lock, token);
758
+ }
759
+ }
760
+ /**
761
+ * breakStaleLock moves a lock judged stale aside with rename, which only one
762
+ * of several concurrent breakers can win (losers get ENOENT and retry the
763
+ * O_EXCL create). The moved file is then re-read: if it is no longer the lock
764
+ * that was judged stale, another process re-created the lock between the
765
+ * check and the rename, and we just moved a LIVE lock. It is linked back; if
766
+ * that fails (a third process created a new lock meanwhile) two holders could
767
+ * exist, so this fails with a lock error instead of proceeding.
768
+ *
769
+ * @param lock - Lock path
770
+ * @param judged - Contents of the lock when it was judged stale
771
+ * @throws {Error} When a live lock was displaced and cannot be restored
772
+ */
773
+ function breakStaleLock(lock, judged) {
774
+ const aside = `${lock}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.stale`;
775
+ try {
776
+ node_fs_1.default.renameSync(lock, aside);
777
+ }
778
+ catch {
779
+ return; // someone else broke it first
780
+ }
781
+ let moved;
782
+ try {
783
+ moved = node_fs_1.default.readFileSync(aside, 'utf8');
784
+ }
785
+ catch {
786
+ moved = null;
787
+ }
788
+ if (moved === judged) {
789
+ node_fs_1.default.rmSync(aside, { force: true });
790
+ return;
791
+ }
792
+ try {
793
+ node_fs_1.default.linkSync(aside, lock);
794
+ }
795
+ catch {
796
+ node_fs_1.default.rmSync(aside, { force: true });
797
+ throw new Error(`Controller lock ${lock} changed while it was being recovered; retry the command.`);
798
+ }
799
+ node_fs_1.default.rmSync(aside, { force: true });
800
+ }
801
+ /** releaseLock removes the lock only while it still holds this process's token. */
802
+ function releaseLock(lock, token) {
803
+ try {
804
+ if (node_fs_1.default.readFileSync(lock, 'utf8') === token) {
805
+ node_fs_1.default.rmSync(lock, { force: true });
806
+ }
807
+ }
808
+ catch {
809
+ // already gone
810
+ }
811
+ }
812
+ /**
813
+ * readStaleLock returns the lock's contents when it is stale, else null.
814
+ * Stale means: the recorded holder PID is dead and the lock is at least
815
+ * LOCK_MIN_STALE_MS old, or there is no readable PID and the lock is older
816
+ * than LOCK_UNOWNED_STALE_MS.
817
+ *
818
+ * @param lock - Lock path
819
+ * @returns Contents judged stale, or null
820
+ */
821
+ function readStaleLock(lock) {
822
+ try {
823
+ const contents = node_fs_1.default.readFileSync(lock, 'utf8');
824
+ const age = Date.now() - node_fs_1.default.statSync(lock).mtimeMs;
825
+ const pid = Number(contents.split(':')[0]);
826
+ const hasPid = contents !== '' && Number.isSafeInteger(pid) && pid > 0;
827
+ if (hasPid) {
828
+ const dead = pid !== process.pid && !processExists(pid);
829
+ return dead && age >= LOCK_MIN_STALE_MS ? contents : null;
830
+ }
831
+ return age > LOCK_UNOWNED_STALE_MS ? contents : null;
832
+ }
833
+ catch {
834
+ return null;
835
+ }
836
+ }
837
+ /** processExists probes a PID with signal 0 (EPERM still means it exists). */
838
+ function processExists(pid) {
839
+ try {
840
+ process.kill(pid, 0);
841
+ return true;
842
+ }
843
+ catch (error) {
844
+ return error.code === 'EPERM';
845
+ }
846
+ }
847
+ /**
848
+ * nextCounter allocates the next request counter: under the lock it re-reads
849
+ * the file, increments ctr and saves, and only then returns. The caller sends
850
+ * a request with the returned counter at most once (§2.6: a lost reply is
851
+ * never replayed with the same ctr; a retry allocates again).
852
+ *
853
+ * @param deviceId - FF1 device ID
854
+ * @param options - Optional directory override
855
+ * @returns The updated record and the counter to use
856
+ * @throws {Error} When the file is missing or corrupt
857
+ */
858
+ function nextCounter(deviceId, options = {}) {
859
+ const file = controllerFilePath(deviceId, options);
860
+ return withControllerLock(file, () => {
861
+ const record = loadController(deviceId, options);
862
+ if (!record) {
863
+ throw new Error(`No controller file for ${deviceId}; run \`ff-cli device pair\``);
864
+ }
865
+ if (!Number.isSafeInteger(record.ctr + 1)) {
866
+ throw new Error('Controller counter exhausted; run `ff-cli device unpair` and pair again');
867
+ }
868
+ record.ctr += 1;
869
+ saveController(record, options);
870
+ return { record, ctr: record.ctr };
871
+ });
872
+ }
873
+ /**
874
+ * skipCounterAhead moves the counter forward by `by` under the lock (§14.4
875
+ * replay recovery): a verified `replay` means the FF1's window is ahead of
876
+ * this file (a restored backup, a second copy of the keys), and the window
877
+ * has no upper bound, so jumping ahead is always safe.
878
+ *
879
+ * @param deviceId - FF1 device ID
880
+ * @param by - How far to move ctr
881
+ * @param options - Optional directory override
882
+ * @returns The updated record
883
+ * @throws {Error} When the file is missing or corrupt, or ctr would overflow
884
+ */
885
+ function skipCounterAhead(deviceId, by, options = {}) {
886
+ const file = controllerFilePath(deviceId, options);
887
+ return withControllerLock(file, () => {
888
+ const record = loadController(deviceId, options);
889
+ if (!record) {
890
+ throw new Error(`No controller file for ${deviceId}; run \`ff-cli device pair\``);
891
+ }
892
+ if (!Number.isSafeInteger(record.ctr + by + 1)) {
893
+ throw new Error('Controller counter exhausted; run `ff-cli device unpair` and pair again');
894
+ }
895
+ record.ctr += by;
896
+ saveController(record, options);
897
+ return record;
898
+ });
899
+ }
900
+ /**
901
+ * markEnrolled stores the grant from a successful enroll reply. It re-reads
902
+ * under the lock so a counter allocated concurrently is not rolled back. A
903
+ * grant without expiresAt clears any expiry the record had (§5.5: the grant
904
+ * states its whole state).
905
+ *
906
+ * @param deviceId - FF1 device ID
907
+ * @param grant - Role, nk and expiry from the reply
908
+ * @param options - Optional directory override
909
+ * @returns The updated record
910
+ */
911
+ function markEnrolled(deviceId, grant, options = {}) {
912
+ const file = controllerFilePath(deviceId, options);
913
+ return withControllerLock(file, () => {
914
+ const record = loadController(deviceId, options);
915
+ if (!record) {
916
+ throw new Error(`No controller file for ${deviceId}`);
917
+ }
918
+ record.role = grant.role;
919
+ record.nk = grant.nk;
920
+ record.enrolledAt = grant.now ?? Math.floor(Date.now() / 1000);
921
+ if (grant.expiresAt !== undefined) {
922
+ record.expiresAt = grant.expiresAt;
923
+ }
924
+ else {
925
+ delete record.expiresAt;
926
+ }
927
+ saveController(record, options);
928
+ return record;
929
+ });
930
+ }
931
+ /**
932
+ * recordRelayV2 stores whether the FF1's latest verified status advertises
933
+ * relay v2, under the controller lock so a concurrent counter allocation is
934
+ * not lost. A missing file (unpaired meanwhile) is left alone.
935
+ *
936
+ * @param deviceId - Device ID
937
+ * @param relayV2 - Whether `control.transports` contained "relayV2"
938
+ * @param options - Store options
939
+ */
940
+ function recordRelayV2(deviceId, relayV2, options = {}) {
941
+ const file = controllerFilePath(deviceId, options);
942
+ withControllerLock(file, () => {
943
+ const record = loadController(deviceId, options);
944
+ if (record && record.relayV2 !== relayV2) {
945
+ record.relayV2 = relayV2;
946
+ saveController(record, options);
947
+ }
948
+ });
949
+ }
950
+ /**
951
+ * describeController returns the non-secret facts of a record for display.
952
+ *
953
+ * @param record - Controller record
954
+ * @returns controllerId, pins and grant facts; never private material
955
+ */
956
+ function describeController(record) {
957
+ return {
958
+ controllerId: (0, codec_1.controllerIdOf)(record.keys),
959
+ gen: record.profile.gen,
960
+ sigPin: (0, jwk_1.thumbprint)(record.profile.sig),
961
+ encPin: (0, jwk_1.thumbprint)(record.profile.enc),
962
+ role: record.role,
963
+ };
964
+ }