@feralfile/cli 3.0.0 → 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 (45) hide show
  1. package/README.md +20 -17
  2. package/config.json.example +1 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +1 -1
  6. package/dist/src/commands/device.js +84 -39
  7. package/dist/src/commands/enrich.js +2 -2
  8. package/dist/src/commands/find.js +71 -37
  9. package/dist/src/commands/helpers/device-control.js +937 -0
  10. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  11. package/dist/src/commands/helpers/playlist-display.js +2 -15
  12. package/dist/src/commands/play.js +39 -26
  13. package/dist/src/commands/report.js +251 -0
  14. package/dist/src/commands/setup.js +1 -1
  15. package/dist/src/config.js +162 -6
  16. package/dist/src/utilities/control/codec.js +578 -0
  17. package/dist/src/utilities/control/control-errors.js +157 -0
  18. package/dist/src/utilities/control/controller-store.js +964 -0
  19. package/dist/src/utilities/control/jwk.js +141 -0
  20. package/dist/src/utilities/control/pairing-channel.js +52 -0
  21. package/dist/src/utilities/control/pairing.js +416 -0
  22. package/dist/src/utilities/control/registration.js +391 -0
  23. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  24. package/dist/src/utilities/control/secure-transport.js +850 -0
  25. package/dist/src/utilities/control/strict-json.js +376 -0
  26. package/dist/src/utilities/device-network.js +129 -0
  27. package/dist/src/utilities/device-upsert.js +1 -1
  28. package/dist/src/utilities/ff1-compatibility.js +87 -11
  29. package/dist/src/utilities/ff1-device.js +161 -78
  30. package/dist/src/utilities/ff1-relayer.js +34 -14
  31. package/dist/src/utilities/handoff-client.js +23 -9
  32. package/dist/src/utilities/neort-marketplace.js +3 -3
  33. package/dist/src/utilities/playlist-builder.js +3 -3
  34. package/dist/src/utilities/playlist-cast.js +6 -6
  35. package/dist/src/utilities/playlist-source.js +7 -7
  36. package/dist/src/utilities/raster-client.js +1 -1
  37. package/dist/src/utilities/relay-client.js +774 -0
  38. package/dist/src/utilities/ssh-access.js +192 -58
  39. package/dist/src/utilities/support-report.js +511 -0
  40. package/dist/src/utilities/temporary-channel.js +139 -0
  41. package/docs/CONFIGURATION.md +270 -28
  42. package/docs/EXAMPLES.md +31 -31
  43. package/docs/PROJECT_SPEC.md +32 -19
  44. package/docs/README.md +113 -36
  45. package/package.json +5 -2
