@byokit/pair 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.10.0 (2026-10-12)
6
+
7
+ - DOCS: the "Checking reachability before pairing" section now shows both sides: setup self-checks its own advertised URLs with `check()` before printing the tick, and the phone re-checks the scanned offer before consent. The probe still spends nothing, so a checked offer pairs afterwards.
8
+ - ADD: `ndjson(stream)` sends and receives whole JSON messages over a pair stream, one per `\n`-terminated line, through the existing credit window. Chunks split at any byte boundary reassemble before decoding, a line past `NDJSON_MAX_LINE` (1 MiB, configurable per stream) ends the stream with `line-too-long` on both ends, and a non-JSON line ends it with `failed`.
9
+ - FIX: every wait in the link end-to-end suite is bounded: each test carries a one-minute deadline, so a request stalled past its reconnect window (twice it parked the whole full-suite run at 0% CPU until killed) fails by its test name instead of hanging every lane and release behind the shared test lock. The file still passes in about 10 s alone; nothing about the protocol changed.
10
+
11
+ ## 0.9.0 (2026-10-09)
12
+
13
+ - FIX: a phone killed while the person at the computer decides now resumes against the pinned computer, for compact and version 1 QRs alike. `pairWithOffer` takes `onPending(grant)`, which hands over the pending grant to keep before the computer can approve this device, and `pendingGrant` accepts a compact QR given the authenticated `host` key, and `parseOffer` reads a compact QR for inspection (expiry, addresses, name, role) without one. Before, `pendingGrant` refused compact QRs, and even a version 1 pending grant never came online after a real kill. A socket that drops once the computer has taken the code now ends pairing with `unreachable` instead of trying the next address, where the spent code or ticket could only be refused as `wrong-code` or `expired`. A socket that closes while `onPending` is still saving ends pairing at once with that close; a version 1 `onPending` that never settles times out. The compact QR is unchanged: 109 characters for one relay address and the name Umer.
14
+ - SECURITY: pinning and approval semantics. A compact QR's pending grant pins the key the code handshake authenticated, kept after Noise message 2 and before the device's identity goes out in message 3; if saving it fails, pairing stops before the computer learns the device. A device using `onPending` says so inside the handshake, and only then does the computer keep a yes given after its socket dropped, granting only the device key that handshake authenticated; any other device that leaves still gets no grant. A computer with another key at the same address is still refused, and a version 1 `pendingGrant` refuses a `host` that differs from its QR.
15
+ - `parseOffer` now returns `PairOffer | CompactOffer`, so a compact QR parses for inspection. Code that reads `.host` or `.ticket`, or passes the result to `offerText` or `encodeOffer`, narrows with `'host' in offer`, or calls the new `parseV1Offer` for the old version 1-only type.
16
+ - FIX: the pairing picture's words match what the host now prints: scan the code, or type the shown code where the phone page asks for it, with no raw address to type.
17
+ - FIX: the browser pairing test closes only the Chromium processes it spawned — over that browser's own private CDP pipe — instead of signalling a whole process group, so a killed or timed-out test can no longer take out or leave behind the wrong process tree. `BYOKIT_CHROME` is the single documented key that selects the test browser.
18
+
5
19
  ## 0.8.0 (2026-10-07)
6
20
 
7
21
  - Add redacted startup, page/module and link-stage diagnostics to the browser pairing test; the intermittent CI timeout's root cause remains unknown.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
- <h1 align="center">@byokit/link</h1>
1
+ <h1 align="center">@byokit/pair</h1>
2
2
 
3
3
  <p align="center">
4
- <a href="https://www.npmjs.com/package/@byokit/link"><img alt="npm" src="https://img.shields.io/npm/v/@byokit/link?style=flat&label=npm" /></a>
4
+ <a href="https://www.npmjs.com/package/@byokit/pair"><img alt="npm" src="https://img.shields.io/npm/v/@byokit/pair?style=flat&label=npm" /></a>
5
5
  <a href="https://github.com/umeranjum17/byokit/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/umeranjum17/byokit/ci.yml?style=flat&branch=main" /></a>
6
6
  <a href="LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
7
7
  <img alt="Node | browsers | React Native" src="https://img.shields.io/badge/platform-Node%20%7C%20browsers%20%7C%20React%20Native-666?style=flat" />
@@ -12,8 +12,11 @@ One encrypted link between the two. The computer (the <strong>host</strong>) kee
12
12
  only its own key and a grant, and asks the host to do things. For apps that reach a person's own computer from their
13
13
  phone or browser.</p>
14
14
 
15
+ This package was published as `@byokit/link` up to 0.8.x. `@byokit/link` still re-exports it unchanged through
16
+ 0.8.x and is removed in 0.9.0; new installs use `@byokit/pair`.
17
+
15
18
  <p align="center">
16
- <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/herdr-kit-host.png" width="420" alt="A terminal running npm start -- --herdr &quot;$(command -v herdr)&quot; --via lan --name 'Kitchen computer', showing a large pairing QR code, then: On the phone, scan this. If its page is already open there, type K3J8-CJZ7-SE4R instead. Codes last five minutes. Press Enter for new ones. Connecting to Herdr… Connected to Herdr. (stand-in Herdr)" />
19
+ <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/herdr-kit-host.png" width="420" alt="A terminal running npm start -- --herdr &quot;$(command -v herdr)&quot; --via lan --name 'Kitchen computer', showing a large pairing QR code, then: On the phone, scan this, or open http://192.168.1.144:7310/ and type K3J8-CJZ7-SE4R. Codes last five minutes. Press Enter for new ones. Connecting to Herdr… Connected to Herdr. (stand-in Herdr)" />
17
20
  <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/examples/herdr-kit/docs/2-compare.png" width="240" alt="A phone page titled Agents, under Pair this phone: Check your computer shows these two words, then say yes there. The words are coast comet." />
18
21
  </p>
