@qontinui/ui-bridge-native 0.8.0 → 0.11.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 (39) hide show
  1. package/README.md +50 -2
  2. package/dist/core/index.js +13 -3
  3. package/dist/core/index.js.map +1 -1
  4. package/dist/core/index.mjs +13 -3
  5. package/dist/core/index.mjs.map +1 -1
  6. package/dist/core/sha256.d.ts +29 -7
  7. package/dist/core/sha256.d.ts.map +1 -1
  8. package/dist/debug/index.js.map +1 -1
  9. package/dist/debug/index.mjs.map +1 -1
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +996 -32
  13. package/dist/index.js.map +1 -1
  14. package/dist/index.mjs +986 -34
  15. package/dist/index.mjs.map +1 -1
  16. package/dist/observation/observation.d.ts +15 -1
  17. package/dist/observation/observation.d.ts.map +1 -1
  18. package/dist/react/UIBridgeNativeProvider.d.ts +25 -3
  19. package/dist/react/UIBridgeNativeProvider.d.ts.map +1 -1
  20. package/dist/react/index.js +967 -32
  21. package/dist/react/index.js.map +1 -1
  22. package/dist/react/index.mjs +968 -33
  23. package/dist/react/index.mjs.map +1 -1
  24. package/dist/server/handlers.d.ts.map +1 -1
  25. package/dist/server/http-server.d.ts +120 -2
  26. package/dist/server/http-server.d.ts.map +1 -1
  27. package/dist/server/index.d.ts +1 -0
  28. package/dist/server/index.d.ts.map +1 -1
  29. package/dist/server/index.js +934 -16
  30. package/dist/server/index.js.map +1 -1
  31. package/dist/server/index.mjs +916 -17
  32. package/dist/server/index.mjs.map +1 -1
  33. package/dist/server/pairing.d.ts +295 -0
  34. package/dist/server/pairing.d.ts.map +1 -0
  35. package/dist/server/types.d.ts +20 -0
  36. package/dist/server/types.d.ts.map +1 -1
  37. package/dist/transport/DeviceAnnouncer.d.ts +0 -2
  38. package/dist/transport/DeviceAnnouncer.d.ts.map +1 -1
  39. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -121,8 +121,7 @@ function utf8Bytes(input) {
121
121
  return Uint8Array.from(out);
122
122
  }
123
123
  var rotr = (x, n) => x >>> n | x << 32 - n;