@@ -0,0 +1,391 @@
1
+ "use strict";
2
+ /**
3
+ * Owner-initiated registration (ffos-user docs/ff1-control-v1.md §5.6), the
4
+ * controller side: the `ff1-ctl:1|…` controller QR ff-cli shows, and the
5
+ * sealed registration result an owner's app sends back over the pairing
6
+ * channel.
7
+ *
8
+ * The result is the pin source for the device profile (§14.1), so it is
9
+ * checked exactly like the Go reference (control/registration.go
10
+ * OpenRegistration) and nothing in it is trusted before every check passed:
11
+ * closed member sets, the channel and pairing secret from our own QR, `ctl`
12
+ * equal to our own key, and the configured device. A message that fails any
13
+ * check is not an answer; the caller keeps waiting for a genuine one.
14
+ *
15
+ * Cryptography is node:crypto (P-256 ECDH, HKDF-SHA256, AES-256-GCM). The
16
+ * fixtures in tests/fixtures/ff1-control-v1 pin every byte of it.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.RegistrationForOtherDeviceError = exports.REGISTRATION_SEALED_TYPE = exports.REGISTRATION_TYPE = exports.CONTROLLER_QR_PREFIX = void 0;
20
+ exports.isValidChannelId = isValidChannelId;
21
+ exports.generatePairingMaterial = generatePairingMaterial;
22
+ exports.controllerQrString = controllerQrString;
23
+ exports.controllerQrCtl = controllerQrCtl;
24
+ exports.parseControllerQr = parseControllerQr;
25
+ exports.sealRegistration = sealRegistration;
26
+ exports.openRegistration = openRegistration;
27
+ const node_crypto_1 = require("node:crypto");
28
+ const codec_1 = require("./codec");
29
+ const jwk_1 = require("./jwk");
30
+ const strict_json_1 = require("./strict-json");
31
+ /** CONTROLLER_QR_PREFIX starts every controller QR payload (§5.6.1). */
32
+ exports.CONTROLLER_QR_PREFIX = 'ff1-ctl:1|';
33
+ /** REGISTRATION_TYPE is the plaintext result's type. */
34
+ exports.REGISTRATION_TYPE = 'ff1_controller_registration';
35
+ /** REGISTRATION_SEALED_TYPE is the wire message's type. */
36
+ exports.REGISTRATION_SEALED_TYPE = 'ff1_controller_registration_sealed';
37
+ const REGISTRATION_LABEL = 'ff1-controller-registration/v1';
38
+ const PS_LEN = 16;
39
+ const GCM_IV_LEN = 12;
40
+ const GCM_TAG_LEN = 16;
41
+ const SEC1_POINT_LEN = 65;
42
+ const COORD_LEN = 32;
43
+ // The label is the last of the nine fields after the prefix and may itself
44
+ // contain "|", so the payload is split at most nine times.
45
+ const CONTROLLER_QR_FIELDS = 9;
46
+ const CHANNEL_ID_PATTERN = /^[A-Za-z0-9_-]{16,64}$/;
47
+ const MAX_LABEL_CHARS = 64;
48
+ /**
49
+ * PROTOCOL_UNSAFE_LABEL is exactly the device's label rule (Go
50
+ * control/store.go unsafeLabelCharacters). It judges labels and device names
51
+ * this module READS, so a genuine result is never refused over a character
52
+ * the device accepts; labels ff-cli sends use the stricter codec.labelProblem.
53
+ */
54
+ const PROTOCOL_UNSAFE_LABEL = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\u2028\u2029\ufeff]/u;
55
+ /**
56
+ * RegistrationForOtherDeviceError is a result that passed the channel,
57
+ * pairing-secret and ctl checks (so it came from someone holding this QR,
58
+ * normally the owner) but names another FF1 than the configured one.
59
+ */
60
+ class RegistrationForOtherDeviceError extends strict_json_1.ControlFormatError {
61
+ dev;
62
+ constructor(dev) {
63
+ super('registration result is for another device');
64
+ this.dev = dev;
65
+ this.name = 'RegistrationForOtherDeviceError';
66
+ }
67
+ }
68
+ exports.RegistrationForOtherDeviceError = RegistrationForOtherDeviceError;
69
+ /**
70
+ * isValidChannelId reports whether a pairing channel ID is valid for the
71
+ * controller QR (§5.6.1: 16-64 characters of [A-Za-z0-9_-]). The relay
72
+ * accepts 1-128, so clients check this themselves.
73
+ *
74
+ * @param channelId - Candidate channel ID
75
+ * @returns True when the QR may carry it
76
+ */
77
+ function isValidChannelId(channelId) {
78
+ return CHANNEL_ID_PATTERN.test(channelId);
79
+ }
80
+ /**
81
+ * isProtocolLabel is the §5 label rule as the device applies it: 1-64
82
+ * characters, none of the control or invisible formatting characters.
83
+ *
84
+ * @param value - Candidate label or device name
85
+ * @returns True when the device would accept it
86
+ */
87
+ function isProtocolLabel(value) {
88
+ const length = [...value].length;
89
+ return length >= 1 && length <= MAX_LABEL_CHARS && !PROTOCOL_UNSAFE_LABEL.test(value);
90
+ }
91
+ /** sec1Of renders a public JWK as b64u(0x04 || X || Y). */
92
+ function sec1Of(key) {
93
+ return (0, strict_json_1.b64uEncode)(Buffer.concat([
94
+ Buffer.from([4]),
95
+ Buffer.from((0, strict_json_1.b64uDecode)(key.x)),
96
+ Buffer.from((0, strict_json_1.b64uDecode)(key.y)),
97
+ ]));
98
+ }
99
+ /**
100
+ * parseSec1Point validates b64u of an uncompressed P-256 point; compressed
101
+ * and off-curve points are refused (parsePublicJwk runs the on-curve check).
102
+ */
103
+ function parseSec1Point(value, what) {
104
+ let bytes;
105
+ try {
106
+ bytes = (0, strict_json_1.b64uDecode)(value);
107
+ }
108
+ catch {
109
+ bytes = new Uint8Array();
110
+ }
111
+ if (bytes.length !== SEC1_POINT_LEN || bytes[0] !== 4) {
112
+ throw new strict_json_1.ControlFormatError(`${what} must be b64u of a 65-byte uncompressed P-256 point`);
113
+ }
114
+ return (0, jwk_1.parsePublicJwk)({
115
+ kty: 'EC',
116
+ crv: 'P-256',
117
+ x: (0, strict_json_1.b64uEncode)(bytes.subarray(1, 1 + COORD_LEN)),
118
+ y: (0, strict_json_1.b64uEncode)(bytes.subarray(1 + COORD_LEN)),
119
+ }, what);
120
+ }
121
+ /**
122
+ * generatePairingMaterial draws what one pairing attempt shows: a fresh
123
+ * minter key, a 16-byte pairing secret and a random channel ID, bound to the
124
+ * controller keys and label the owner will register.
125
+ *
126
+ * @param keys - Controller keys to register
127
+ * @param label - Label the owner sees (already validated)
128
+ * @param channelId - Pairing channel ID (default: a fresh random UUID)
129
+ * @returns The QR fields and the minter private key
130
+ */
131
+ function generatePairingMaterial(keys, label, channelId = (0, node_crypto_1.randomUUID)()) {
132
+ const minter = (0, jwk_1.generatePrivateJwk)();
133
+ return {
134
+ minter,
135
+ qr: {
136
+ channelId,
137
+ minterKey: sec1Of(minter),
138
+ sig: (0, jwk_1.publicOf)(keys.sig),
139
+ enc: (0, jwk_1.publicOf)(keys.enc),
140
+ ps: (0, strict_json_1.b64uEncode)((0, node_crypto_1.randomBytes)(PS_LEN)),
141
+ platform: 'cli',
142
+ label,
143
+ },
144
+ };
145
+ }
146
+ /**
147
+ * controllerQrString renders the QR payload.
148
+ *
149
+ * @param qr - QR fields
150
+ * @returns `ff1-ctl:1|channelId|minterKey|sig.x|sig.y|enc.x|enc.y|ps|platform|label`
151
+ */
152
+ function controllerQrString(qr) {
153
+ return (exports.CONTROLLER_QR_PREFIX +
154
+ [
155
+ qr.channelId,
156
+ qr.minterKey,
157
+ qr.sig.x,
158
+ qr.sig.y,
159
+ qr.enc.x,
160
+ qr.enc.y,
161
+ qr.ps,
162
+ qr.platform,
163
+ qr.label,
164
+ ].join('|'));
165
+ }
166
+ /**
167
+ * controllerQrCtl is the controller ID the QR's sig key has once registered.
168
+ *
169
+ * @param qr - QR fields
170
+ * @returns thumb(sig)
171
+ */
172
+ function controllerQrCtl(qr) {
173
+ return (0, jwk_1.thumbprint)(qr.sig);
174
+ }
175
+ /**
176
+ * parseControllerQr parses and validates a controller QR payload (§5.6.1).
177
+ *
178
+ * @param value - QR text
179
+ * @returns The QR fields
180
+ * @throws {ControlFormatError} On anything but a valid payload
181
+ */
182
+ function parseControllerQr(value) {
183
+ if (!value.startsWith(exports.CONTROLLER_QR_PREFIX)) {
184
+ throw new strict_json_1.ControlFormatError('not an ff1-ctl:1 payload');
185
+ }
186
+ const fields = [];
187
+ let rest = value.slice(exports.CONTROLLER_QR_PREFIX.length);
188
+ while (fields.length < CONTROLLER_QR_FIELDS - 1) {
189
+ const cut = rest.indexOf('|');
190
+ if (cut < 0) {
191
+ throw new strict_json_1.ControlFormatError('controller QR has too few fields');
192
+ }
193
+ fields.push(rest.slice(0, cut));
194
+ rest = rest.slice(cut + 1);
195
+ }
196
+ fields.push(rest);
197
+ const [channelId, minterKey, sigX, sigY, encX, encY, ps, platform, label] = fields;
198
+ if (!CHANNEL_ID_PATTERN.test(channelId)) {
199
+ throw new strict_json_1.ControlFormatError('controller QR channelId is invalid');
200
+ }
201
+ parseSec1Point(minterKey, 'controller QR minterKey');
202
+ const sig = (0, jwk_1.parsePublicJwk)({ kty: 'EC', crv: 'P-256', x: sigX, y: sigY }, 'controller QR sig');
203
+ const enc = (0, jwk_1.parsePublicJwk)({ kty: 'EC', crv: 'P-256', x: encX, y: encY }, 'controller QR enc');
204
+ let psBytes;
205
+ try {
206
+ psBytes = (0, strict_json_1.b64uDecode)(ps);
207
+ }
208
+ catch {
209
+ psBytes = new Uint8Array();
210
+ }
211
+ if (psBytes.length !== PS_LEN) {
212
+ throw new strict_json_1.ControlFormatError('controller QR ps must be b64u of 16 bytes');
213
+ }
214
+ if (!codec_1.CONTROL_PLATFORMS.includes(platform)) {
215
+ throw new strict_json_1.ControlFormatError('controller QR platform is invalid');
216
+ }
217
+ if (!isProtocolLabel(label)) {
218
+ throw new strict_json_1.ControlFormatError('controller QR label is invalid');
219
+ }
220
+ return {
221
+ channelId,
222
+ minterKey,
223
+ sig,
224
+ enc,
225
+ ps,
226
+ platform: platform,
227
+ label,
228
+ };
229
+ }
230
+ /** registrationKey derives the AES-256-GCM key and AAD of §5.6.2. */
231
+ function registrationKey(own, peer, channelId, minterKey, epk) {
232
+ const z = (0, node_crypto_1.diffieHellman)({
233
+ privateKey: (0, node_crypto_1.createPrivateKey)({ key: { ...own }, format: 'jwk' }),
234
+ publicKey: (0, node_crypto_1.createPublicKey)({ key: { ...(0, jwk_1.publicOf)(peer) }, format: 'jwk' }),
235
+ });
236
+ const salt = (0, node_crypto_1.createHash)('sha256')
237
+ .update(exports.CONTROLLER_QR_PREFIX + channelId, 'utf8')
238
+ .digest();
239
+ const key = Buffer.from((0, node_crypto_1.hkdfSync)('sha256', z, salt, REGISTRATION_LABEL, 32));
240
+ const aad = Buffer.from(`${REGISTRATION_LABEL}|${channelId}|${minterKey}|${epk}`, 'utf8');
241
+ return { key, aad };
242
+ }
243
+ /**
244
+ * sealRegistration is the owner side of §5.6.2, used by tests and as the
245
+ * reference for the app: encrypt an exact plaintext to the QR's minter key
246
+ * under the ephemeral key with iv (12 bytes, never reused for one epk).
247
+ *
248
+ * @param qr - The controller QR
249
+ * @param plaintext - Exact result JSON
250
+ * @param ephemeral - Owner's fresh P-256 key
251
+ * @param iv - 12-byte nonce
252
+ * @returns The wire message JSON
253
+ */
254
+ function sealRegistration(qr, plaintext, ephemeral = (0, jwk_1.generatePrivateJwk)(), iv = (0, node_crypto_1.randomBytes)(GCM_IV_LEN)) {
255
+ if (iv.length !== GCM_IV_LEN) {
256
+ throw new Error('registration iv must be 12 bytes');
257
+ }
258
+ const minter = parseSec1Point(qr.minterKey, 'minterKey');
259
+ const epk = sec1Of(ephemeral);
260
+ const { key, aad } = registrationKey(ephemeral, minter, qr.channelId, qr.minterKey, epk);
261
+ const cipher = (0, node_crypto_1.createCipheriv)('aes-256-gcm', key, iv);
262
+ cipher.setAAD(aad);
263
+ const ct = Buffer.concat([
264
+ cipher.update(typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext),
265
+ cipher.final(),
266
+ cipher.getAuthTag(),
267
+ ]);
268
+ return `{"v":1,"type":${JSON.stringify(exports.REGISTRATION_SEALED_TYPE)},"epk":${JSON.stringify(epk)},"iv":${JSON.stringify((0, strict_json_1.b64uEncode)(iv))},"ct":${JSON.stringify((0, strict_json_1.b64uEncode)(ct))}}`;
269
+ }
270
+ /**
271
+ * openRegistration is the controller side of §5.6.3: decrypt a sealed result
272
+ * with the minter key behind qr and check every binding, in the spec's order.
273
+ *
274
+ * @param material - The QR this controller showed and its minter key
275
+ * @param wire - The wire message (relay frame payload)
276
+ * @param wantDev - The configured device ID; profile.dev must equal it
277
+ * @param now - Unix seconds, bounding expiresAt
278
+ * @returns The verified result
279
+ * @throws {ControlFormatError} When the message is not a genuine result for this QR
280
+ */
281
+ function openRegistration(material, wire, wantDev, now) {
282
+ const { qr, minter } = material;
283
+ if (sec1Of(minter) !== qr.minterKey) {
284
+ throw new strict_json_1.ControlFormatError('minter key does not match the QR');
285
+ }
286
+ const sealed = (0, strict_json_1.strictObject)((0, strict_json_1.parseStrictJson)(wire), ['v', 'type', 'epk', 'iv', 'ct'], [], 'sealed registration');
287
+ if ((0, strict_json_1.uintMember)(sealed, 'v') !== 1 || sealed.type !== exports.REGISTRATION_SEALED_TYPE) {
288
+ throw new strict_json_1.ControlFormatError('sealed registration has the wrong v or type');
289
+ }
290
+ const epkB64 = (0, strict_json_1.stringMember)(sealed, 'epk');
291
+ const epk = parseSec1Point(epkB64, 'sealed registration epk');
292
+ const iv = (0, strict_json_1.b64uDecode)((0, strict_json_1.stringMember)(sealed, 'iv'));
293
+ if (iv.length !== GCM_IV_LEN) {
294
+ throw new strict_json_1.ControlFormatError('sealed registration iv must be 12 bytes');
295
+ }
296
+ const ct = (0, strict_json_1.b64uDecode)((0, strict_json_1.stringMember)(sealed, 'ct'));
297
+ if (ct.length < GCM_TAG_LEN) {
298
+ throw new strict_json_1.ControlFormatError('sealed registration does not decrypt');
299
+ }
300
+ const { key, aad } = registrationKey(minter, epk, qr.channelId, qr.minterKey, epkB64);
301
+ let plaintext;
302
+ try {
303
+ const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', key, iv);
304
+ decipher.setAAD(aad);
305
+ decipher.setAuthTag(ct.subarray(ct.length - GCM_TAG_LEN));
306
+ plaintext = Buffer.concat([
307
+ decipher.update(ct.subarray(0, ct.length - GCM_TAG_LEN)),
308
+ decipher.final(),
309
+ ]);
310
+ }
311
+ catch {
312
+ throw new strict_json_1.ControlFormatError('sealed registration does not decrypt');
313
+ }
314
+ return parseRegistrationResult(qr, plaintext, wantDev, now);
315
+ }
316
+ /** parseRegistrationResult checks the decrypted plaintext (§5.6.2, §5.6.3 steps 2-4). */
317
+ function parseRegistrationResult(qr, plaintext, wantDev, now) {
318
+ const base = ['v', 'type', 'channelId', 'ps', 'ctl', 'outcome'];
319
+ const obj = (0, strict_json_1.strictObject)((0, strict_json_1.parseStrictJson)(plaintext), base, ['profile', 'deviceName', 'role', 'expiresAt', 'topicId'], 'registration result');
320
+ if ((0, strict_json_1.uintMember)(obj, 'v') !== 1 || obj.type !== exports.REGISTRATION_TYPE) {
321
+ throw new strict_json_1.ControlFormatError('registration result has the wrong v or type');
322
+ }
323
+ if (obj.channelId !== qr.channelId) {
324
+ throw new strict_json_1.ControlFormatError('registration result is for another channel');
325
+ }
326
+ const ps = (0, strict_json_1.stringMember)(obj, 'ps');
327
+ const psBytes = Buffer.from(ps, 'utf8');
328
+ const wantPs = Buffer.from(qr.ps, 'utf8');
329
+ if (psBytes.length !== wantPs.length || !(0, node_crypto_1.timingSafeEqual)(psBytes, wantPs)) {
330
+ throw new strict_json_1.ControlFormatError('registration result ps does not match');
331
+ }
332
+ const ctl = controllerQrCtl(qr);
333
+ if (obj.ctl !== ctl) {
334
+ throw new strict_json_1.ControlFormatError('registration result is for another controller');
335
+ }
336
+ const outcome = (0, strict_json_1.stringMember)(obj, 'outcome');
337
+ const result = {
338
+ v: 1,
339
+ type: exports.REGISTRATION_TYPE,
340
+ channelId: qr.channelId,
341
+ ps,
342
+ ctl,
343
+ outcome: 'rejected',
344
+ };
345
+ if (outcome === 'rejected') {
346
+ if (Object.keys(obj).length !== base.length) {
347
+ throw new strict_json_1.ControlFormatError('a rejected registration carries no grant members');
348
+ }
349
+ return result;
350
+ }
351
+ if (outcome !== 'approved') {
352
+ throw new strict_json_1.ControlFormatError('registration result outcome is invalid');
353
+ }
354
+ for (const member of ['profile', 'deviceName', 'role']) {
355
+ if (!Object.prototype.hasOwnProperty.call(obj, member)) {
356
+ throw new strict_json_1.ControlFormatError(`an approved registration needs ${member}`);
357
+ }
358
+ }
359
+ if (!(0, strict_json_1.isPlainObject)(obj.profile) || (0, strict_json_1.uintMember)(obj.profile, 'v') !== 1) {
360
+ throw new strict_json_1.ControlFormatError('registration result profile is invalid');
361
+ }
362
+ const profile = (0, codec_1.parseProfile)(obj.profile);
363
+ if (profile.dev !== wantDev) {
364
+ throw new RegistrationForOtherDeviceError(profile.dev);
365
+ }
366
+ const deviceName = (0, strict_json_1.stringMember)(obj, 'deviceName');
367
+ if (!isProtocolLabel(deviceName)) {
368
+ throw new strict_json_1.ControlFormatError('registration result deviceName is invalid');
369
+ }
370
+ const role = (0, strict_json_1.stringMember)(obj, 'role');
371
+ if (role !== 'owner' && role !== 'controller') {
372
+ throw new strict_json_1.ControlFormatError('registration result role is invalid');
373
+ }
374
+ Object.assign(result, { outcome, profile, deviceName, role });
375
+ if (Object.prototype.hasOwnProperty.call(obj, 'expiresAt')) {
376
+ const expiresAt = (0, strict_json_1.uintMember)(obj, 'expiresAt');
377
+ // An owner grant never expires (§5.5), and an expiry in the past is no grant.
378
+ if (expiresAt <= now || role === 'owner') {
379
+ throw new strict_json_1.ControlFormatError('registration result expiresAt is invalid');
380
+ }
381
+ result.expiresAt = expiresAt;
382
+ }
383
+ if (Object.prototype.hasOwnProperty.call(obj, 'topicId')) {
384
+ const topicId = obj.topicId;
385
+ if (typeof topicId !== 'string' || topicId === '') {
386
+ throw new strict_json_1.ControlFormatError('registration result topicId must be a non-empty string');
387
+ }
388
+ result.topicId = topicId;
389
+ }
390
+ return result;
391
+ }
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ /**
3
+ * The relay v2 opener for the owner-initiated pairing channel
4
+ * (ffos-user docs/ff1-control-v1.md §5.6 step 1). It adapts TemporaryChannel
5
+ * (`/v2/channels/{channelId}/connections`) to the PairingChannel interface
6
+ * that `ff-cli device pair` consumes, and is installed at startup by
7
+ * installRelayPairingChannel when a v2 endpoint is configured.
8
+ *
9
+ * The channel carries no authority: every message is untrusted bytes that
10
+ * registration.ts opens and checks. This adapter only queues them.
11
+ *
12
+ * A relay drop ends the pairing wait (no reconnect: a new connection would
13
+ * get a new connection ID the owner's app does not know); `device pair` says
14
+ * the channel closed and the user shows a new code.
15
+ *
16
+ * Limit: TemporaryChannel does not reassemble `relayChunk` transfers, so a
17
+ * sealed registration result must fit one relay frame (about 32 KiB of
18
+ * payload). Results are about 1 KiB; a chunked one would arrive as fragments
19
+ * that fail to open and are ignored like any other stray message.
20
+ */
21
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ var desc = Object.getOwnPropertyDescriptor(m, k);
24
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
25
+ desc = { enumerable: true, get: function() { return m[k]; } };
26
+ }
27
+ Object.defineProperty(o, k2, desc);
28
+ }) : (function(o, m, k, k2) {
29
+ if (k2 === undefined) k2 = k;
30
+ o[k2] = m[k];
31
+ }));
32
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
33
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
34
+ }) : function(o, v) {
35
+ o["default"] = v;
36
+ });
37
+ var __importStar = (this && this.__importStar) || (function () {
38
+ var ownKeys = function(o) {
39
+ ownKeys = Object.getOwnPropertyNames || function (o) {
40
+ var ar = [];
41
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
42
+ return ar;
43
+ };
44
+ return ownKeys(o);
45
+ };
46
+ return function (mod) {
47
+ if (mod && mod.__esModule) return mod;
48
+ var result = {};
49
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
50
+ __setModuleDefault(result, mod);
51
+ return result;
52
+ };
53
+ })();
54
+ Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.relayPairingChannelOpener = relayPairingChannelOpener;
56
+ exports.installRelayPairingChannel = installRelayPairingChannel;
57
+ const config_1 = require("../../config");
58
+ const logger = __importStar(require("../../logger"));
59
+ const temporary_channel_1 = require("../temporary-channel");
60
+ const pairing_1 = require("./pairing");
61
+ const pairing_channel_1 = require("./pairing-channel");
62
+ const registration_1 = require("./registration");
63
+ /**
64
+ * PAIRING_CHANNEL_TIMEOUT_MS is TemporaryChannel's own deadline. It only has
65
+ * to outlast the longest `device pair --timeout`; the caller's AbortSignal
66
+ * is what actually ends the wait.
67
+ */
68
+ const PAIRING_CHANNEL_TIMEOUT_MS = pairing_1.MAX_PAIR_TIMEOUT_MS + 60_000;
69
+ /**
70
+ * MAX_QUEUED_MESSAGES bounds unread messages. Anyone who saw the QR can write
71
+ * to the channel; the pairing loop drains each message at once, so only a
72
+ * flood fills the queue. Then NEW messages are dropped: evicting the oldest
73
+ * would let a flood push out an owner's result that is already queued.
74
+ */
75
+ const MAX_QUEUED_MESSAGES = 64;
76
+ /**
77
+ * MessageQueue is a single-consumer async iterable of channel payloads that
78
+ * ends when the channel closes.
79
+ */
80
+ class MessageQueue {
81
+ items = [];
82
+ waiter;
83
+ ended = false;
84
+ /** push queues one payload, or hands it to a waiting reader. */
85
+ push(item) {
86
+ if (this.ended) {
87
+ return;
88
+ }
89
+ if (this.waiter) {
90
+ const waiter = this.waiter;
91
+ this.waiter = undefined;
92
+ waiter({ value: item, done: false });
93
+ return;
94
+ }
95
+ if (this.items.length < MAX_QUEUED_MESSAGES) {
96
+ this.items.push(item);
97
+ }
98
+ }
99
+ /** end finishes the iteration once queued payloads are read. */
100
+ end() {
101
+ this.ended = true;
102
+ if (this.waiter && !this.items.length) {
103
+ const waiter = this.waiter;
104
+ this.waiter = undefined;
105
+ waiter({ value: undefined, done: true });
106
+ }
107
+ }
108
+ [Symbol.asyncIterator]() {
109
+ return {
110
+ next: () => {
111
+ const item = this.items.shift();
112
+ if (item) {
113
+ return Promise.resolve({ value: item, done: false });
114
+ }
115
+ if (this.ended) {
116
+ return Promise.resolve({ value: undefined, done: true });
117
+ }
118
+ return new Promise((resolve) => {
119
+ this.waiter = resolve;
120
+ });
121
+ },
122
+ };
123
+ }
124
+ }
125
+ /**
126
+ * relayPairingChannelOpener returns an opener that joins the pairing channel
127
+ * on the relay v2 endpoint.
128
+ *
129
+ * The payload handler is attached before `ready` is awaited, so a result
130
+ * sent right after the relay's hello is not lost. Aborting the signal closes
131
+ * the channel, which ends the message iteration.
132
+ *
133
+ * @param endpoint - Relay v2 origin
134
+ * @param socketFactory - WebSocket constructor seam for tests
135
+ * @returns The opener for setPairingChannelOpener
136
+ */
137
+ function relayPairingChannelOpener(endpoint, socketFactory) {
138
+ return async (channelId, signal) => {
139
+ if (!(0, registration_1.isValidChannelId)(channelId)) {
140
+ throw new Error('invalid pairing channel ID');
141
+ }
142
+ signal.throwIfAborted();
143
+ const channel = new temporary_channel_1.TemporaryChannel(endpoint, channelId, PAIRING_CHANNEL_TIMEOUT_MS, socketFactory);
144
+ const queue = new MessageQueue();
145
+ // The frame payload is a view into the relay frame; keep a private copy.
146
+ channel.onPayload = (_from, payload) => queue.push(payload.slice());
147
+ channel.onClose = () => queue.end();
148
+ const onAbort = () => channel.close(new Error('Pairing cancelled'));
149
+ signal.addEventListener('abort', onAbort, { once: true });
150
+ try {
151
+ await channel.ready;
152
+ }
153
+ catch (error) {
154
+ signal.removeEventListener('abort', onAbort);
155
+ throw error;
156
+ }
157
+ return pairingChannel(channel, queue, () => signal.removeEventListener('abort', onAbort));
158
+ };
159
+ }
160
+ /** pairingChannel exposes a joined TemporaryChannel as a receive-only PairingChannel. */
161
+ function pairingChannel(channel, queue, detach) {
162
+ return {
163
+ channelId: channel.channelId,
164
+ messages: () => queue,
165
+ close: async () => {
166
+ detach();
167
+ channel.close();
168
+ },
169
+ };
170
+ }
171
+ /**
172
+ * installRelayPairingChannel installs the relay opener when a v2 endpoint is
173
+ * configured (ff1Relayer.v2Endpoint, FF1_RELAY_V2_ENDPOINT, or the default
174
+ * derived from the relayer). Without one, or with an invalid relayer config,
175
+ * `device pair` keeps the default opener, which says pairing is unavailable.
176
+ *
177
+ * @param getConfig - Relayer config source (tests inject one)
178
+ */
179
+ function installRelayPairingChannel(getConfig = config_1.getFF1RelayerConfig) {
180
+ let endpoint;
181
+ try {
182
+ endpoint = getConfig().v2Endpoint;
183
+ }
184
+ catch (error) {
185
+ logger.debug(`Relay pairing channel not installed: ${error.message}`);
186
+ return;
187
+ }
188
+ if (endpoint) {
189
+ (0, pairing_channel_1.setPairingChannelOpener)(relayPairingChannelOpener(endpoint));
190
+ }
191
+ }