19
22
  <p align="center"><sub>Left: the host terminal of <a href="https://github.com/umeranjum17/byokit/tree/main/examples/herdr-kit">examples/herdr-kit</a> (<code>npm start -- --herdr "$(command -v herdr)" --via lan --name 'Kitchen computer'</code>, pictured against the kit's stand-in Herdr, <code>BYOKIT_EXAMPLE_FAKE=1</code>) showing the QR and the typed code. Right: the phone in the same example showing the two words to compare.</sub></p>
@@ -27,26 +30,26 @@ Host policy and streams are available; relay routing is in [`@byokit/relay`](../
27
30
  ## Install
28
31
 
29
32
  ```sh
30
- npm install @byokit/link
33
+ npm install @byokit/pair
31
34
  ```
32
35
 
33
- [![npm](https://img.shields.io/npm/v/@byokit/link?style=flat&label=)](https://www.npmjs.com/package/@byokit/link) · [Latest release](https://github.com/umeranjum17/byokit/releases?q=link-v) · [All releases](https://github.com/umeranjum17/byokit/releases)
36
+ [![npm](https://img.shields.io/npm/v/@byokit/pair?style=flat&label=)](https://www.npmjs.com/package/@byokit/pair) · [Latest release](https://github.com/umeranjum17/byokit/releases?q=pair-v) · [All releases](https://github.com/umeranjum17/byokit/releases)
34
37
 
35
38
  ## Quickstart
36
39
 
37
40
  ```sh
38
- npm install @byokit/link ws
41
+ npm install @byokit/pair ws
39
42
  ```
40
43
 
41
44
  `ws` is only the WebSocket server for this example; any server works. A host and a device in one file, on loopback:
42
45
 
43
46
  ```ts
44
47
  import { WebSocketServer } from 'ws';
45
- import { DeviceLink, Host, keyPair, pairWithOffer } from '@byokit/link';
48
+ import { DeviceLink, Host, keyPair, pairWithOffer } from '@byokit/pair';
46
49
 
47
50
  // The computer: it keeps the credentials and answers requests.
48
51
  const host = await Host.open({
49
- keys: keyPair(), // keep it with hostKeyFile() from '@byokit/link/node' in a real app
52
+ keys: keyPair(), // keep it with hostKeyFile() from '@byokit/pair/node' in a real app
50
53
  name: 'Kitchen computer',
51
54
  confirm: ({ name, words }) => { console.log(`host: pair ${name}? it shows "${words}"`); return true; },
52
55
  handle: (req, device) => ({ echo: req.args, from: device.name }),
@@ -89,13 +92,13 @@ answer: { echo: { text: 'hi' }, from: 'Pixel 9' }
89
92
  | `LinkError`, `PublicLinkError`, `LINK_WORDS` | Failures with a plain `message` and a `code`; a handler error safe to show; the plain message for each failure code |
90
93
  | `secureDeviceStore`, `browserDeviceStore` | Where a device keeps its grant on a phone or in a browser |
91
94
  | `keyPair`, `keyPairFrom`, `hostId`, `b64url`, `unb64url`, `normalizeCode` | Keys, the host id, base64url and typed-code helpers |
92
- | `hostKeyFile`, `fileDeviceStore` (`@byokit/link/node`) | Node only: the host's key file, and a computer's device store |
95
+ | `hostKeyFile`, `fileDeviceStore` (`@byokit/pair/node`) | Node only: the host's key file, and a computer's device store |
93
96
 
94
97
  ## Host
95
98
 
96
99
  ```ts
97
- import { Host } from '@byokit/link';
98
- import { hostKeyFile } from '@byokit/link/node';
100
+ import { Host } from '@byokit/pair';
101
+ import { hostKeyFile } from '@byokit/pair/node';
99
102
 
100
103
  const host = await Host.open({
101
104
  keys: hostKeyFile('./link-secret/host.key'), // made once in its own 0700 folder; or use your keychain
@@ -112,7 +115,7 @@ const { code } = host.code({ role: 'view' }); // or a code to type: "7KQ4-M2
112
115
  host.devices(); await host.revoke(id); host.broadcast(event);
113
116
  ```
114
117
 
115
- `hostKeyFile(path)` from `@byokit/link/node` wants a path in its own private (`0700`) folder; it refuses an
118
+ `hostKeyFile(path)` from `@byokit/pair/node` wants a path in its own private (`0700`) folder; it refuses an
116
119
  existing folder with another mode rather than changing its permissions.
117
120
 
118
121
  More host policy, all optional:
@@ -127,6 +130,9 @@ More host policy, all optional:
127
130
  handlers with transactional effects can store it alongside the effect to avoid repeating work after a crash between
128
131
  the handler and `answers.put`.
129
132
  - `handshakes: { perMinute, perPeer }` limits new handshakes; pass `host.accept(ws, { peer: ip })` to count per source.
133
+ - `onGrantRemoved(grant, why)` runs at the one boundary where a grant is durably gone — `revoked`, `expired` or
134
+ `changed` — so an app can delete its copy. Access is already withdrawn when it runs, a socket drop does not
135
+ trigger it, and a failing cleanup never restores access.
130
136
 
131
137
  `compactOffer` is the QR text: the same secret a typed code carries (same single use, life, words and
132
138
  approval) plus the addresses to try, packed binary, so a standard offer fits a QR version 4 or lower.
@@ -152,11 +158,13 @@ const { text } = host.offer({
152
158
  console.log(text); // terminal-to-browser deep link; the offer stays after #
153
159
  ```
154
160
 
155
- `offerText(parseOffer(text), 'https://app.example/pair.html')` makes the same deep link from an existing offer.
161
+ `offerText(parseV1Offer(text), 'https://app.example/pair.html')` makes the same deep link from an existing version 1 offer.
156
162
  There is no separate `offerLink`. Use `parseOffer(text, 0)` to inspect an expired offer; this does not renew it,
157
- and pairing still refuses an expired ticket.
163
+ and pairing still refuses an expired ticket. `parseOffer` reads a compact QR too, returning a `CompactOffer` (expiry,
164
+ addresses, name, role) with no `host` or `ticket`: narrow with `'host' in offer` before reading a version 1 offer's
165
+ fields. `parseV1Offer` is the old version 1-only parse, for `offerText` and `encodeOffer`, which take a `PairOffer`.
158
166
 
159
- For an offline, typeable alternative, `encodeOffer(parseOffer(text))` holds the **entire offer**, including
167
+ For an offline, typeable alternative, `encodeOffer(parseV1Offer(text))` holds the **entire offer**, including
160
168
  all direct and relay addresses. `decodeOffer` also reads old compact direct codes for migration, preserving
161
169
  their key, ticket, role and second-resolution expiry; re-encoding uses the current complete format.
162
170
  `decodeOffer(typed)` returns a `PairOffer` and checks expiry without contacting
@@ -184,7 +192,7 @@ old connections, and changed expiry reschedules access. A failed load rejects an
184
192
  A `GrantStore` may provide `subscribe(changed): unsubscribe`; the host reloads automatically on notification,
185
193
  reports reload failures through `onError`, and unsubscribes on `close()`.
186
194
 
187
- Node apps can use `fileGrantStore(path)` from `@byokit/link/node`. It touches the app's chosen file, temporary
195
+ Node apps can use `fileGrantStore(path)` from `@byokit/pair/node`. It touches the app's chosen file, temporary
188
196
  files and a sibling `.lock` only, writes atomically at 0600 in a 0700 folder, rejects symlinks/nonprivate files on read, and polls
189
197
  for cross-process replace/remove (default 100 ms; second argument changes the interval). Cooperating writers
190
198
  use an exclusive lock and reject a stale loaded snapshot instead of overwriting an external revoke. Load again
@@ -205,7 +213,7 @@ The **handle** step authorizes the already-paired inviter, **offer** records the
205
213
  then **confirm** checks that metadata before approving the new peer:
206
214
 
207
215
  ```ts
208
- import { Host, keyPair, PublicLinkError } from '@byokit/link';
216
+ import { Host, keyPair, PublicLinkError } from '@byokit/pair';
209
217
 
210
218
  // Supply the app's human approval UI.
211
219
  declare const ui: { ask(message: string): Promise<boolean> };
@@ -233,7 +241,7 @@ const host: Host = await Host.open({
233
241
  });
234
242
  ```
235
243
 
236
- Import `Host`, `keyPair` and `PublicLinkError` from `@byokit/link`; `ui.ask` is the app's approval UI. The app
244
+ Import `Host`, `keyPair` and `PublicLinkError` from `@byokit/pair`; `ui.ask` is the app's approval UI. The app
237
245
  chooses metadata on the host, never from an untrusted invitee. This composition is delegated pairing:
238
246
  the approved control device requests a constrained invitation; a peer cannot widen its terms. Check the
239
247
  inviter's current grant, including expiry, in both `handle` and `confirm`. Apply capability scope in `allow`
@@ -245,7 +253,7 @@ long-lived or host-offline signed invitation.
245
253
  ## Device
246
254
 
247
255
  To keep several paired computers, use `secureDeviceStores(secureStore, prefix?)` or
248
- `browserDeviceStores(databaseName?)` from `@byokit/link`. Both offer `list()` (entry names), `load(name)`,
256
+ `browserDeviceStores(databaseName?)` from `@byokit/pair`. Both offer `list()` (entry names), `load(name)`,
249
257
  `save(name, grant)`, `remove(name)`, and `store(name)` (the one-entry adapter for `DeviceLink`). Names use
250
258
  letters, digits, dots, underscores and dashes, up to 120 characters. A secure collection uses
251
259
  `<prefix>.index` and `<prefix>.grant.<name>`; existing single-entry stores stay independent. Calls sharing
@@ -255,7 +263,7 @@ The browser collection enumerates existing `browserDeviceStore` entries in its d
255
263
  non-extractable wrapping keys and forgotten-grant protection; removed tombstones stay out of `list()`.
256
264
 
257
265
  ```ts
258
- import { DeviceLink, secureDeviceStores, type DeviceGrant, type SecureStoreLike } from '@byokit/link';
266
+ import { DeviceLink, secureDeviceStores, type DeviceGrant, type SecureStoreLike } from '@byokit/pair';
259
267
 
260
268
  // Supply the platform secure-storage module and a grant returned by pairing.
261
269
  declare const SecureStore: SecureStoreLike;
@@ -269,7 +277,7 @@ await computers.remove('kitchen');
269
277
  ```
270
278
 
271
279
  ```ts
272
- import { DeviceLink, pairWithOffer, pairWithCode } from '@byokit/link';
280
+ import { DeviceLink, pairWithOffer, pairWithCode } from '@byokit/pair';
273
281
 
274
282
  const grant = await pairWithOffer(scanned, { name: 'Pixel 9', onWords: (w) => show(w) });
275
283
  // Or pairWithCode(url, typed, { name: 'Pixel 9', onWords: (w) => show(w) });
@@ -279,10 +287,17 @@ await link.request('send.message', { text: 'hi' }); // waits through reconnects
279
287
  await link.request('send.message', { text: 'hi' }, { timeoutMs: 20_000, notValidAfter: Date.now() + 60_000 });
280
288
  ```
281
289
 
282
- - For crash-safe pairing, make a `keyPair()`, save `pendingGrant(scanned, { name, key })` before pairing, then pass
283
- that same `key` to `pairWithOffer(scanned, { name, key, onWords })`. If the app dies while the person decides,
284
- a `DeviceLink` made from the saved pending grant retries until approval (up to five minutes after the offer expires),
285
- then forgets it if the host still has not approved.
290
+ - For crash-safe pairing, pass `onPending: (g) => secureStore.save(g)` to `pairWithOffer`. It gets the pending grant
291
+ before the host can approve this device: for a compact offer, once the host proved it holds the code and before
292
+ this device's key and name reach it (so the grant pins the host key the handshake authenticated); for a version 1
293
+ offer, before dialling. If the app dies while the person decides, a `DeviceLink` made from the saved pending grant
294
+ retries until approval (up to five minutes after the offer expires), then forgets it if the host still has not
295
+ approved; a host with another key there is `refused`. Because the device tells the host it kept a pending grant, a yes
296
+ given after it went away still counts; a device that kept none gets no grant it could never use. So when
297
+ `pairWithOffer` rejects after `onPending` ran without a sealed refusal (`e.sealed` false, e.g. `unreachable` once the
298
+ computer has taken the code), keep the pending grant and make a `DeviceLink` from it: it comes online on a yes and forgets
299
+ itself on a no.
300
+ `pendingGrant(scanned, { name, key, host })` builds the same grant; a compact offer needs `host`.
286
301
  - `resolve: (url) => …` runs before each dial (e.g. open an SSH tunnel and return `ws://127.0.0.1:<port>/…`);
287
302
  `link.addUrl(url)` adds an address found later (a wrong host there just fails its handshake).
288
303
  - A quiet connection is pinged (`pingMs`, default 20 s) and redialled when the host stops answering; at most
@@ -308,7 +323,7 @@ One store per paired computer, each with `load()` for the next start:
308
323
  |---|---|---|
309
324
  | Phone | `secureDeviceStore(SecureStore, 'byokit.link.home')` with `expo-secure-store` | Keychain on iOS, Keystore on Android; the grant is about 300 bytes, one value. |
310
325
  | Browser or PWA | `browserDeviceStore('home')` | IndexedDB, sealed with AES-GCM by a non-extractable browser key. The stored record alone opens nothing, but scripts running on the same origin can still use the key to decrypt it; keep untrusted scripts off the page. |
311
- | Computer (Node, Electron's main process) | `fileDeviceStore(path, safeStorage?)` from `@byokit/link/node` | A 0600 file in a newly created 0700 folder (an existing folder keeps its permissions), sealed with Electron's `safeStorage` when given. Without `safeStorage`, the file contains the grant in plaintext. |
326
+ | Computer (Node, Electron's main process) | `fileDeviceStore(path, safeStorage?)` from `@byokit/pair/node` | A 0600 file in a newly created 0700 folder (an existing folder keeps its permissions), sealed with Electron's `safeStorage` when given. Without `safeStorage`, the file contains the grant in plaintext. |
312
327
 
313
328
  ```ts
314
329
  const store = secureDeviceStore(SecureStore, 'byokit.link.home');
@@ -320,7 +335,7 @@ const link = new DeviceLink(grant, { store, onStatus });
320
335
  ### React Native
321
336
 
322
337
  Install a `crypto.getRandomValues` polyfill such as `react-native-get-random-values` (or use `expo-crypto`) and
323
- import it **before** `@byokit/link`. Metro resolves `sodium-universal` to `sodium-javascript` through its browser
338
+ import it **before** `@byokit/pair`. Metro resolves `sodium-universal` to `sodium-javascript` through its browser
324
339
  field. The device uses the platform's WebSocket and needs no Node globals. See [`examples/expo`](../../examples/expo).
325
340
 
326
341
  ## Streams
@@ -348,6 +363,13 @@ s.end();
348
363
  View-only devices open only the streams `allow` permits (or `canView` when `allow` is absent). A host without `stream`
349
364
  tells devices so (`LinkError` `not-supported`), and so does a host older than streams.
350
365
 
366
+ For whole JSON messages instead of bytes, `ndjson(stream)` sends one object per `\n`-terminated line and delivers
367
+ each complete message in order: `const j = ndjson(s); await j.send({ text: 'hi' }); j.onMessage((m) => …)`.
368
+ Messages split at any byte boundary arrive whole, including a multi-byte character split across two chunks, and a
369
+ returned handler promise holds the sender back through the same credit window. A line past `NDJSON_MAX_LINE`
370
+ (1 MiB, `ndjson(s, { maxLine })` changes it) ends the stream with `'line-too-long'` on both ends and is never
371
+ delivered, so nothing is buffered without bound; a line that is not JSON ends it with `failed`.
372
+
351
373
  On a direct socket, stream bytes go as binary WebSocket messages (without base64 overhead); through a relay, whose
352
374
  host wrapper is text, the same frames go as base64 text. The host says which in `ready`; everything else stays text.
353
375
 
@@ -367,7 +389,7 @@ Frames are the same bytes on every path; a relay only routes them and cannot rea
367
389
  - The relay authenticates the host's socket and limits abuse its own way. Link routing needs no device id: the host
368
390
  checks devices itself on every handshake.
369
391
 
370
- `@byokit/link` does not include a relay server; use [`@byokit/relay`](../relay) for the standalone server,
392
+ `@byokit/pair` does not include a relay server; use [`@byokit/relay`](../relay) for the standalone server,
371
393
  reconnecting host client and device code lookup.
372
394
 
373
395
  ## Migrating pre-kit phone pairings
@@ -406,10 +428,10 @@ share one key across machines. Muxr parity beyond this migration is tracked sepa
406
428
  ## Checking reachability before pairing
407
429
 
408
430
  ```ts
409
- import { check, parseOffer } from '@byokit/link';
431
+ import { check, parseOffer } from '@byokit/pair';
410
432
 
411
- // Before asking the person to approve, the phone probes the offer's URLs (the host probes its own advertised
412
- // URLs the same way, as a self-check only). Results come back in input order.
433
+ // Phone: before asking the person to approve, probe the scanned offer's URLs.
434
+ // Results come back in input order.
413
435
  export async function precheck(scanned: string): Promise<void> {
414
436
  const { urls } = parseOffer(scanned);
415
437
  const results = await check(urls, { timeoutMs: 5000 });
@@ -419,6 +441,22 @@ export async function precheck(scanned: string): Promise<void> {
419
441
  }
420
442
  ```
421
443
 
444
+ Setup runs the same probe on its own advertised URLs before printing the tick, so a dead
445
+ address never becomes a QR. The phone runs it again after scanning, before consent:
446
+
447
+ ```ts
448
+ import { check } from '@byokit/pair';
449
+
450
+ // Setup: self-check the offer's URLs first; only print the tick when one answers.
451
+ export async function selfcheck(urls: string[]): Promise<boolean> {
452
+ const results = await check(urls, { timeoutMs: 5000 });
453
+ for (const r of results) {
454
+ if (!r.ok) console.log(`${r.url}: ${r.message}`);
455
+ }
456
+ return results.some((r) => r.ok);
457
+ }
458
+ ```
459
+
422
460
  Probed four at a time by default (`concurrency` changes that). The probe answers read-only: it sends no ticket,
423
461
  so a checked offer still pairs afterwards, and the reply carries nothing but the fact a link host is there. It
424
462
  works in Node, browsers and React Native, and through a relay with no relay change (devices speak bare frames
@@ -470,13 +508,13 @@ untrusted address, never a trusted machine key. A malicious lookup pointing at a
470
508
  fails the commitment check. QR offers and legacy `host.code()` remain compatible; callers of
471
509
  the latter do not get this additional machine-key pinning.
472
510
 
473
- Runnable loopback example (Node 22.18+, `npm install @byokit/link ws`; save as `pair.ts`, run
511
+ Runnable loopback example (Node 22.18+, `npm install @byokit/pair ws`; save as `pair.ts`, run
474
512
  `node pair.ts`). In an app, show the code on the computer and let the phone type it:
475
513
 
476
514
  ```ts
477
515
  import { createInterface } from 'node:readline/promises';
478
516
  import { WebSocketServer } from 'ws';
479
- import { DeviceLink, Host, keyPair, pairWithCode } from '@byokit/link';
517
+ import { DeviceLink, Host, keyPair, pairWithCode } from '@byokit/pair';
480
518
 
481
519
  const terminal = createInterface({ input: process.stdin, output: process.stdout });
482
520
  const host = await Host.open({
package/SECURITY.md CHANGED
@@ -1,4 +1,4 @@
1
- # @byokit/link: threat model and review checklist
1
+ # @byokit/pair: threat model and review checklist
2
2
 
3
3
  Muxr parity is tracked separately.
4
4
 
@@ -27,7 +27,7 @@ A home computer (the **host**) holds AI sign-ins and other credentials. Phones,
27
27
  | QR / pairing link | `byokit-link:1:<base64url JSON>`: `{v, host (X25519 public key), name, urls, ticket (128 bits), expires, role?, lifetime?}`. The optional role and access lifetime let the device show the terms; older parsers ignore them. As a link, it rides after `#`, which browsers never send to a server. |
28
28
  | Offline offer envelope | Versioned base32 of the complete validated offer plus a 32-bit FNV-1a transcription checksum. Case, whitespace, dashes and O/0, I/L/1 aliases are normalized; noncanonical padding is refused. The checksum is not authentication. The same QR ticket, host key, expiry and approval rules apply; relay addresses and grant terms are preserved. |
29
29
  | Typed code | 12 characters from a 31-character unambiguous alphabet (about 59 bits), `XXXX-XXXX-XXXX`. |
30
- | Pairing rules | Tickets and codes are single use (burned at first presentation, even when expired or refused), live 5 minutes through grant creation, and 5 wrong tries withdraw every open ticket and code. Nothing is stored until the person at the host approves; both screens show the same **two confirmation words**, derived from the handshake hash. Optional device cap. |
30
+ | Pairing rules | Tickets and codes are single use (burned at first presentation, even when expired or refused), live 5 minutes through grant creation, and 5 wrong tries withdraw every open ticket and code. Nothing is stored until the person at the host approves; both screens show the same **two confirmation words**, derived from the handshake hash. Optional device cap. An approval holds after the device's socket dropped only when the device said, inside the handshake (`pending: 1` in its sealed `pair` request or typed-code message 3), that it kept a pending grant; the grant names only the device key that handshake authenticated. Any other device that leaves gets no grant, so no slot is held by a key nobody has. A compact QR carries no host key; the device keeps the key the code handshake authenticated (after message 2, before its identity goes out in message 3) as its pending grant's pin, so a resumed link is pinned exactly as from a version 1 QR. |
31
31
  | Grants | Held by the host with a device key and role; optional `kind`, `expires`, `nextKey` and `meta` support caps, expiry, rekey and app policy. Every handshake and request checks the grant. `allow` decides access when supplied; otherwise control is allowed and view-only uses `canView` (default: none). |
32
32
  | Presence and reload | Presence counts authenticated live sockets per grant and fires only on first/last. Store notifications trigger a serialized `Host.reload` with no write-back; removed/key-changed/role-changed grants close live sockets, and changed expiry reschedules access. A failed read preserves the last known authority and reports the failure: notifications are eventual, so an app needing instantaneous external-authority checks must enforce them in `allow`. The Node file backend uses a cooperating-writer lock and rejects stale snapshots; it polls only the app's chosen path and stops when the host closes. |
33
33
  | Ready metadata | Only the opt-in `deviceMeta(grant)` projection is sent to the device, inside the sealed `ready`. Host grant metadata is private by default. The app must not project credentials. |
@@ -115,9 +115,9 @@ A home computer (the **host**) holds AI sign-ins and other credentials. Phones,
115
115
  - [x] Host policy is enforced before the app handler; without `allow`, view-only defaults closed. Only an explicit `PublicLinkError` discloses its chosen message; other handler failures are logged on the host and return plain `failed`.
116
116
  - [x] A device forgets its grant only on a sealed `revoked`, `ended` or `not-paired` (or when a pending pairing expires).
117
117
  - [x] Names shown to people are stripped of control and direction-flipping characters and capped at 60.
118
- - [x] `parseOffer` accepts only `ws:`/`wss:` addresses without credentials, at most 8, a 32-byte key and 16-byte
118
+ - [x] `parseV1Offer` accepts only `ws:`/`wss:` addresses without credentials, at most 8, a 32-byte key and 16-byte
119
119
  ticket.
120
- - [x] Offline offer decoding checks the checksum, version, canonical padding, bounds and all `parseOffer` rules, including expiry; inspecting with `now = 0` never renews a ticket. Legacy compact offers are accepted only on the migration read path, with their original pinned key, ticket, role and second-resolution expiry; they cannot change new encodings.
120
+ - [x] Offline offer decoding checks the checksum, version, canonical padding, bounds and all `parseV1Offer` rules, including expiry; inspecting with `now = 0` never renews a ticket. Legacy compact offers are accepted only on the migration read path, with their original pinned key, ticket, role and second-resolution expiry; they cannot change new encodings.
121
121
  - [x] The channel refuses frames past 2³² − 1 and messages over 16 MB.
122
122
  - [x] Stream opens re-check the grant and policy before the app's `stream` handler; data past a stream's window,
123
123
  or stream data that isn't a binary inner message, drops the socket; streams end on disconnect and revoke.
@@ -132,7 +132,7 @@ A home computer (the **host**) holds AI sign-ins and other credentials. Phones,
132
132
  authenticates with it.
133
133
  - [x] The handshake limiter runs before any Diffie-Hellman.
134
134
  - [x] Nothing in the main entry reads or writes files, environment variables or other programs.
135
- `@byokit/link/node` `hostKeyFile(path)` touches only that path: 0600 in 0700, atomic, never replaces a file it
135
+ `@byokit/pair/node` `hostKeyFile(path)` touches only that path: 0600 in 0700, atomic, never replaces a file it
136
136
  can't read, refuses symlinks.
137
137
 
138
138
  - [ ] Presence fires once on first/last sockets, including failed/successful unpair, reload, expiry and close.
package/dist/check.d.ts CHANGED
@@ -28,7 +28,7 @@ export type CheckOptions = {
28
28
  onerror: unknown;
29
29
  };
30
30
  };
31
- /** Probe each URL for a link host, in input order. Only `unreachable` (nothing there), `timeout` (something there
32
- * that never answered) and `wrong-host` (something else answered) can come back, each with its LINK_WORDS sentence;
33
- * no new codes were needed. Never sends a ticket, so offers stay usable. */
31
+ /** Probe each URL for a link host, in input order. Only `unreachable` (nothing there: same network or firewall),
32
+ * `slow` (something there that never answered: wait and try again) and `wrong-host` (something else answered) can
33
+ * come back, each with its LINK_WORDS sentence. Never sends a ticket, so offers stay usable. */
34
34
  export declare function check(urls: string[], o?: CheckOptions): Promise<CheckResult[]>;
package/dist/check.js CHANGED
@@ -24,7 +24,7 @@ function probe(url, o, timeoutMs) {
24
24
  catch { }
25
25
  resolve(r);
26
26
  };
27
- const timer = setTimeout(() => finish(fail(url, opened ? 'timeout' : 'unreachable')), timeoutMs);
27
+ const timer = setTimeout(() => finish(fail(url, opened ? 'slow' : 'unreachable')), timeoutMs);
28
28
  timer.unref?.();
29
29
  try {
30
30
  const WS = o.WebSocket ?? globalThis.WebSocket;
@@ -55,9 +55,9 @@ function probe(url, o, timeoutMs) {
55
55
  };
56
56
  });
57
57
  }
58
- /** Probe each URL for a link host, in input order. Only `unreachable` (nothing there), `timeout` (something there
59
- * that never answered) and `wrong-host` (something else answered) can come back, each with its LINK_WORDS sentence;
60
- * no new codes were needed. Never sends a ticket, so offers stay usable. */
58
+ /** Probe each URL for a link host, in input order. Only `unreachable` (nothing there: same network or firewall),
59
+ * `slow` (something there that never answered: wait and try again) and `wrong-host` (something else answered) can
60
+ * come back, each with its LINK_WORDS sentence. Never sends a ticket, so offers stay usable. */
61
61
  export async function check(urls, o = {}) {
62
62
  const timeoutMs = o.timeoutMs ?? 8000;
63
63
  const out = new Array(urls.length);
package/dist/device.d.ts CHANGED
@@ -41,7 +41,7 @@ export type Dial = {
41
41
  };
42
42
  /** Every way the link can fail, in words a person can act on. */
43
43
  export declare const LINK_WORDS: {
44
- readonly unreachable: "Can't reach your computer. Check it's on and connected.";
44
+ readonly unreachable: "Can't reach your computer. Check the phone and computer are on the same network, and that no firewall is blocking the connection.";
45
45
  readonly 'wrong-host': "Something other than your computer answered, so this device didn't connect.";
46
46
  readonly 'wrong-code': "That code didn't match. Check it, or show a new one on your computer.";
47
47
  readonly expired: "That pairing code has run out. Show a new one on your computer.";
@@ -51,6 +51,7 @@ export declare const LINK_WORDS: {
51
51
  readonly removed: "This device was removed on your computer.";
52
52
  readonly 'view-only': "This device can watch but not make changes.";
53
53
  readonly timeout: "Your computer didn't answer in time.";
54
+ readonly slow: "Your computer is slow to answer. Wait a moment and try again.";
54
55
  readonly failed: "Your computer couldn't do that.";
55
56
  readonly busy: "Your computer is still catching up. Try again in a moment.";
56
57
  readonly stopped: "This link is stopped. Try connecting again.";
@@ -78,15 +79,24 @@ type PairOptions = Dial & {
78
79
  };
79
80
  /** The grant a scanned code will become, to keep in secure storage *before* pairing. If the app dies while the person
80
81
  * at the host decides, a `DeviceLink` made from it later connects once the host has said yes (or learns it said no),
81
- * instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer`. */
82
+ * instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer`, or let
83
+ * `pairWithOffer`'s `onPending` hand it over. A compact offer carries no host key: it needs `host`, the key the code
84
+ * handshake authenticated (`onPending` passes it). For a version 1 offer, a different `host` is refused. */
82
85
  export declare function pendingGrant(scanned: string, o: {
83
86
  name: string;
84
87
  key?: KeyPair;
88
+ host?: string;
85
89
  }): DeviceGrant;
90
+ type OfferOptions = PairOptions & {
91
+ /** Keep this pending grant before the host can approve this device (see `pendingGrant`). A compact offer calls it
92
+ * once the host proved it holds the code, before this device's key and name reach it; a throw stops pairing there. */
93
+ onPending?: (g: DeviceGrant) => void | Promise<void>;
94
+ };
86
95
  /** A scanned QR (or opened pairing link) in, a grant out, once the person at the host says yes. `onWords` gets the
87
96
  * two words to show while they decide. Tries each address in the code until one answers. A compact offer pairs
88
- * like a typed code over the packed addresses: same single use, life, words and approval, no host key pinned. */
89
- export declare function pairWithOffer(scanned: string, o: PairOptions): Promise<DeviceGrant>;
97
+ * like a typed code over the packed addresses: same single use, life, words and approval; the host key it pins is
98
+ * the one the code handshake authenticated, handed to `onPending` before the host learns this device. */
99
+ export declare function pairWithOffer(scanned: string, o: OfferOptions): Promise<DeviceGrant>;
90
100
  /** A typed code in, a grant out. `Host.shortCode` also pins the machine key before sharing the device identity.
91
101
  * `url` is an untrusted address, e.g. from relay `findHost`; only the full code authenticates the host. */
92
102
  export declare function pairWithCode(url: string, typed: string, o: PairOptions): Promise<DeviceGrant>;
package/dist/device.js CHANGED
@@ -3,11 +3,11 @@
3
3
  // its requests survive a reconnect: each carries a key, so a retried tap runs once. Uses only the platform's
4
4
  // WebSocket, so the same code runs in browsers, React Native and Node.
5
5
  import { Handshake, b64, b64url, keyPair, keyPairFrom, random, unb64url } from "./channel.js";
6
- import { cleanName, codeKey, decodeCompactOffer, parseCode, parseOffer, shortKey, COMPACT_TAG } from "./pairing.js";
6
+ import { cleanName, codeKey, decodeCompactOffer, parseCode, parseOffer, parseV1Offer, shortKey, COMPACT_TAG } from "./pairing.js";
7
7
  import { Streams } from "./stream.js";
8
8
  /** Every way the link can fail, in words a person can act on. */
9
9
  export const LINK_WORDS = {
10
- unreachable: "Can't reach your computer. Check it's on and connected.",
10
+ unreachable: "Can't reach your computer. Check the phone and computer are on the same network, and that no firewall is blocking the connection.",
11
11
  'wrong-host': "Something other than your computer answered, so this device didn't connect.",
12
12
  'wrong-code': "That code didn't match. Check it, or show a new one on your computer.",
13
13
  expired: 'That pairing code has run out. Show a new one on your computer.',
@@ -17,6 +17,7 @@ export const LINK_WORDS = {
17
17
  removed: 'This device was removed on your computer.',
18
18
  'view-only': 'This device can watch but not make changes.',
19
19
  timeout: "Your computer didn't answer in time.",
20
+ slow: 'Your computer is slow to answer. Wait a moment and try again.',
20
21
  failed: "Your computer couldn't do that.",
21
22
  busy: 'Your computer is still catching up. Try again in a moment.',
22
23
  stopped: 'This link is stopped. Try connecting again.',
@@ -43,6 +44,10 @@ export class PublicLinkError extends LinkError {
43
44
  const known = (why) => typeof why === 'string' && Object.hasOwn(LINK_WORDS, why);
44
45
  const problem = (why) => (known(why) ? why : 'unreachable');
45
46
  const later = (ms, fn) => { const t = setTimeout(fn, ms); t.unref?.(); return t; };
47
+ const within = (ms, work) => new Promise((resolve, reject) => {
48
+ const t = later(ms, () => reject(new LinkError('timeout')));
49
+ work.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
50
+ });
46
51
  /** One socket: the handshake, the first request (`auth` or `pair`), and the host's `ready`. */
47
52
  async function dial(url, me, host, hello, o, on = { message: () => { }, close: () => { } }) {
48
53
  let target = url;
@@ -75,7 +80,8 @@ async function dial(url, me, host, hello, o, on = { message: () => { }, close: (
75
80
  }
76
81
  catch { }
77
82
  };
78
- let timer = later(o.timeoutMs ?? 8000, () => fail(new LinkError('timeout')));
83
+ let opened = false;
84
+ let timer = later(o.timeoutMs ?? 8000, () => fail(new LinkError(opened ? 'slow' : 'unreachable')));
79
85
  try {
80
86
  ws = new WS(target);
81
87
  ws.binaryType = 'arraybuffer';
@@ -88,7 +94,12 @@ async function dial(url, me, host, hello, o, on = { message: () => { }, close: (
88
94
  ws.send(f); };
89
95
  const data = (s, d) => { for (const f of ch.sealData(s, d))
90
96
  ws.send(binary ? f : b64(f)); };
91
- ws.onopen = () => ws.send(hs.write(mode === 'ik' ? { v: 1 } : {}));
97
+ ws.onopen = () => { opened = true; try {
98
+ ws.send(hs.write(mode === 'ik' ? { v: 1 } : {}));
99
+ }
100
+ catch {
101
+ fail(new LinkError('unreachable'));
102
+ } };
92
103
  ws.onerror = () => fail(new LinkError('unreachable'));
93
104
  // Before the handshake finishes nothing is authenticated, so these only choose what to say, never what to forget.
94
105
  ws.onclose = (e) => fail(new LinkError(ch ? 'unreachable' : e?.code === 4401 && mode === 'code' ? 'wrong-code' : e?.code === 4403 ? 'wrong-host' : 'unreachable'));
@@ -107,16 +118,33 @@ async function dial(url, me, host, hello, o, on = { message: () => { }, close: (
107
118
  } // only the right host can answer
108
119
  if (host.commitment && shortKey(hs.remoteKey) !== host.commitment)
109
120
  return fail(new LinkError('wrong-host'));
110
- if (hello.t === 'code')
111
- ws.send(hs.write({ name: hello.name }));
112
- ch = hs.channel();
113
- if (hello.t !== 'code')
114
- send(hello);
115
- if (pairing) { // now a person at the host decides; give them time
116
- o.onWords?.(hs.words);
117
- clearTimeout(timer);
118
- timer = later(6 * 60_000, () => fail(new LinkError('timeout')));
119
- }
121
+ const go = () => {
122
+ if (hello.t === 'code')
123
+ ws.send(hs.write({ name: hello.name, ...(hello.pending ? { pending: 1 } : {}) }));
124
+ ch = hs.channel();
125
+ if (hello.t !== 'code')
126
+ send(hello);
127
+ if (pairing) { // now a person at the host decides; give them time
128
+ o.onWords?.(hs.words);
129
+ clearTimeout(timer);
130
+ timer = later(6 * 60_000, () => fail(new LinkError('slow')));
131
+ }
132
+ };
133
+ if (!host.pin)
134
+ return go();
135
+ // The host proved it holds the code; the app keeps its key before this device is disclosed in message 3. A save
136
+ // that never settles times out, or ends sooner when the host drops the socket.
137
+ clearTimeout(timer);
138
+ within(o.timeoutMs ?? 8000, Promise.resolve().then(() => host.pin(hs.remoteKey))).then(() => {
139
+ if (settled)
140
+ return;
141
+ try {
142
+ go();
143
+ }
144
+ catch {
145
+ fail(new LinkError('unreachable'));
146
+ }
147
+ }, fail);
120
148
  return;
121
149
  }
122
150
  const m = ch.open(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data)); // throws unless it is the host's next authentic frame
@@ -146,47 +174,72 @@ function granted(me, hostKey, url, urls, ready) {
146
174
  }
147
175
  /** The grant a scanned code will become, to keep in secure storage *before* pairing. If the app dies while the person
148
176
  * at the host decides, a `DeviceLink` made from it later connects once the host has said yes (or learns it said no),
149
- * instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer`. */
177
+ * instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer`, or let
178
+ * `pairWithOffer`'s `onPending` hand it over. A compact offer carries no host key: it needs `host`, the key the code
179
+ * handshake authenticated (`onPending` passes it). For a version 1 offer, a different `host` is refused. */
150
180
  export function pendingGrant(scanned, o) {
151
181
  const offer = parseOffer(scanned);
182
+ let host = 'host' in offer ? offer.host : undefined;
183
+ if (o.host !== undefined) {
184
+ let given;
185
+ try {
186
+ given = unb64url(o.host);
187
+ }
188
+ catch {
189
+ throw new LinkError('wrong-host');
190
+ }
191
+ if (given.length !== 32 || (host !== undefined && host !== b64url(given)))
192
+ throw new LinkError('wrong-host');
193
+ host = b64url(given);
194
+ }
195
+ if (host === undefined)
196
+ throw new Error("A compact pairing code doesn't carry the computer's key: use pairWithOffer's onPending.");
152
197
  const me = o.key ?? keyPair();
153
- return { v: 1, secretKey: b64url(me.secretKey), pendingUntil: offer.expires + 300_000, host: offer.host, hostName: offer.name, urls: offer.urls,
198
+ return { v: 1, secretKey: b64url(me.secretKey), pendingUntil: offer.expires + 300_000, host, hostName: offer.name, urls: offer.urls,
154
199
  device: { id: '', name: cleanName(o.name, 'Device'), role: offer.role ?? 'view' } };
155
200
  }
156
201
  /** A scanned QR (or opened pairing link) in, a grant out, once the person at the host says yes. `onWords` gets the
157
202
  * two words to show while they decide. Tries each address in the code until one answers. A compact offer pairs
158
- * like a typed code over the packed addresses: same single use, life, words and approval, no host key pinned. */
203
+ * like a typed code over the packed addresses: same single use, life, words and approval; the host key it pins is
204
+ * the one the code handshake authenticated, handed to `onPending` before the host learns this device. */
159
205
  export async function pairWithOffer(scanned, o) {
206
+ let taken = false; // the host has the code (it read the ticket or the code's first message): no other address can answer
207
+ const words = { ...o, onWords: (w) => { taken = true; o.onWords(w); } };
160
208
  if (scanned.indexOf(COMPACT_TAG) >= 0) {
161
209
  const compact = decodeCompactOffer(scanned);
162
210
  const me = o.key ?? keyPair();
211
+ const pin = o.onPending && ((key) => { taken = true; return o.onPending(pendingGrant(scanned, { name: o.name, key: me, host: b64url(key) })); });
163
212
  let last = new LinkError('unreachable');
164
213
  for (const url of compact.urls) {
165
214
  try {
166
- const l = await dial(url, me, { psk: codeKey(compact.code) }, { t: 'code', name: o.name }, o);
215
+ const l = await dial(url, me, { psk: codeKey(compact.code), pin }, { t: 'code', name: o.name, ...(pin ? { pending: 1 } : {}) }, words);
167
216
  l.close();
168
217
  return granted(me, b64url(l.hostKey), url, compact.urls, l.ready);
169
218
  }
170
219
  catch (e) {
171
- last = e instanceof LinkError ? e : new LinkError('unreachable');
172
- if (last.code !== 'unreachable' && last.code !== 'timeout' && last.code !== 'wrong-host')
220
+ if (!(e instanceof LinkError))
221
+ throw e; // the app's own onPending failure, as it said it
222
+ last = e;
223
+ if (taken || (last.code !== 'unreachable' && last.code !== 'slow' && last.code !== 'timeout' && last.code !== 'wrong-host'))
173
224
  break;
174
225
  }
175
226
  }
176
227
  throw last;
177
228
  }
178
- const offer = parseOffer(scanned);
229
+ const offer = parseV1Offer(scanned);
179
230
  const me = o.key ?? keyPair();
231
+ if (o.onPending)
232
+ await within(o.timeoutMs ?? 8000, Promise.resolve(o.onPending(pendingGrant(scanned, { name: o.name, key: me }))));
180
233
  let last = new LinkError('unreachable');
181
234
  for (const url of offer.urls) {
182
235
  try {
183
- const l = await dial(url, me, { key: unb64url(offer.host) }, { t: 'pair', ticket: offer.ticket, name: o.name }, o);
236
+ const l = await dial(url, me, { key: unb64url(offer.host) }, { t: 'pair', ticket: offer.ticket, name: o.name, ...(o.onPending ? { pending: 1 } : {}) }, words);
184
237
  l.close();
185
238
  return granted(me, offer.host, url, offer.urls, l.ready);
186
239
  }
187
240
  catch (e) {
188
241
  last = e instanceof LinkError ? e : new LinkError('unreachable');
189
- if (last.code !== 'unreachable' && last.code !== 'timeout' && last.code !== 'wrong-host')
242
+ if (taken || (last.code !== 'unreachable' && last.code !== 'slow' && last.code !== 'timeout' && last.code !== 'wrong-host'))
190
243
  break;
191
244
  }
192
245
  }
package/dist/host.js CHANGED
@@ -428,7 +428,7 @@ export class Host {
428
428
  const end = (c, why) => { if (!gone)
429
429
  conn.close(c, why); };
430
430
  const refuse = (why) => { this.sealed(conn, ch, { t: 'refused', why }); later(1000, () => end(4401, why)); };
431
- const pair = async (key, name, p, how) => {
431
+ const pair = async (key, name, p, how, kept) => {
432
432
  const k = b64url(key);
433
433
  busy = true;
434
434
  clearTimeout(timer);
@@ -438,10 +438,15 @@ export class Host {
438
438
  Promise.resolve().then(() => this.opts.confirm(req)).catch(() => false),
439
439
  new Promise((r) => later(this.pairMs, () => r(false))),
440
440
  ]);
441
- if (gone)
441
+ // A device that said it kept a pending grant resumes from it after being killed while the person decided, so
442
+ // its yes outlives the socket; any other gone device could never use the grant, so it gets none.
443
+ if (gone && kept !== 1)
444
+ return;
445
+ if (!yes) {
446
+ if (!gone)
447
+ refuse('declined');
442
448
  return;
443
- if (!yes)
444
- return refuse('declined');
449
+ }
445
450
  try {
446
451
  const added = await this.grant(k, req.name, p, p.expires);
447
452
  if (gone)
@@ -581,7 +586,7 @@ export class Host {
581
586
  }
582
587
  const hello = hs.read(text); // the typed-code handshake's last message: the device's key and name
583
588
  ch = hs.channel();
584
- void pair(hs.remoteKey, hello.name, code, 'code');
589
+ void pair(hs.remoteKey, hello.name, code, 'code', hello.pending);
585
590
  };
586
591
  const inner = (d, m) => {
587
592
  if (m.t === 'req')
@@ -674,7 +679,7 @@ export class Host {
674
679
  this.wrong();
675
680
  return refuse('expired');
676
681
  }
677
- return void pair(hs.remoteKey, m.name, p, 'scan');
682
+ return void pair(hs.remoteKey, m.name, p, 'scan', m.pending);
678
683
  }
679
684
  end(4400, 'unexpected');
680
685
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { hostId, keyPair, keyPairFrom, b64url, unb64url, type KeyPair } from './channel.ts';
2
2
  export { Host, type AnswerStore, type Grant, type GrantStore, type GrantTerms, type HostOptions, type LinkRequest, type PairRequest, type Role, type Socket, } from './host.ts';
3
3
  export { DeviceLink, LINK_WORDS, LinkError, PublicLinkError, pairWithCode, pairWithOffer, pendingGrant, type DeviceGrant, type DeviceStore, type Dial, type LinkOptions, type LinkProblem, type LinkStatus, type RequestOptions, } from './device.ts';
4
- export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, COMPACT_TAG } from './pairing.ts';
5
- export { LinkStream, WINDOW } from './stream.ts';
4
+ export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, parseV1Offer, COMPACT_TAG } from './pairing.ts';
5
+ export { LinkStream, NDJSON_LINE_TOO_LONG, NDJSON_MAX_LINE, WINDOW, ndjson, type NdjsonOptions, type NdjsonStream } from './stream.ts';
6
6
  export { check, LINK_PROBE, LINK_PROBE_OK, type CheckOptions, type CheckResult } from './check.ts';
7
7
  export type { CompactOffer, PairOffer } from './pairing.ts';
8
8
  export { browserDeviceStore, browserDeviceStores, secureDeviceStore, secureDeviceStores, type DeviceStores, type KeptDevice, type SecureStoreLike } from './stores.ts';
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  export { hostId, keyPair, keyPairFrom, b64url, unb64url } from "./channel.js";
2
2
  export { Host, } from "./host.js";
3
3
  export { DeviceLink, LINK_WORDS, LinkError, PublicLinkError, pairWithCode, pairWithOffer, pendingGrant, } from "./device.js";
4
- export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, COMPACT_TAG } from "./pairing.js";
5
- export { LinkStream, WINDOW } from "./stream.js";
4
+ export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, parseV1Offer, COMPACT_TAG } from "./pairing.js";
5
+ export { LinkStream, NDJSON_LINE_TOO_LONG, NDJSON_MAX_LINE, WINDOW, ndjson } from "./stream.js";
6
6
  export { check, LINK_PROBE, LINK_PROBE_OK } from "./check.js";
7
7
  export { browserDeviceStore, browserDeviceStores, secureDeviceStore, secureDeviceStores } from "./stores.js";
8
8
  export { migrateGrant, GrantMigrationError } from "./migrate.js";
package/dist/pairing.d.ts CHANGED
@@ -18,14 +18,17 @@ export declare const cleanName: (name: unknown, fallback: string) => string;
18
18
  * the part after `#`, which browsers never send to a server. */
19
19
  export declare function offerText(offer: PairOffer, base?: string): string;
20
20
  /** Scan (or pasted link) in, offer out; throws a plain sentence for anything that is not a live byokit pairing code.
21
+ * A compact offer comes back without `host` or `ticket` (it has neither: `pairWithOffer` learns the host key).
21
22
  * Pass `0` as `now` to inspect an expired offer without connecting. */
22
- export declare function parseOffer(scanned: string, now?: number): PairOffer;
23
+ export declare function parseOffer(scanned: string, now?: number): PairOffer | CompactOffer;
24
+ /** A version 1 offer only; `parseOffer` for anything else. */
25
+ export declare function parseV1Offer(scanned: string, now?: number): PairOffer;
23
26
  /** A complete offline offer in groups of five base32 characters, with a transcription checksum. This carries the
24
27
  * same single-use ticket as the QR; it neither extends expiry nor replaces host approval. */
25
28
  export declare function encodeOffer(offer: PairOffer): string;
26
29
  /** Read an offline offer without network access. Case, spaces and dashes are ignored; O means 0 and I/L mean 1.
27
30
  * Old compact direct offers remain readable for migration; new offers always use the current format.
28
- * Other typos fail the checksum. Expiry and addresses follow `parseOffer`; `now = 0` permits inspection only. */
31
+ * Other typos fail the checksum. Expiry and addresses follow `parseV1Offer`; `now = 0` permits inspection only. */
29
32
  export declare function decodeOffer(text: string, now?: number): PairOffer;
30
33
  /** Typed codes use letters and numbers nobody mixes up (no 0/O, 1/I/L). Twelve of them are about 59 bits: too many
31
34
  * to guess, even offline, in the few minutes a code lives. */
package/dist/pairing.js CHANGED
@@ -12,8 +12,13 @@ export function offerText(offer, base) {
12
12
  return base ? `${base}#${body}` : body;
13
13
  }
14
14
  /** Scan (or pasted link) in, offer out; throws a plain sentence for anything that is not a live byokit pairing code.
15
+ * A compact offer comes back without `host` or `ticket` (it has neither: `pairWithOffer` learns the host key).
15
16
  * Pass `0` as `now` to inspect an expired offer without connecting. */
16
17
  export function parseOffer(scanned, now = Date.now()) {
18
+ return scanned.indexOf(COMPACT_TAG) >= 0 ? decodeCompactOffer(scanned, now) : parseV1Offer(scanned, now);
19
+ }
20
+ /** A version 1 offer only; `parseOffer` for anything else. */
21
+ export function parseV1Offer(scanned, now = Date.now()) {
17
22
  const at = scanned.indexOf(TAG);
18
23
  let o;
19
24
  try {
@@ -63,7 +68,7 @@ function checksum(bytes) {
63
68
  /** A complete offline offer in groups of five base32 characters, with a transcription checksum. This carries the
64
69
  * same single-use ticket as the QR; it neither extends expiry nor replaces host approval. */
65
70
  export function encodeOffer(offer) {
66
- const checked = parseOffer(offerText(offer), 0);
71
+ const checked = parseV1Offer(offerText(offer), 0);
67
72
  const body = b4a.from(JSON.stringify(checked));
68
73
  const bytes = new Uint8Array(1 + body.length + 4);
69
74
  bytes[0] = 1;
@@ -84,7 +89,7 @@ export function encodeOffer(offer) {
84
89
  }
85
90
  /** Read an offline offer without network access. Case, spaces and dashes are ignored; O means 0 and I/L mean 1.
86
91
  * Old compact direct offers remain readable for migration; new offers always use the current format.
87
- * Other typos fail the checksum. Expiry and addresses follow `parseOffer`; `now = 0` permits inspection only. */
92
+ * Other typos fail the checksum. Expiry and addresses follow `parseV1Offer`; `now = 0` permits inspection only. */
88
93
  export function decodeOffer(text, now = Date.now()) {
89
94
  if (text.length > MAX_TYPED_OFFER)
90
95
  throw badOffer();
@@ -111,7 +116,7 @@ export function decodeOffer(text, now = Date.now()) {
111
116
  const b = Uint8Array.from(bytes);
112
117
  if (b.length < 6 || b[0] !== 1 || checksum(b.subarray(0, -4)) !== new DataView(b.buffer).getUint32(b.length - 4))
113
118
  throw badOffer();
114
- return parseOffer(TAG + b64url(b.subarray(1, -4)), now);
119
+ return parseV1Offer(TAG + b64url(b.subarray(1, -4)), now);
115
120
  }
116
121
  // Migration read path only. The old direct envelope used seconds, a role byte and no name/lifetime.
117
122
  function decodeLegacyOffer(s, now) {
@@ -169,7 +174,7 @@ function decodeLegacyOffer(s, now) {
169
174
  }
170
175
  if (at !== end || urls.some((url) => !url.startsWith('ws:') || !wsUrl(url)))
171
176
  throw badOffer();
172
- return parseOffer(offerText({
177
+ return parseV1Offer(offerText({
173
178
  v: 1, host: b64url(b.subarray(2, 34)), ticket: b64url(b.subarray(34, 50)),
174
179
  expires: view.getUint32(50) * 1000, name: 'your computer', role: b[1] ? 'control' : 'view', urls,
175
180
  }), now);
package/dist/stream.d.ts CHANGED
@@ -53,6 +53,29 @@ export declare class LinkStream {
53
53
  ended(error?: string): void;
54
54
  waitOpened(): Promise<LinkStream>;
55
55
  }
56
+ /** The longest one JSON line `ndjson` buffers, in bytes (a line is its UTF-8 bytes up to `\n`). */
57
+ export declare const NDJSON_MAX_LINE: number;
58
+ /** The stream error an `ndjson` reader ends its stream with when a line runs past its cap. Both sides see it. */
59
+ export declare const NDJSON_LINE_TOO_LONG = "line-too-long";
60
+ export type NdjsonOptions = {
61
+ /** The longest one line buffered before the stream ends with `NDJSON_LINE_TOO_LONG`. Defaults to `NDJSON_MAX_LINE`. */
62
+ maxLine?: number;
63
+ };
64
+ export type NdjsonStream = {
65
+ /** The byte stream underneath, for `onEnd`, `end` and anything else not about JSON. */
66
+ readonly stream: LinkStream;
67
+ /** Sends one JSON message (`JSON.stringify` plus `\n`) through the stream's credit window. */
68
+ send(message: unknown): Promise<void>;
69
+ /** Each whole JSON message, in order. Returning a promise holds the sender back until it settles. */
70
+ onMessage(fn: (message: unknown) => void | Promise<void>): void;
71
+ /** Ends the stream both ways: after the sends already made, or at once with an error. */
72
+ end(error?: string): void;
73
+ };
74
+ /** Whole JSON messages over a byte `LinkStream`, one `\n`-terminated line each. Chunks split at any byte
75
+ * boundary reassemble before decoding, so a multi-byte character split across two chunks still arrives whole.
76
+ * A line past the cap ends the stream with `NDJSON_LINE_TOO_LONG` and is never delivered, so nothing is buffered
77
+ * without bound; a line that is not JSON ends it with `failed` through the stream's usual handler-error path. */
78
+ export declare function ndjson(stream: LinkStream, opts?: NdjsonOptions): NdjsonStream;
56
79
  /** One connection's streams. */
57
80
  export declare class Streams {
58
81
  private all;
package/dist/stream.js CHANGED
@@ -178,6 +178,79 @@ export class LinkStream {
178
178
  return new Promise((resolve, reject) => { this.settle = { resolve, reject: (e) => reject(e) }; });
179
179
  }
180
180
  }
181
+ /** The longest one JSON line `ndjson` buffers, in bytes (a line is its UTF-8 bytes up to `\n`). */
182
+ export const NDJSON_MAX_LINE = 1024 * 1024;
183
+ /** The stream error an `ndjson` reader ends its stream with when a line runs past its cap. Both sides see it. */
184
+ export const NDJSON_LINE_TOO_LONG = 'line-too-long';
185
+ /** Whole JSON messages over a byte `LinkStream`, one `\n`-terminated line each. Chunks split at any byte
186
+ * boundary reassemble before decoding, so a multi-byte character split across two chunks still arrives whole.
187
+ * A line past the cap ends the stream with `NDJSON_LINE_TOO_LONG` and is never delivered, so nothing is buffered
188
+ * without bound; a line that is not JSON ends it with `failed` through the stream's usual handler-error path. */
189
+ export function ndjson(stream, opts = {}) {
190
+ const maxLine = opts.maxLine ?? NDJSON_MAX_LINE;
191
+ if (!Number.isInteger(maxLine) || maxLine < 1)
192
+ throw new Error('bad ndjson maxLine');
193
+ const decode = new TextDecoder();
194
+ let parts = [];
195
+ let buffered = 0;
196
+ let failed = false;
197
+ const untilNewline = () => {
198
+ let at = 0;
199
+ for (const p of parts) {
200
+ const i = p.indexOf(10);
201
+ if (i !== -1)
202
+ return at + i;
203
+ at += p.byteLength;
204
+ }
205
+ return -1;
206
+ };
207
+ const take = (n) => {
208
+ const out = new Uint8Array(n);
209
+ for (let at = 0; at < n;) {
210
+ const p = parts[0];
211
+ const k = Math.min(p.byteLength, n - at);
212
+ out.set(p.subarray(0, k), at);
213
+ at += k;
214
+ parts = k === p.byteLength ? parts.slice(1) : [p.subarray(k), ...parts.slice(1)];
215
+ }
216
+ return out;
217
+ };
218
+ return {
219
+ stream,
220
+ send(message) {
221
+ const line = JSON.stringify(message);
222
+ if (line === undefined)
223
+ throw new Error('ndjson cannot send undefined');
224
+ return stream.write(`${line}\n`);
225
+ },
226
+ onMessage(fn) {
227
+ stream.onData = async (chunk) => {
228
+ if (failed)
229
+ return;
230
+ parts.push(chunk);
231
+ buffered += chunk.byteLength;
232
+ for (;;) {
233
+ const at = untilNewline();
234
+ if (at === -1) {
235
+ if (buffered > maxLine) {
236
+ failed = true;
237
+ parts = [];
238
+ buffered = 0;
239
+ stream.end(NDJSON_LINE_TOO_LONG);
240
+ }
241
+ return;
242
+ }
243
+ const line = decode.decode(take(at));
244
+ take(1); // the '\n'
245
+ buffered -= at + 1;
246
+ if (line.length > 0)
247
+ await fn(JSON.parse(line));
248
+ }
249
+ };
250
+ },
251
+ end(error) { stream.end(error); },
252
+ };
253
+ }
181
254
  const count = (n, max) => {
182
255
  if (!Number.isInteger(n) || n < 0 || n > max)
183
256
  throw new Error('bad stream message');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/pair",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Scan a code to pair a phone or browser with the home computer over one encrypted link (Noise IK over WebSocket).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",