124
- function sha256Hex(input) {
125
- const bytes = utf8Bytes(input);
124
+ function sha256Bytes(bytes) {
126
125
  const blockBytes = Math.ceil((bytes.length + 9) / 64) * 64;
127
126
  const buffer = new Uint8Array(blockBytes);
128
127
  buffer.set(bytes);
@@ -166,7 +165,38 @@ function sha256Hex(input) {
166
165
  h[6] = h[6] + g | 0;
167
166
  h[7] = h[7] + hh | 0;
168
167
  }
169
- return h.map((word) => (word >>> 0).toString(16).padStart(8, "0")).join("");
168
+ const digest = new Uint8Array(32);
169
+ const digestView = new DataView(digest.buffer);
170
+ h.forEach((word, i) => digestView.setUint32(i * 4, word >>> 0));
171
+ return digest;
172
+ }
173
+ function bytesToHex(bytes) {
174
+ let out = "";
175
+ for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
176
+ return out;
177
+ }
178
+ function sha256Hex(input) {
179
+ return bytesToHex(sha256Bytes(utf8Bytes(input)));
180
+ }
181
+ var SHA256_BLOCK_BYTES = 64;
182
+ function hmacSha256(key, message) {
183
+ const normalizedKey = key.length > SHA256_BLOCK_BYTES ? sha256Bytes(key) : key;
184
+ const inner = new Uint8Array(SHA256_BLOCK_BYTES + message.length);
185
+ const outer = new Uint8Array(SHA256_BLOCK_BYTES + 32);
186
+ for (let i = 0; i < SHA256_BLOCK_BYTES; i++) {
187
+ const keyByte = i < normalizedKey.length ? normalizedKey[i] : 0;
188
+ inner[i] = keyByte ^ 54;
189
+ outer[i] = keyByte ^ 92;
190
+ }
191
+ inner.set(message, SHA256_BLOCK_BYTES);
192
+ outer.set(sha256Bytes(inner), SHA256_BLOCK_BYTES);
193
+ return sha256Bytes(outer);
194
+ }
195
+ function constantTimeEqual(a, b) {
196
+ if (a.length !== b.length) return false;
197
+ let diff = 0;
198
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
199
+ return diff === 0;
170
200
  }
171
201
 
172
202
  // src/core/vision-fields.ts
@@ -3505,7 +3535,14 @@ var UNKNOWN_CODES = [
3505
3535
  "below_confidence_floor",
3506
3536
  "model_reply_unparseable",
3507
3537
  "needs_multi_frame_input",
3508
- "stale_input"
3538
+ "stale_input",
3539
+ "frontier_open",
3540
+ "ledger_schema_absent",
3541
+ "ledger_write_failing",
3542
+ "no_observations",
3543
+ "to_node_unobserved",
3544
+ "performance_observer_unsupported",
3545
+ "no_event_before_timeout"
3509
3546
  ];
3510
3547
  function isUnknownCode(value) {
3511
3548
  return typeof value === "string" && UNKNOWN_CODES.includes(value);
@@ -3695,7 +3732,7 @@ var NATIVE_PAGE_HEALTH_PRODUCER_ID = "sdk-native/page-health";
3695
3732
  function defaultProducer() {
3696
3733
  return {
3697
3734
  id: NATIVE_PAGE_HEALTH_PRODUCER_ID,
3698
- version: "0.8.0"
3735
+ version: "0.11.0"
3699
3736
  };
3700
3737
  }
3701
3738
  function pageHealthUnknown(code, detail, options = {}) {
@@ -4598,6 +4635,16 @@ function createServerHandlers(registry, executor, config) {
4598
4635
  // the envelope agree; an unmounted path answers 404 instead. Each error
4599
4636
  // message names an in-tree mobile replacement so operators aren't left
4600
4637
  // guessing what to use instead.
4638
+ // The real pairing route lives in the server's network entry, ahead of this
4639
+ // table, because only that entry knows the socket peer. This answer is what
4640
+ // JSON-RPC over a WebSocket and the cloud relay get, so neither can mint a
4641
+ // token or spend a code attempt.
4642
+ pair: async () => {
4643
+ return error2(
4644
+ "Pairing is served only on a direct HTTP connection to the device, not over JSON-RPC or the cloud relay.",
4645
+ "NOT_SUPPORTED"
4646
+ );
4647
+ },
4601
4648
  aiForms: async () => {
4602
4649
  return error2(
4603
4650
  'ai/forms (form discovery) is runner-only; mobile React Native has no DOM to walk. Use GET /ui-bridge/control/snapshot and filter for type === "input" instead.',
@@ -4992,7 +5039,7 @@ function createServerHandlers(registry, executor, config) {
4992
5039
  };
4993
5040
  if (config?.appInfo) {
4994
5041
  response.uiBridge = {
4995
- version: "0.8.0",
5042
+ version: "0.11.0",
4996
5043
  ...config.appInfo,
4997
5044
  capabilities: ["elements", "components", "actions", "design"],
4998
5045
  elementCount: stats.elements,
@@ -5004,6 +5051,527 @@ function createServerHandlers(registry, executor, config) {
5004
5051
  };
5005
5052
  }
5006
5053
 
5054
+ // src/server/pairing.ts
5055
+ var UI_BRIDGE_AUTH_HEADER = "X-UI-Bridge-Auth";
5056
+ var SIGNATURE_WINDOW_MS = 3e5;
5057
+ var PAIRING_CODE_TTL_MS = 12e4;
5058
+ var PAIRING_CODE_MAX_ATTEMPTS = 5;
5059
+ var SIGNED_BODY_MAX_CHARS = 256 * 1024;
5060
+ var SIGNED_METHOD_MAX_CHARS = 16;
5061
+ var SIGNED_TARGET_MAX_CHARS = 8192;
5062
+ var TOKEN_BYTES = 32;
5063
+ var NONCE_CACHE_MAX = 4096;
5064
+ var STORE_TIMEOUT_MS = 1e4;
5065
+ var CODE_SAMPLE_ATTEMPTS = 32;
5066
+ var CODE_SAMPLE_LIMIT = Math.floor(4294967296 / 1e6) * 1e6;
5067
+ var CLOCK_STEP_TOLERANCE_MS = 5e3;
5068
+ function monotonicNow() {
5069
+ const clock = globalThis.performance;
5070
+ return typeof clock?.now === "function" ? clock.now() : null;
5071
+ }
5072
+ var PairingError = class extends Error {
5073
+ constructor(code, message) {
5074
+ super(message);
5075
+ this.code = code;
5076
+ this.name = "PairingError";
5077
+ }
5078
+ };
5079
+ function canonicalSignatureInput(parts) {
5080
+ return [parts.method, parts.pathAndQuery, parts.ts, parts.nonce, parts.bodySha256Hex].join("\n");
5081
+ }
5082
+ function signatureHex(token, parts) {
5083
+ const input = canonicalSignatureInput({
5084
+ method: parts.method,
5085
+ pathAndQuery: parts.pathAndQuery,
5086
+ ts: parts.ts,
5087
+ nonce: parts.nonce,
5088
+ bodySha256Hex: sha256Hex(parts.body)
5089
+ });
5090
+ return bytesToHex(hmacSha256(utf8Bytes(token), utf8Bytes(input)));
5091
+ }
5092
+ function signUIBridgeRequest(token, request) {
5093
+ const ts = String(request.ts);
5094
+ const sig = signatureHex(token, {
5095
+ method: request.method,
5096
+ pathAndQuery: request.pathAndQuery,
5097
+ ts,
5098
+ nonce: request.nonce,
5099
+ body: request.body ?? ""
5100
+ });
5101
+ return `${ts}.${request.nonce}.${sig}`;
5102
+ }
5103
+ function headerValues(headers, name) {
5104
+ if (!headers) return [];
5105
+ const wanted = name.toLowerCase();
5106
+ return Object.keys(headers).filter((key) => key.toLowerCase() === wanted).map((key) => headers[key]);
5107
+ }
5108
+ function ipv4Octets(address) {
5109
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address);
5110
+ if (!match) return null;
5111
+ const octets = match.slice(1).map(Number);
5112
+ return octets.every((octet) => octet <= 255) ? octets : null;
5113
+ }
5114
+ function isLoopbackIpv4(address) {
5115
+ return ipv4Octets(address)?.[0] === 127;
5116
+ }
5117
+ function parseIpv6(address) {
5118
+ if (!address.includes(":")) return null;
5119
+ let text = address;
5120
+ const trailing = [];
5121
+ const lastColon = text.lastIndexOf(":");
5122
+ const tail = text.slice(lastColon + 1);
5123
+ if (tail.includes(".")) {
5124
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(tail);
5125
+ if (!match) return null;
5126
+ const octets = match.slice(1).map(Number);
5127
+ if (octets.some((octet) => octet > 255)) return null;
5128
+ trailing.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
5129
+ text = text.slice(0, lastColon + 1);
5130
+ if (!text.endsWith("::")) text = text.slice(0, -1);
5131
+ }
5132
+ const groupsOf = (part) => {
5133
+ if (part === "") return [];
5134
+ const groups = [];
5135
+ for (const group of part.split(":")) {
5136
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
5137
+ groups.push(parseInt(group, 16));
5138
+ }
5139
+ return groups;
5140
+ };
5141
+ const gapAt = text.indexOf("::");
5142
+ if (gapAt !== text.lastIndexOf("::")) return null;
5143
+ const width = 8 - trailing.length;
5144
+ if (gapAt === -1) {
5145
+ const groups = groupsOf(text);
5146
+ return groups && groups.length === width ? [...groups, ...trailing] : null;
5147
+ }
5148
+ const head = groupsOf(text.slice(0, gapAt));
5149
+ const rest = groupsOf(text.slice(gapAt + 2));
5150
+ if (!head || !rest || head.length + rest.length >= width) return null;
5151
+ const zeros = new Array(width - head.length - rest.length).fill(0);
5152
+ return [...head, ...zeros, ...rest, ...trailing];
5153
+ }
5154
+ function isLoopbackIpv6(address) {
5155
+ const zone = address.indexOf("%");
5156
+ const groups = parseIpv6(zone === -1 ? address : address.slice(0, zone));
5157
+ if (!groups) return false;
5158
+ const leadingZeros = (count) => groups.slice(0, count).every((g) => g === 0);
5159
+ if (leadingZeros(7) && groups[7] === 1) return true;
5160
+ return leadingZeros(5) && groups[5] === 65535 && groups[6] >> 8 === 127;
5161
+ }
5162
+ function isLoopbackAddress(address) {
5163
+ return isLoopbackIpv4(address) || isLoopbackIpv6(address);
5164
+ }
5165
+ function isLoopbackHost(host) {
5166
+ const value = host.trim().toLowerCase();
5167
+ const bracketed = /^\[([^\]]+)\](?::(\d{1,5}))?$/.exec(value);
5168
+ if (bracketed) {
5169
+ return isLoopbackIpv6(bracketed[1]) && isValidPort(bracketed[2]);
5170
+ }
5171
+ const plain = /^([^:]+)(?::(\d{1,5}))?$/.exec(value);
5172
+ if (!plain) return false;
5173
+ return (plain[1] === "localhost" || isLoopbackIpv4(plain[1])) && isValidPort(plain[2]);
5174
+ }
5175
+ function isValidPort(port) {
5176
+ return port === void 0 || Number(port) <= 65535;
5177
+ }
5178
+ function isUnrebindableHost(host) {
5179
+ const value = host.trim().toLowerCase();
5180
+ const bracketed = /^\[([^\]%]+)(?:%[^\]]*)?\](?::(\d{1,5}))?$/.exec(value);
5181
+ if (bracketed) return parseIpv6(bracketed[1]) !== null && isValidPort(bracketed[2]);
5182
+ const plain = /^([^:]+)(?::(\d{1,5}))?$/.exec(value);
5183
+ if (!plain) return false;
5184
+ const named = plain[1] === "localhost" || plain[1].endsWith(".local") || ipv4Octets(plain[1]) !== null;
5185
+ return named && isValidPort(plain[2]);
5186
+ }
5187
+ function carriesWebContext(request) {
5188
+ const { headers } = request;
5189
+ if (headerValues(headers, "origin").length > 0) return true;
5190
+ if (headerValues(headers, "referer").length > 0) return true;
5191
+ return headerValues(headers, "sec-fetch-site").some(
5192
+ (site) => site.trim().toLowerCase() !== "none"
5193
+ );
5194
+ }
5195
+ function isTrustedLoopbackRequest(request) {
5196
+ if (!request.remoteAddress || !isLoopbackAddress(request.remoteAddress)) return false;
5197
+ if (carriesWebContext(request)) return false;
5198
+ const hosts = headerValues(request.headers, "host");
5199
+ return hosts.length > 0 && hosts.every(isLoopbackHost);
5200
+ }
5201
+ function isWebReachableRequest(request) {
5202
+ if (carriesWebContext(request)) return true;
5203
+ const hosts = headerValues(request.headers, "host");
5204
+ return hosts.length === 0 || !hosts.every(isUnrebindableHost);
5205
+ }
5206
+ async function bounded(work) {
5207
+ let timer;
5208
+ try {
5209
+ return await Promise.race([
5210
+ Promise.resolve().then(work),
5211
+ new Promise((_, reject) => {
5212
+ timer = setTimeout(() => reject(new Error("store call timed out")), STORE_TIMEOUT_MS);
5213
+ })
5214
+ ]);
5215
+ } finally {
5216
+ if (timer !== void 0) clearTimeout(timer);
5217
+ }
5218
+ }
5219
+ var UNAVAILABLE = {
5220
+ ok: false,
5221
+ code: "PAIRING_UNAVAILABLE",
5222
+ error: "The pairing store is not available yet. Try again."
5223
+ };
5224
+ var TOKEN_NOT_LOADED = /* @__PURE__ */ Symbol("token-not-loaded");
5225
+ var PairingAuthority = class {
5226
+ constructor(store) {
5227
+ this.store = store;
5228
+ this.armedCode = null;
5229
+ /** A correct code waiting on the store step, restored if that step fails. */
5230
+ this.heldCode = null;
5231
+ this.disarmReason = null;
5232
+ this.consumedBy = null;
5233
+ this.consumedAt = null;
5234
+ this.lastWrite = Promise.resolve();
5235
+ /** Nonce to its timestamp, oldest insertion first. */
5236
+ this.nonces = /* @__PURE__ */ new Map();
5237
+ /**
5238
+ * The oldest timestamp still accepted. A nonce this process did not record
5239
+ * cannot be checked for a replay, so a signature from before it existed is
5240
+ * refused: otherwise an app restart, which empties the cache, would reopen
5241
+ * the whole window to a captured request. Eviction raises it for the same
5242
+ * reason. A clock step re-bases it to the new now, or a clock set back past it
5243
+ * would refuse every signature until the app restarted. That leaves one gap with
5244
+ * two arms: a request captured before a step back can replay once, if its
5245
+ * timestamp is at or above the new now and this process does not remember its
5246
+ * nonce, because it has forgotten it, or because the app restarted since and it
5247
+ * never recorded it. Without `performance.now` no step is seen, and the floor
5248
+ * holds until the clock passes it.
5249
+ */
5250
+ this.oldestAcceptedTs = Date.now();
5251
+ /**
5252
+ * The wall clock minus the monotonic clock at the last re-base. It moves only
5253
+ * then, so small steps add up until they cross the tolerance, and a clock step
5254
+ * leaves the floor at most twice the tolerance ahead of the clock: the clock can
5255
+ * swing from just over the baseline to just under it without crossing it.
5256
+ */
5257
+ this.clockOffset = this.readClockOffset();
5258
+ /** Moves when a revoke ends, so a read of the old token that spans one cannot admit. */
5259
+ this.tokenGeneration = 0;
5260
+ /** Revokes under way. The old token may already be dying, so nothing verifies. */
5261
+ this.revokesInFlight = 0;
5262
+ /** A revoke failed in a way that may have left the old token in the store. */
5263
+ this.storeSuspect = false;
5264
+ }
5265
+ /**
5266
+ * The stored token, `null` when there is none, or `TOKEN_NOT_LOADED`. Only
5267
+ * `readStore` tells these apart: an empty or non-string value is never a
5268
+ * token, because an empty key would verify anything signed with it.
5269
+ */
5270
+ async readToken() {
5271
+ return this.storeSuspect ? TOKEN_NOT_LOADED : this.readStore();
5272
+ }
5273
+ async readStore() {
5274
+ try {
5275
+ const token = await bounded(() => this.store.get());
5276
+ if (token === null) return null;
5277
+ return typeof token === "string" && token.length > 0 ? token : TOKEN_NOT_LOADED;
5278
+ } catch {
5279
+ return TOKEN_NOT_LOADED;
5280
+ }
5281
+ }
5282
+ async randomBytes(n) {
5283
+ const unusable = new PairingError(
5284
+ "PAIRING_UNAVAILABLE",
5285
+ "The secure random source returned no usable bytes."
5286
+ );
5287
+ let bytes;
5288
+ try {
5289
+ bytes = await bounded(() => this.store.randomBytes(n));
5290
+ } catch {
5291
+ throw unusable;
5292
+ }
5293
+ if (!ArrayBuffer.isView(bytes) || bytes.length !== n || bytes.every((byte) => byte === 0)) {
5294
+ throw unusable;
5295
+ }
5296
+ return bytes;
5297
+ }
5298
+ async mintAndStoreToken() {
5299
+ const token = bytesToHex(await this.randomBytes(TOKEN_BYTES));
5300
+ await bounded(() => this.store.set(token));
5301
+ return token;
5302
+ }
5303
+ /** Run token writes one at a time, so two first pairs cannot mint two tokens. */
5304
+ exclusive(work) {
5305
+ const run = this.lastWrite.then(work, work);
5306
+ this.lastWrite = run.catch(() => void 0);
5307
+ return run;
5308
+ }
5309
+ /** Check a `<ts>.<nonce>.<sig>` value against this request and the stored token. */
5310
+ async verifySignature(request, header) {
5311
+ const match = /^(\d{1,16})\.([A-Za-z0-9_-]{16,64})\.([0-9a-f]{64})$/.exec(header);
5312
+ if (!match) return "refused";
5313
+ const [, ts, nonce, sig] = match;
5314
+ const tsMs = Number(ts);
5315
+ this.noticeClockStep();
5316
+ if (Math.abs(Date.now() - tsMs) > SIGNATURE_WINDOW_MS) return "timestamp_refused";
5317
+ if (tsMs < this.oldestAcceptedTs) return "timestamp_refused";
5318
+ const pathAndQuery = signedTarget(request);
5319
+ const body = signedBody(request);
5320
+ if (pathAndQuery === null || body === null) return "refused";
5321
+ if (body.length > SIGNED_BODY_MAX_CHARS) return "body_too_large";
5322
+ if (request.method.length > SIGNED_METHOD_MAX_CHARS) return "target_too_long";
5323
+ if (pathAndQuery.length > SIGNED_TARGET_MAX_CHARS) return "target_too_long";
5324
+ const generation = this.tokenGeneration;
5325
+ const token = await this.readToken();
5326
+ if (this.revokesInFlight > 0) return "refused";
5327
+ if (token === TOKEN_NOT_LOADED) return "store_unavailable";
5328
+ if (token === null) return "refused";
5329
+ const expected = signatureHex(token, { method: request.method, pathAndQuery, ts, nonce, body });
5330
+ if (!constantTimeEqual(expected, sig)) return "refused";
5331
+ if (generation !== this.tokenGeneration) return "refused";
5332
+ if (this.nonces.has(nonce)) return "refused";
5333
+ this.rememberNonce(nonce, tsMs);
5334
+ return "valid";
5335
+ }
5336
+ readClockOffset() {
5337
+ const monotonic = monotonicNow();
5338
+ return monotonic === null ? null : Date.now() - monotonic;
5339
+ }
5340
+ /**
5341
+ * Re-base the floor when the wall clock has stepped against the monotonic one.
5342
+ * A nonce remembered from above the new now is clamped to just below the new
5343
+ * floor. Left as it is, forgetting it later would raise the floor back to a time
5344
+ * before the step, and the lockout would return when the cache overflowed.
5345
+ */
5346
+ noticeClockStep() {
5347
+ const offset = this.readClockOffset();
5348
+ if (offset === null || this.clockOffset === null) return;
5349
+ if (Math.abs(offset - this.clockOffset) > CLOCK_STEP_TOLERANCE_MS) {
5350
+ this.clockOffset = offset;
5351
+ this.oldestAcceptedTs = Date.now();
5352
+ for (const [nonce, tsMs] of this.nonces) {
5353
+ if (tsMs >= this.oldestAcceptedTs) this.nonces.set(nonce, this.oldestAcceptedTs - 1);
5354
+ }
5355
+ }
5356
+ }
5357
+ rememberNonce(nonce, tsMs) {
5358
+ if (this.nonces.size >= NONCE_CACHE_MAX) {
5359
+ const now = Date.now();
5360
+ for (const [seen, seenTs] of this.nonces) {
5361
+ if (seenTs + SIGNATURE_WINDOW_MS < now) this.forgetNonce(seen, seenTs);
5362
+ }
5363
+ }
5364
+ while (this.nonces.size >= NONCE_CACHE_MAX) {
5365
+ const [seen, seenTs] = this.nonces.entries().next().value;
5366
+ this.forgetNonce(seen, seenTs);
5367
+ }
5368
+ this.nonces.set(nonce, tsMs);
5369
+ }
5370
+ /** Forgetting a nonce must not readmit it, so its timestamp stops being accepted. */
5371
+ forgetNonce(nonce, tsMs) {
5372
+ this.nonces.delete(nonce);
5373
+ this.oldestAcceptedTs = Math.max(this.oldestAcceptedTs, tsMs + 1);
5374
+ }
5375
+ /**
5376
+ * The route's decision. A trusted loopback caller needs no code. Anyone else
5377
+ * spends the armed code, and an unarmed one refuses without comparing.
5378
+ */
5379
+ async pair(input) {
5380
+ if (!input.trustedLoopback) {
5381
+ const refusal = this.spendCode(input.body);
5382
+ if (refusal) return refusal;
5383
+ }
5384
+ const spent = input.trustedLoopback ? null : this.heldCode;
5385
+ const outcome = await this.exclusive(async () => {
5386
+ const existing = await this.readToken();
5387
+ if (existing === TOKEN_NOT_LOADED) return UNAVAILABLE;
5388
+ if (existing !== null) return { ok: true, token: existing };
5389
+ try {
5390
+ return { ok: true, token: await this.mintAndStoreToken() };
5391
+ } catch {
5392
+ return UNAVAILABLE;
5393
+ }
5394
+ });
5395
+ if (spent) this.settleSpentCode(spent, outcome.ok, input.peer);
5396
+ return outcome;
5397
+ }
5398
+ /**
5399
+ * The store step has answered. A failure gives the code back, since the user
5400
+ * typed the right one and the phone let them down, unless the app has since
5401
+ * cancelled it or armed another.
5402
+ */
5403
+ settleSpentCode(spent, paired, peer) {
5404
+ const stillHeld = this.heldCode === spent;
5405
+ if (stillHeld) this.heldCode = null;
5406
+ if (paired) {
5407
+ if (stillHeld) this.disarmReason = "consumed";
5408
+ this.consumedBy = peer ?? "unknown";
5409
+ this.consumedAt = Date.now();
5410
+ } else if (stillHeld) {
5411
+ this.armedCode = spent;
5412
+ }
5413
+ }
5414
+ /** No await in here: the attempt count has to move with the comparison. */
5415
+ spendCode(body) {
5416
+ this.expireCode();
5417
+ const armed = this.armedCode;
5418
+ if (!armed) {
5419
+ return {
5420
+ ok: false,
5421
+ code: "PAIRING_NOT_ARMED",
5422
+ error: "No pairing code is armed. Arm one in the app, or pair over a cable."
5423
+ };
5424
+ }
5425
+ const code = body?.code;
5426
+ if (typeof code !== "string" || !/^\d{6}$/.test(code)) {
5427
+ return {
5428
+ ok: false,
5429
+ code: "PAIRING_CODE_REQUIRED",
5430
+ error: 'Send the 6-digit code shown in the app, as { "code": "123456" }.'
5431
+ };
5432
+ }
5433
+ if (constantTimeEqual(armed.code, code)) {
5434
+ this.armedCode = null;
5435
+ this.heldCode = armed;
5436
+ return null;
5437
+ }
5438
+ armed.wrongAttempts += 1;
5439
+ if (armed.wrongAttempts >= PAIRING_CODE_MAX_ATTEMPTS) {
5440
+ this.armedCode = null;
5441
+ this.disarmReason = "attempts_exhausted";
5442
+ }
5443
+ return { ok: false, code: "PAIRING_CODE_INVALID", error: "That code is not correct." };
5444
+ }
5445
+ /** Either clock ends the code, so a clock set back cannot extend it. */
5446
+ expireCode() {
5447
+ if (!this.armedCode) return;
5448
+ const { expiresAt, monotonicDeadline } = this.armedCode;
5449
+ const monotonic = monotonicNow();
5450
+ const monotonicExpired = monotonic !== null && monotonicDeadline !== null && monotonic >= monotonicDeadline;
5451
+ if (Date.now() >= expiresAt || monotonicExpired) {
5452
+ this.armedCode = null;
5453
+ this.disarmReason = "expired";
5454
+ }
5455
+ }
5456
+ /** Arm a fresh code, replacing any live one. */
5457
+ async arm() {
5458
+ const stored = await this.readToken();
5459
+ if (stored === TOKEN_NOT_LOADED) {
5460
+ throw new PairingError("PAIRING_UNAVAILABLE", "The pairing store is not available yet.");
5461
+ }
5462
+ const code = await this.drawCode();
5463
+ const expiresAt = Date.now() + PAIRING_CODE_TTL_MS;
5464
+ const monotonic = monotonicNow();
5465
+ this.armedCode = {
5466
+ code,
5467
+ expiresAt,
5468
+ monotonicDeadline: monotonic === null ? null : monotonic + PAIRING_CODE_TTL_MS,
5469
+ wrongAttempts: 0
5470
+ };
5471
+ this.heldCode = null;
5472
+ this.disarmReason = null;
5473
+ this.consumedBy = null;
5474
+ this.consumedAt = null;
5475
+ return { code, expiresAt };
5476
+ }
5477
+ /** A uniform 6-digit code, by rejection sampling so that no digit is favoured. */
5478
+ async drawCode() {
5479
+ for (let attempt = 0; attempt < CODE_SAMPLE_ATTEMPTS; attempt++) {
5480
+ const bytes = await this.randomBytes(4);
5481
+ const value = new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0);
5482
+ if (value < CODE_SAMPLE_LIMIT) return String(value % 1e6).padStart(6, "0");
5483
+ }
5484
+ throw new PairingError(
5485
+ "PAIRING_UNAVAILABLE",
5486
+ "The secure random source returned no usable bytes."
5487
+ );
5488
+ }
5489
+ /** Cancel a live code. */
5490
+ disarm() {
5491
+ this.dropCode("cancelled");
5492
+ }
5493
+ dropCode(reason) {
5494
+ if (this.armedCode || this.heldCode) {
5495
+ this.armedCode = null;
5496
+ this.heldCode = null;
5497
+ this.disarmReason = reason;
5498
+ }
5499
+ }
5500
+ /**
5501
+ * Replace the token, so every signature made with the old one stops verifying.
5502
+ * If it cannot be replaced it is cleared. If that fails too, signatures and
5503
+ * pairing stay refused until a revoke succeeds: the user asked for the old
5504
+ * token to die, and a store that would not say so cannot be trusted to have
5505
+ * done it.
5506
+ *
5507
+ * Nothing verifies from the moment this starts until it ends. The generation
5508
+ * moves when it ends, whichever way, so a check that read the old token before
5509
+ * then cannot admit its request afterwards. A request a check has already
5510
+ * admitted runs to its end.
5511
+ */
5512
+ async revoke() {
5513
+ this.revokesInFlight += 1;
5514
+ this.dropCode("revoked");
5515
+ try {
5516
+ await this.exclusive(async () => {
5517
+ const stored = await this.readStore();
5518
+ if (stored === TOKEN_NOT_LOADED) {
5519
+ throw new PairingError("PAIRING_UNAVAILABLE", "The pairing store is not available yet.");
5520
+ }
5521
+ try {
5522
+ await this.mintAndStoreToken();
5523
+ this.storeSuspect = false;
5524
+ } catch {
5525
+ try {
5526
+ await bounded(() => this.store.clear());
5527
+ this.storeSuspect = false;
5528
+ } catch {
5529
+ this.storeSuspect = true;
5530
+ }
5531
+ throw new PairingError("PAIRING_UNAVAILABLE", "The pairing token could not be replaced.");
5532
+ }
5533
+ });
5534
+ } finally {
5535
+ this.revokesInFlight -= 1;
5536
+ this.tokenGeneration += 1;
5537
+ }
5538
+ }
5539
+ /** What Settings shows. */
5540
+ getState() {
5541
+ this.expireCode();
5542
+ return {
5543
+ configured: true,
5544
+ armed: this.armedCode !== null,
5545
+ expiresAt: this.armedCode?.expiresAt ?? null,
5546
+ attemptsRemaining: this.armedCode ? PAIRING_CODE_MAX_ATTEMPTS - this.armedCode.wrongAttempts : null,
5547
+ consumedBy: this.consumedBy,
5548
+ consumedAt: this.consumedAt,
5549
+ disarmReason: this.disarmReason
5550
+ };
5551
+ }
5552
+ };
5553
+ var UNCONFIGURED_PAIRING_STATE = {
5554
+ configured: false,
5555
+ armed: false,
5556
+ expiresAt: null,
5557
+ attemptsRemaining: null,
5558
+ consumedBy: null,
5559
+ consumedAt: null,
5560
+ disarmReason: null
5561
+ };
5562
+ function signedTarget(request) {
5563
+ if (request.rawTarget === void 0) {
5564
+ return Object.keys(request.query ?? {}).length === 0 ? request.path : null;
5565
+ }
5566
+ const queryAt = request.rawTarget.indexOf("?");
5567
+ const path = queryAt === -1 ? request.rawTarget : request.rawTarget.slice(0, queryAt);
5568
+ return path === request.path ? request.rawTarget : null;
5569
+ }
5570
+ function signedBody(request) {
5571
+ if (request.rawBody !== void 0) return request.rawBody;
5572
+ return request.body === void 0 ? "" : null;
5573
+ }
5574
+
5007
5575
  // src/server/observability.ts
5008
5576
  var RingBuffer = class {
5009
5577
  constructor(capacity) {
@@ -5334,7 +5902,20 @@ function isUnsubscribe(msg) {
5334
5902
  var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
5335
5903
  ["NOT_SUPPORTED", 501],
5336
5904
  ["NOT_FOUND", 404],
5337
- ["METHOD_NOT_ALLOWED", 405]
5905
+ ["METHOD_NOT_ALLOWED", 405],
5906
+ // Device pairing, all answered by the network entry. A 401 means this caller
5907
+ // has not proved it may act; a 403 means proving it would change nothing; a 503
5908
+ // means the phone cannot check yet.
5909
+ ["PAIRING_REQUIRED", 401],
5910
+ ["PAIRING_UNCONFIGURED", 401],
5911
+ ["PAIRING_NOT_ARMED", 401],
5912
+ ["PAIRING_CODE_INVALID", 401],
5913
+ ["PAIRING_TIMESTAMP_REFUSED", 401],
5914
+ ["PAIRING_WS_LOOPBACK_ONLY", 403],
5915
+ ["PAIRING_WEB_CALLER_REFUSED", 403],
5916
+ ["PAIRING_BODY_TOO_LARGE", 413],
5917
+ ["PAIRING_TARGET_TOO_LONG", 414],
5918
+ ["PAIRING_UNAVAILABLE", 503]
5338
5919
  ]);
5339
5920
  function httpStatusForResponse(response) {
5340
5921
  if (response.success) return 200;
@@ -5360,6 +5941,10 @@ function parsePath(pattern, actual) {
5360
5941
  var WS_ROUTES = [
5361
5942
  // Health
5362
5943
  { pattern: "health", handler: "health", httpMethods: ["GET"] },
5944
+ // Device pairing. Always NOT_SUPPORTED from this table: the real route is in
5945
+ // `handleNetworkRequest`, because route context carries no peer, and this
5946
+ // entry is what JSON-RPC and the relay reach instead.
5947
+ { pattern: "pair", handler: "pair", httpMethods: ["POST"] },
5363
5948
  // Elements
5364
5949
  { pattern: "control/elements", handler: "getElements", httpMethods: ["GET"] },
5365
5950
  // Flat alias — matches the runner UI Bridge's `/ui-bridge/elements` path so
@@ -5522,6 +6107,7 @@ function allowedMethodsForPath(path, testHooks) {
5522
6107
  }
5523
6108
  var HANDLER_DESCRIPTIONS = {
5524
6109
  health: "Liveness probe; returns app metadata + element count",
6110
+ pair: "Get the pairing token: loopback with no code, otherwise body { code }. Direct HTTP only",
5525
6111
  getElements: "Flat list of registered UI elements",
5526
6112
  getElement: "Single element by id",
5527
6113
  getElementState: "Dynamic state (value, focused, checked) for one element",
@@ -5623,10 +6209,24 @@ function buildHelpPayload(testHooks) {
5623
6209
  ]
5624
6210
  };
5625
6211
  }
6212
+ var HEALTH_PATH = "/ui-bridge/health";
6213
+ var PAIR_PATH = "/ui-bridge/pair";
6214
+ function jsonRpcIdOf(rawMessage) {
6215
+ try {
6216
+ const id = JSON.parse(rawMessage)?.id;
6217
+ return typeof id === "number" || typeof id === "string" ? id : 0;
6218
+ } catch {
6219
+ return 0;
6220
+ }
6221
+ }
5626
6222
  var NativeUIBridgeServer = class {
5627
6223
  constructor(registry, executor, config = {}) {
5628
6224
  this.registry = registry;
5629
6225
  this.executor = executor;
6226
+ /** Whether this run's adapter has called `onWebSocketUpgrade` at all. */
6227
+ this.upgradeGateSeen = false;
6228
+ /** Warned once per run rather than per request, which would flood the error ring. */
6229
+ this.warnedMissingPeer = false;
5630
6230
  this.running = false;
5631
6231
  /**
5632
6232
  * Per-connection cleanup thunks for in-flight waiters (waitForElement,
@@ -5639,6 +6239,9 @@ var NativeUIBridgeServer = class {
5639
6239
  cors: true,
5640
6240
  ...config
5641
6241
  };
6242
+ if (this.config.pairingStore) {
6243
+ this.pairing = new PairingAuthority(this.config.pairingStore);
6244
+ }
5642
6245
  this.consoleErrorBuffer = new ConsoleErrorBuffer();
5643
6246
  this.networkRequestBuffer = new NetworkRequestBuffer();
5644
6247
  if (this.config.appInfo) {
@@ -5684,6 +6287,14 @@ var NativeUIBridgeServer = class {
5684
6287
  setAdapter(adapter) {
5685
6288
  this.adapter = adapter;
5686
6289
  }
6290
+ /**
6291
+ * Set the device's stable id. An app usually loads it from storage after the
6292
+ * server has started, and this spares it a restart that would drop every
6293
+ * socket and any armed code.
6294
+ */
6295
+ setDeviceId(deviceId) {
6296
+ this.config.deviceId = deviceId;
6297
+ }
5687
6298
  /**
5688
6299
  * Set a navigation provider for programmatic route navigation.
5689
6300
  * This enables `control/page/navigate` and `control/page/back` on native.
@@ -5917,7 +6528,18 @@ var NativeUIBridgeServer = class {
5917
6528
  this.consoleErrorBuffer.install();
5918
6529
  this.networkRequestBuffer.install();
5919
6530
  }
5920
- await this.adapter.start(this.config.serverPort, this.handleRequest.bind(this));
6531
+ this.upgradeGateSeen = false;
6532
+ this.warnedMissingPeer = false;
6533
+ if (this.config.allowUnauthenticatedNetwork) {
6534
+ console.warn(
6535
+ "[ui-bridge-native] allowUnauthenticatedNetwork is on: anyone who can reach this port can drive the app over HTTP."
6536
+ );
6537
+ } else if (!this.pairing) {
6538
+ console.warn(
6539
+ "[ui-bridge-native] No pairingStore configured: non-loopback callers are refused with PAIRING_UNCONFIGURED."
6540
+ );
6541
+ }
6542
+ await this.adapter.start(this.config.serverPort, this.handleNetworkRequest.bind(this));
5921
6543
  this.running = true;
5922
6544
  console.log(`[ui-bridge-native] HTTP server started on port ${this.config.serverPort}`);
5923
6545
  }
@@ -5946,6 +6568,26 @@ var NativeUIBridgeServer = class {
5946
6568
  setEventBridge(bridge) {
5947
6569
  this.eventBridge = bridge;
5948
6570
  }
6571
+ /**
6572
+ * The entry a socket's messages come through. An adapter that never called
6573
+ * `onWebSocketUpgrade` admitted the socket with no check, and a message carries
6574
+ * no peer to check now, so it is refused: the same fail-closed reading as a
6575
+ * missing peer address. An adapter that gates sees a call before its first socket.
6576
+ */
6577
+ async handleNetworkWebSocketMessage(connId, rawMessage) {
6578
+ if (!this.upgradeGateSeen) {
6579
+ return JSON.stringify({
6580
+ id: jsonRpcIdOf(rawMessage),
6581
+ result: {
6582
+ success: false,
6583
+ error: "This server adapter does not gate WebSocket upgrades (onWebSocketUpgrade).",
6584
+ code: "PAIRING_UNCONFIGURED",
6585
+ timestamp: Date.now()
6586
+ }
6587
+ });
6588
+ }
6589
+ return this.handleWebSocketMessage(connId, rawMessage);
6590
+ }
5949
6591
  /**
5950
6592
  * Handle a JSON-RPC message from a WebSocket client.
5951
6593
  * Parses the message, routes to the appropriate handler, and returns
@@ -6474,21 +7116,272 @@ var NativeUIBridgeServer = class {
6474
7116
  timestamp: Date.now()
6475
7117
  };
6476
7118
  }
7119
+ // ── Device pairing ──────────────────────────────────────────────────────
6477
7120
  /**
6478
- * Handle incoming HTTP request
7121
+ * What `start()` hands the adapter: every request off the socket comes through
7122
+ * here, and `handleRequest` sits behind it.
7123
+ *
7124
+ * | Caller | Admitted |
7125
+ * |----------------------------------------------------------|----------|
7126
+ * | Loopback peer, no web context, loopback `Host` | yes |
7127
+ * | Anyone with a valid `X-UI-Bridge-Auth` signature | yes |
7128
+ * | Anyone, `GET /ui-bridge/health` (trimmed) | yes |
7129
+ * | Anyone with no web context, `POST /ui-bridge/pair` | yes |
7130
+ * | A web page, `POST /ui-bridge/pair` | 403 `PAIRING_WEB_CALLER_REFUSED` |
7131
+ * | Signature outside the window or below the floor | 401 `PAIRING_TIMESTAMP_REFUSED` |
7132
+ * | Signed body over `SIGNED_BODY_MAX_CHARS` | 413 `PAIRING_BODY_TOO_LARGE` |
7133
+ * | Signed method or target over its cap | 414 `PAIRING_TARGET_TOO_LONG` |
7134
+ * | Everyone else | 401 `PAIRING_REQUIRED` |
7135
+ * | No store configured, not loopback | 401 `PAIRING_UNCONFIGURED` |
7136
+ * | Under `allowUnauthenticatedNetwork`, a web page or a rebindable `Host` | 403 `PAIRING_WEB_CALLER_REFUSED` |
7137
+ *
7138
+ * The gate is here rather than in `handleRequest` because `CloudRelayClient`
7139
+ * calls that directly and must keep working: relay access already means the
7140
+ * same user account, checked by the backend.
7141
+ */
7142
+ async handleNetworkRequest(request) {
7143
+ try {
7144
+ if (request.method === "POST" && request.path === PAIR_PATH) {
7145
+ return await this.handlePair(request);
7146
+ }
7147
+ const admission = await this.admit(request);
7148
+ if (request.method === "GET" && request.path === HEALTH_PATH) {
7149
+ return await this.handleNetworkHealth(request, admission);
7150
+ }
7151
+ if (!admission.admitted) {
7152
+ return this.refusal(admission.code, admission.error, { data: admission.data });
7153
+ }
7154
+ return await this.handleRequest(request);
7155
+ } catch {
7156
+ return this.refusal("INTERNAL_ERROR", "Internal server error", { status: 500 });
7157
+ }
7158
+ }
7159
+ /**
7160
+ * The WebSocket door: trusted loopback or nothing, whatever else the request
7161
+ * carries. A signature covers no upgrade intent, so one swallowed GET re-sent
7162
+ * with `Upgrade: websocket` would become a permanent control socket, and a socket
7163
+ * outlives the signature's window. `allowUnauthenticatedNetwork` opens HTTP only,
7164
+ * and no runner dials a device's socket.
7165
+ */
7166
+ handleWebSocketUpgrade(request) {
7167
+ this.upgradeGateSeen = true;
7168
+ if (this.isTrustedLoopback(request)) return { admit: true };
7169
+ return {
7170
+ admit: false,
7171
+ response: this.refusal(
7172
+ "PAIRING_WS_LOOPBACK_ONLY",
7173
+ "WebSocket upgrades are accepted only from this machine. Use signed HTTP from anywhere else."
7174
+ )
7175
+ };
7176
+ }
7177
+ isTrustedLoopback(request) {
7178
+ if (!request.remoteAddress && !this.warnedMissingPeer) {
7179
+ this.warnedMissingPeer = true;
7180
+ console.warn(
7181
+ "[ui-bridge-native] A request arrived with no peer address and is treated as not loopback. The server adapter must pass remoteAddress."
7182
+ );
7183
+ }
7184
+ return isTrustedLoopbackRequest(request);
7185
+ }
7186
+ async admit(request) {
7187
+ if (this.isTrustedLoopback(request)) return { admitted: true, as: "loopback" };
7188
+ if (this.config.allowUnauthenticatedNetwork) {
7189
+ if (isWebReachableRequest(request)) {
7190
+ return {
7191
+ admitted: false,
7192
+ code: "PAIRING_WEB_CALLER_REFUSED",
7193
+ error: "A web page cannot drive this device."
7194
+ };
7195
+ }
7196
+ return { admitted: true, as: "unauthenticated-network" };
7197
+ }
7198
+ if (!this.pairing) {
7199
+ return {
7200
+ admitted: false,
7201
+ code: "PAIRING_UNCONFIGURED",
7202
+ error: "This app has not configured device pairing, so it accepts only local callers."
7203
+ };
7204
+ }
7205
+ const required = {
7206
+ admitted: false,
7207
+ code: "PAIRING_REQUIRED",
7208
+ error: "This device requires pairing. Sign the request with the token from POST /ui-bridge/pair."
7209
+ };
7210
+ const signatures = headerValues(request.headers, UI_BRIDGE_AUTH_HEADER);
7211
+ if (signatures.length !== 1) return required;
7212
+ const verdict = await this.pairing.verifySignature(request, signatures[0]);
7213
+ if (verdict === "valid") return { admitted: true, as: "signed" };
7214
+ if (verdict === "store_unavailable") {
7215
+ return {
7216
+ admitted: false,
7217
+ code: "PAIRING_UNAVAILABLE",
7218
+ error: "The pairing store is not available yet. Try again."
7219
+ };
7220
+ }
7221
+ if (verdict === "timestamp_refused") {
7222
+ return {
7223
+ admitted: false,
7224
+ code: "PAIRING_TIMESTAMP_REFUSED",
7225
+ error: "The signature timestamp is not acceptable. Re-sync to data.serverTime and sign again.",
7226
+ data: { serverTime: Date.now() }
7227
+ };
7228
+ }
7229
+ if (verdict === "body_too_large") {
7230
+ return {
7231
+ admitted: false,
7232
+ code: "PAIRING_BODY_TOO_LARGE",
7233
+ error: `A signed body may be at most ${SIGNED_BODY_MAX_CHARS} characters.`
7234
+ };
7235
+ }
7236
+ if (verdict === "target_too_long") {
7237
+ return {
7238
+ admitted: false,
7239
+ code: "PAIRING_TARGET_TOO_LONG",
7240
+ error: `A signed target may be at most ${SIGNED_TARGET_MAX_CHARS} characters.`
7241
+ };
7242
+ }
7243
+ return required;
7244
+ }
7245
+ corsHeaders() {
7246
+ if (!this.config.cors) return {};
7247
+ return {
7248
+ "Access-Control-Allow-Origin": this.config.allowedOrigins ? this.config.allowedOrigins.join(",") : "*",
7249
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
7250
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
7251
+ };
7252
+ }
7253
+ /** No CORS header: a page must not be able to read what the gate says. */
7254
+ refusal(code, error3, extra = {}) {
7255
+ const body = {
7256
+ success: false,
7257
+ error: error3,
7258
+ code,
7259
+ timestamp: Date.now(),
7260
+ ...extra.data ? { data: extra.data } : {}
7261
+ };
7262
+ return {
7263
+ status: extra.status ?? httpStatusForResponse(body),
7264
+ headers: { "Content-Type": "application/json" },
7265
+ body: JSON.stringify(body)
7266
+ };
7267
+ }
7268
+ /**
7269
+ * Open health. An admitted caller gets the full answer. Anyone else gets what
7270
+ * discovery needs and nothing about the app's contents, and that answer is
7271
+ * built without going through `handleRequest`, whose keep-awake auto-arm is no
7272
+ * business of an anonymous LAN probe. It carries no CORS header, so a web page
7273
+ * cannot read the stable device id.
7274
+ */
7275
+ async handleNetworkHealth(request, admission) {
7276
+ const pairing = {
7277
+ required: !(admission.admitted && (admission.as === "loopback" || admission.as === "unauthenticated-network")),
7278
+ authenticated: admission.admitted && admission.as !== "unauthenticated-network"
7279
+ };
7280
+ const identity = {
7281
+ deviceId: this.config.deviceId ?? null,
7282
+ serverTime: Date.now(),
7283
+ pairing
7284
+ };
7285
+ if (admission.admitted) {
7286
+ const response = await this.handleRequest(request);
7287
+ const parsed = JSON.parse(response.body);
7288
+ if (!parsed.success || !parsed.data) return response;
7289
+ return {
7290
+ ...response,
7291
+ body: JSON.stringify({ ...parsed, data: { ...parsed.data, ...identity } })
7292
+ };
7293
+ }
7294
+ const health = await this.handlers.health({ params: {}, query: {}, body: void 0 });
7295
+ const full = health.data ?? {};
7296
+ const trimmed = { status: full.status, ...identity };
7297
+ if (full.uiBridge) {
7298
+ const { version, appId, appName, appType, framework } = full.uiBridge;
7299
+ trimmed.uiBridge = { version, appId, appName, appType, framework };
7300
+ }
7301
+ return {
7302
+ status: 200,
7303
+ headers: { "Content-Type": "application/json" },
7304
+ body: JSON.stringify({ success: true, data: trimmed, timestamp: Date.now() })
7305
+ };
7306
+ }
7307
+ /**
7308
+ * `POST /ui-bridge/pair`, the only way to a token, and only ever from here.
7309
+ * Its answer carries no CORS header, because no web page has any business
7310
+ * reading a token, and it answers only after the store write has finished. A web
7311
+ * page is refused first of all, so it cannot burn an attempt or the armed code.
7312
+ */
7313
+ async handlePair(request) {
7314
+ if (carriesWebContext(request)) {
7315
+ return this.refusal("PAIRING_WEB_CALLER_REFUSED", "A web page cannot pair with this device.");
7316
+ }
7317
+ if (!this.pairing) {
7318
+ return this.refusal(
7319
+ "PAIRING_UNCONFIGURED",
7320
+ "This app has not configured device pairing, so it cannot issue a token."
7321
+ );
7322
+ }
7323
+ const deviceId = this.config.deviceId;
7324
+ if (!deviceId) {
7325
+ return this.refusal("PAIRING_UNAVAILABLE", "The device id is not available yet. Try again.");
7326
+ }
7327
+ const outcome = await this.pairing.pair({
7328
+ trustedLoopback: this.isTrustedLoopback(request),
7329
+ peer: request.remoteAddress,
7330
+ body: request.body
7331
+ });
7332
+ if (!outcome.ok) return this.refusal(outcome.code, outcome.error);
7333
+ const body = {
7334
+ success: true,
7335
+ data: { token: outcome.token, deviceId, serverTime: Date.now() },
7336
+ timestamp: Date.now()
7337
+ };
7338
+ return {
7339
+ status: 200,
7340
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
7341
+ body: JSON.stringify(body)
7342
+ };
7343
+ }
7344
+ /**
7345
+ * Arm a 6-digit code for a LAN pairing. In memory and single use. It stops
7346
+ * working after about 2 minutes, or after 5 wrong attempts from anyone. Rejects
7347
+ * with a `PairingError` when no store is configured or it has not loaded.
7348
+ */
7349
+ async armPairingCode() {
7350
+ return this.requirePairing().arm();
7351
+ }
7352
+ /** Cancel a live code. */
7353
+ disarmPairingCode() {
7354
+ this.pairing?.disarm();
7355
+ }
7356
+ /** What Settings shows, including the peer that consumed the last code. */
7357
+ getPairingState() {
7358
+ return this.pairing?.getState() ?? UNCONFIGURED_PAIRING_STATE;
7359
+ }
7360
+ /**
7361
+ * Replace the token, so every signature made with the old one stops verifying.
7362
+ * There are no network sockets to close, because none is ever admitted.
7363
+ */
7364
+ async revokePairing() {
7365
+ await this.requirePairing().revoke();
7366
+ }
7367
+ requirePairing() {
7368
+ if (!this.pairing) {
7369
+ throw new PairingError("PAIRING_UNCONFIGURED", "No pairingStore is configured.");
7370
+ }
7371
+ return this.pairing;
7372
+ }
7373
+ /**
7374
+ * Handle incoming HTTP request, past the gate.
6479
7375
  *
6480
7376
  * Exposed as public so that CloudRelayClient can call it directly (without
6481
- * loopback TCP overhead) when processing tunneled requests.
7377
+ * loopback TCP overhead) when processing tunneled requests. Anything reached
7378
+ * from a socket goes through `handleNetworkRequest` first.
6482
7379
  */
6483
7380
  async handleRequest(request) {
6484
7381
  const headers = {
6485
- "Content-Type": "application/json"
7382
+ "Content-Type": "application/json",
7383
+ ...this.corsHeaders()
6486
7384
  };
6487
- if (this.config.cors) {
6488
- headers["Access-Control-Allow-Origin"] = this.config.allowedOrigins ? this.config.allowedOrigins.join(",") : "*";
6489
- headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS";
6490
- headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization";
6491
- }
6492
7385
  if (request.method === "OPTIONS") {
6493
7386
  return { status: 204, headers, body: "" };
6494
7387
  }
@@ -6958,7 +7851,6 @@ var DeviceAnnouncer = class {
6958
7851
  */
6959
7852
  this.publishInFlight = false;
6960
7853
  this.config = config;
6961
- this.pairingToken = generateHex(16);
6962
7854
  }
6963
7855
  // ── mDNS ──────────────────────────────────────────────────────────────────
6964
7856
  /**
@@ -6994,8 +7886,7 @@ var DeviceAnnouncer = class {
6994
7886
  result = await zeroconf.publishService("uibridge", "tcp", "local.", serviceName, port, {
6995
7887
  device_id: this.config.deviceId,
6996
7888
  app_id: this.config.appId,
6997
- version: this.config.version ?? "unknown",
6998
- pairing_token: this.pairingToken
7889
+ version: this.config.version ?? "unknown"
6999
7890
  });
7000
7891
  } finally {
7001
7892
  this.publishInFlight = false;
@@ -7119,13 +8010,6 @@ var DeviceAnnouncer = class {
7119
8010
  this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
7120
8011
  }
7121
8012
  };
7122
- function generateHex(bytes) {
7123
- const arr = new Uint8Array(bytes);
7124
- for (let i = 0; i < bytes; i++) {
7125
- arr[i] = Math.floor(Math.random() * 256);
7126
- }
7127
- return Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
7128
- }
7129
8013
  function getPlatform() {
7130
8014
  return reactNative.Platform?.OS ?? "unknown";
7131
8015
  }
@@ -7497,6 +8381,26 @@ function readWindowViewport() {
7497
8381
  const win = reactNative.Dimensions.get("window");
7498
8382
  return { width: win.width, height: win.height };
7499
8383
  }
8384
+ function useStablePairingStore(store) {
8385
+ const latestRef = react.useRef(store);
8386
+ react.useEffect(() => {
8387
+ latestRef.current = store;
8388
+ });
8389
+ const hasStore = store !== void 0;
8390
+ return react.useMemo(() => {
8391
+ if (!hasStore) return void 0;
8392
+ const current = () => {
8393
+ if (!latestRef.current) throw new Error("pairingStore was removed");
8394
+ return latestRef.current;
8395
+ };
8396
+ return {
8397
+ get: () => current().get(),
8398
+ set: (token) => current().set(token),
8399
+ clear: () => current().clear(),
8400
+ randomBytes: (n) => current().randomBytes(n)
8401
+ };
8402
+ }, [hasStore]);
8403
+ }
7500
8404
  function UIBridgeNativeProvider({
7501
8405
  children,
7502
8406
  features: featuresProp = EMPTY_FEATURES,
@@ -7510,10 +8414,13 @@ function UIBridgeNativeProvider({
7510
8414
  cloudRelayConfig,
7511
8415
  enableMdnsAnnounce,
7512
8416
  zeroconf,
7513
- deviceId
8417
+ deviceId,
8418
+ pairingStore: pairingStoreProp,
8419
+ allowUnauthenticatedNetwork
7514
8420
  }) {
7515
8421
  const features = useValueStable(featuresProp);
7516
8422
  const config = useValueStable(configProp);
8423
+ const pairingStore = useStablePairingStore(pairingStoreProp);
7517
8424
  const registryRef = react.useRef(null);
7518
8425
  const executorRef = react.useRef(null);
7519
8426
  const modalDetectorRef = react.useRef(null);
@@ -7544,6 +8451,11 @@ function UIBridgeNativeProvider({
7544
8451
  const eventBridgeRef = react.useRef(null);
7545
8452
  const announcerRef = react.useRef(null);
7546
8453
  const cloudRelayRef = react.useRef(null);
8454
+ const deviceIdRef = react.useRef(deviceId);
8455
+ react.useEffect(() => {
8456
+ deviceIdRef.current = deviceId;
8457
+ serverRef.current?.setDeviceId(deviceId);
8458
+ }, [deviceId]);
7547
8459
  const startServer = react.useCallback(async () => {
7548
8460
  if (!features.server) return;
7549
8461
  if (!serverAdapter) {
@@ -7564,7 +8476,10 @@ function UIBridgeNativeProvider({
7564
8476
  // Injected device-viewport getter for POST /control/page-health and the
7565
8477
  // design handlers. Same source as the registry's clipping bounds — see
7566
8478
  // `readWindowViewport`.
7567
- viewportProvider: readWindowViewport
8479
+ viewportProvider: readWindowViewport,
8480
+ pairingStore,
8481
+ allowUnauthenticatedNetwork,
8482
+ deviceId: deviceIdRef.current
7568
8483
  });
7569
8484
  server.setAdapter(serverAdapter);
7570
8485
  if (navigationProvider) {
@@ -7606,8 +8521,9 @@ function UIBridgeNativeProvider({
7606
8521
  server.abortWaitersForConnection(connId);
7607
8522
  eventBridge.removeConnection(connId);
7608
8523
  };
8524
+ wsAdapter.onWebSocketUpgrade = (req) => server.handleWebSocketUpgrade(req);
7609
8525
  wsAdapter.onWebSocketMessage = async (connId, message) => {
7610
- return server.handleWebSocketMessage(connId, message);
8526
+ return server.handleNetworkWebSocketMessage(connId, message);
7611
8527
  };
7612
8528
  eventBridge.start();
7613
8529
  eventBridgeRef.current = eventBridge;
@@ -7615,6 +8531,7 @@ function UIBridgeNativeProvider({
7615
8531
  try {
7616
8532
  await server.start();
7617
8533
  serverRef.current = server;
8534
+ server.setDeviceId(deviceIdRef.current);
7618
8535
  setServerRunning(true);
7619
8536
  } catch (err) {
7620
8537
  console.warn("[ui-bridge-native] Failed to start HTTP server:", err);
@@ -7631,7 +8548,9 @@ function UIBridgeNativeProvider({
7631
8548
  navigationProvider,
7632
8549
  screenshotProvider,
7633
8550
  keepAwakeProvider,
7634
- routeProvider
8551
+ routeProvider,
8552
+ pairingStore,
8553
+ allowUnauthenticatedNetwork
7635
8554
  ]);
7636
8555
  const stopServer = react.useCallback(() => {
7637
8556
  if (eventBridgeRef.current) {
@@ -7739,6 +8658,25 @@ function UIBridgeNativeProvider({
7739
8658
  const getCurrentRoute = react.useCallback(() => {
7740
8659
  return routeProvider?.getCurrentRoute() ?? null;
7741
8660
  }, [routeProvider]);
8661
+ const armPairingCode = react.useCallback(async () => {
8662
+ if (!serverRef.current) {
8663
+ throw new PairingError("PAIRING_UNCONFIGURED", "The UI Bridge server is not running.");
8664
+ }
8665
+ return serverRef.current.armPairingCode();
8666
+ }, []);
8667
+ const disarmPairingCode = react.useCallback(() => {
8668
+ serverRef.current?.disarmPairingCode();
8669
+ }, []);
8670
+ const getPairingState = react.useCallback(
8671
+ () => serverRef.current?.getPairingState() ?? UNCONFIGURED_PAIRING_STATE,
8672
+ []
8673
+ );
8674
+ const revokePairing = react.useCallback(async () => {
8675
+ if (!serverRef.current) {
8676
+ throw new PairingError("PAIRING_UNCONFIGURED", "The UI Bridge server is not running.");
8677
+ }
8678
+ await serverRef.current.revokePairing();
8679
+ }, []);
7742
8680
  react.useEffect(() => {
7743
8681
  if (!routeProvider) return;
7744
8682
  let lastRoute = routeProvider.getCurrentRoute();
@@ -7791,7 +8729,11 @@ function UIBridgeNativeProvider({
7791
8729
  serverRunning,
7792
8730
  startServer,
7793
8731
  stopServer,
7794
- getCurrentRoute
8732
+ getCurrentRoute,
8733
+ armPairingCode,
8734
+ disarmPairingCode,
8735
+ getPairingState,
8736
+ revokePairing
7795
8737
  }),
7796
8738
  [
7797
8739
  features,
@@ -7809,7 +8751,11 @@ function UIBridgeNativeProvider({
7809
8751
  serverRunning,
7810
8752
  startServer,
7811
8753
  stopServer,
7812
- getCurrentRoute
8754
+ getCurrentRoute,
8755
+ armPairingCode,
8756
+ disarmPairingCode,
8757
+ getPairingState,
8758
+ revokePairing
7813
8759
  ]
7814
8760
  );
7815
8761
  return /* @__PURE__ */ jsxRuntime.jsx(UIBridgeNativeContext, { value: contextValue, children });
@@ -8914,6 +9860,12 @@ var UI_BRIDGE_NATIVE_ROUTES = {
8914
9860
  method: "GET",
8915
9861
  path: "/ui-bridge/health",
8916
9862
  description: "Health check"
9863
+ },
9864
+ // Device pairing
9865
+ PAIR: {
9866
+ method: "POST",
9867
+ path: "/ui-bridge/pair",
9868
+ description: "Get the pairing token: loopback with no code, otherwise body { code }. Direct HTTP only"
8917
9869
  }
8918
9870
  };
8919
9871
 
@@ -9731,11 +10683,19 @@ exports.NATIVE_STANDARD_ACTION_EFFECTS = NATIVE_STANDARD_ACTION_EFFECTS;
9731
10683
  exports.NativeActionError = NativeActionError;
9732
10684
  exports.NativeUIBridgeRegistry = NativeUIBridgeRegistry;
9733
10685
  exports.NativeUIBridgeServer = NativeUIBridgeServer;
10686
+ exports.PAIRING_CODE_MAX_ATTEMPTS = PAIRING_CODE_MAX_ATTEMPTS;
10687
+ exports.PAIRING_CODE_TTL_MS = PAIRING_CODE_TTL_MS;
10688
+ exports.PairingError = PairingError;
10689
+ exports.SIGNATURE_WINDOW_MS = SIGNATURE_WINDOW_MS;
10690
+ exports.SIGNED_BODY_MAX_CHARS = SIGNED_BODY_MAX_CHARS;
10691
+ exports.SIGNED_METHOD_MAX_CHARS = SIGNED_METHOD_MAX_CHARS;
10692
+ exports.SIGNED_TARGET_MAX_CHARS = SIGNED_TARGET_MAX_CHARS;
9734
10693
  exports.State = State;
9735
10694
  exports.ToastCapture = ToastCapture;
9736
10695
  exports.TransitionTo = TransitionTo;
9737
10696
  exports.UIBridgeInspector = UIBridgeInspector;
9738
10697
  exports.UIBridgeNativeProvider = UIBridgeNativeProvider;
10698
+ exports.UI_BRIDGE_AUTH_HEADER = UI_BRIDGE_AUTH_HEADER;
9739
10699
  exports.UI_BRIDGE_NATIVE_ROUTES = UI_BRIDGE_NATIVE_ROUTES;
9740
10700
  exports.UndoTracker = UndoTracker;
9741
10701
  exports.WebSocketConnection = WebSocketConnection;
@@ -9743,6 +10703,7 @@ exports.WebSocketEventBridge = WebSocketEventBridge;
9743
10703
  exports.bindWithRetry = bindWithRetry;
9744
10704
  exports.buildTreePath = buildTreePath;
9745
10705
  exports.buildUpgradeResponse = buildUpgradeResponse;
10706
+ exports.canonicalSignatureInput = canonicalSignatureInput;
9746
10707
  exports.captureNativeResponsiveSnapshot = captureNativeResponsiveSnapshot;
9747
10708
  exports.captureNativeStateVariations = captureNativeStateVariations;
9748
10709
  exports.computeAcceptKey = computeAcceptKey;
@@ -9771,6 +10732,8 @@ exports.isAddrInUseError = isAddrInUseError;
9771
10732
  exports.isEmptyRect = isEmptyRect;
9772
10733
  exports.isExpoRouterUnmatched = isExpoRouterUnmatched;
9773
10734
  exports.isHiddenFromAccessibility = isHiddenFromAccessibility;
10735
+ exports.isLoopbackAddress = isLoopbackAddress;
10736
+ exports.isLoopbackHost = isLoopbackHost;
9774
10737
  exports.mapNativeStyleToExtended = mapNativeStyleToExtended;
9775
10738
  exports.matchesIdentifier = matchesIdentifier;
9776
10739
  exports.nativeStandardActionEffect = nativeStandardActionEffect;
@@ -9792,6 +10755,7 @@ exports.roleForType = roleForType;
9792
10755
  exports.runAbortable = runAbortable;
9793
10756
  exports.setDefaultParamValidationMode = setDefaultParamValidationMode;
9794
10757
  exports.setGlobalRegistry = setGlobalRegistry;
10758
+ exports.signUIBridgeRequest = signUIBridgeRequest;
9795
10759
  exports.useRouteAwareness = useRouteAwareness;
9796
10760
  exports.useToastRecorder = useToastRecorder;
9797
10761
  exports.useUIBridge = useUIBridge;