@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
@@ -1,4 +1,4 @@
1
- import { createContext, useRef, useState, useCallback, useEffect, useMemo, use } from 'react';
1
+ import { createContext, useRef, useState, useEffect, useCallback, useMemo, use } from 'react';
2
2
  import { PixelRatio, Dimensions, StyleSheet, Platform } from 'react-native';
3
3
  import { jsx, Fragment } from 'react/jsx-runtime';
4
4
 
@@ -119,8 +119,7 @@ function utf8Bytes(input) {
119
119
  return Uint8Array.from(out);
120
120
  }
121
121
  var rotr = (x, n) => x >>> n | x << 32 - n;
122
- function sha256Hex(input) {
123
- const bytes = utf8Bytes(input);
122
+ function sha256Bytes(bytes) {
124
123
  const blockBytes = Math.ceil((bytes.length + 9) / 64) * 64;
125
124
  const buffer = new Uint8Array(blockBytes);
126
125
  buffer.set(bytes);
@@ -164,7 +163,38 @@ function sha256Hex(input) {
164
163
  h[6] = h[6] + g | 0;
165
164
  h[7] = h[7] + hh | 0;
166
165
  }
167
- return h.map((word) => (word >>> 0).toString(16).padStart(8, "0")).join("");
166
+ const digest = new Uint8Array(32);
167
+ const digestView = new DataView(digest.buffer);
168
+ h.forEach((word, i) => digestView.setUint32(i * 4, word >>> 0));
169
+ return digest;
170
+ }
171
+ function bytesToHex(bytes) {
172
+ let out = "";
173
+ for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
174
+ return out;
175
+ }
176
+ function sha256Hex(input) {
177
+ return bytesToHex(sha256Bytes(utf8Bytes(input)));
178
+ }
179
+ var SHA256_BLOCK_BYTES = 64;
180
+ function hmacSha256(key, message) {
181
+ const normalizedKey = key.length > SHA256_BLOCK_BYTES ? sha256Bytes(key) : key;
182
+ const inner = new Uint8Array(SHA256_BLOCK_BYTES + message.length);
183
+ const outer = new Uint8Array(SHA256_BLOCK_BYTES + 32);
184
+ for (let i = 0; i < SHA256_BLOCK_BYTES; i++) {
185
+ const keyByte = i < normalizedKey.length ? normalizedKey[i] : 0;
186
+ inner[i] = keyByte ^ 54;
187
+ outer[i] = keyByte ^ 92;
188
+ }
189
+ inner.set(message, SHA256_BLOCK_BYTES);
190
+ outer.set(sha256Bytes(inner), SHA256_BLOCK_BYTES);
191
+ return sha256Bytes(outer);
192
+ }
193
+ function constantTimeEqual(a, b) {
194
+ if (a.length !== b.length) return false;
195
+ let diff = 0;
196
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
197
+ return diff === 0;
168
198
  }
169
199
 
170
200
  // src/core/vision-fields.ts
@@ -3420,7 +3450,14 @@ var UNKNOWN_CODES = [
3420
3450
  "below_confidence_floor",
3421
3451
  "model_reply_unparseable",
3422
3452
  "needs_multi_frame_input",
3423
- "stale_input"
3453
+ "stale_input",
3454
+ "frontier_open",
3455
+ "ledger_schema_absent",
3456
+ "ledger_write_failing",
3457
+ "no_observations",
3458
+ "to_node_unobserved",
3459
+ "performance_observer_unsupported",
3460
+ "no_event_before_timeout"
3424
3461
  ];
3425
3462
  function isUnknownCode(value) {
3426
3463
  return typeof value === "string" && UNKNOWN_CODES.includes(value);
@@ -3610,7 +3647,7 @@ var NATIVE_PAGE_HEALTH_PRODUCER_ID = "sdk-native/page-health";
3610
3647
  function defaultProducer() {
3611
3648
  return {
3612
3649
  id: NATIVE_PAGE_HEALTH_PRODUCER_ID,
3613
- version: "0.8.0"
3650
+ version: "0.11.0"
3614
3651
  };
3615
3652
  }
3616
3653
  function pageHealthUnknown(code, detail, options = {}) {
@@ -4513,6 +4550,16 @@ function createServerHandlers(registry, executor, config) {
4513
4550
  // the envelope agree; an unmounted path answers 404 instead. Each error
4514
4551
  // message names an in-tree mobile replacement so operators aren't left
4515
4552
  // guessing what to use instead.
4553
+ // The real pairing route lives in the server's network entry, ahead of this
4554
+ // table, because only that entry knows the socket peer. This answer is what
4555
+ // JSON-RPC over a WebSocket and the cloud relay get, so neither can mint a
4556
+ // token or spend a code attempt.
4557
+ pair: async () => {
4558
+ return error2(
4559
+ "Pairing is served only on a direct HTTP connection to the device, not over JSON-RPC or the cloud relay.",
4560
+ "NOT_SUPPORTED"
4561
+ );
4562
+ },
4516
4563
  aiForms: async () => {
4517
4564
  return error2(
4518
4565
  '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.',
@@ -4907,7 +4954,7 @@ function createServerHandlers(registry, executor, config) {
4907
4954
  };
4908
4955
  if (config?.appInfo) {
4909
4956
  response.uiBridge = {
4910
- version: "0.8.0",
4957
+ version: "0.11.0",
4911
4958
  ...config.appInfo,
4912
4959
  capabilities: ["elements", "components", "actions", "design"],
4913
4960
  elementCount: stats.elements,
@@ -4919,6 +4966,516 @@ function createServerHandlers(registry, executor, config) {
4919
4966
  };
4920
4967
  }
4921
4968
 
4969
+ // src/server/pairing.ts
4970
+ var UI_BRIDGE_AUTH_HEADER = "X-UI-Bridge-Auth";
4971
+ var SIGNATURE_WINDOW_MS = 3e5;
4972
+ var PAIRING_CODE_TTL_MS = 12e4;
4973
+ var PAIRING_CODE_MAX_ATTEMPTS = 5;
4974
+ var SIGNED_BODY_MAX_CHARS = 256 * 1024;
4975
+ var SIGNED_METHOD_MAX_CHARS = 16;
4976
+ var SIGNED_TARGET_MAX_CHARS = 8192;
4977
+ var TOKEN_BYTES = 32;
4978
+ var NONCE_CACHE_MAX = 4096;
4979
+ var STORE_TIMEOUT_MS = 1e4;
4980
+ var CODE_SAMPLE_ATTEMPTS = 32;
4981
+ var CODE_SAMPLE_LIMIT = Math.floor(4294967296 / 1e6) * 1e6;
4982
+ var CLOCK_STEP_TOLERANCE_MS = 5e3;
4983
+ function monotonicNow() {
4984
+ const clock = globalThis.performance;
4985
+ return typeof clock?.now === "function" ? clock.now() : null;
4986
+ }
4987
+ var PairingError = class extends Error {
4988
+ constructor(code, message) {
4989
+ super(message);
4990
+ this.code = code;
4991
+ this.name = "PairingError";
4992
+ }
4993
+ };
4994
+ function canonicalSignatureInput(parts) {
4995
+ return [parts.method, parts.pathAndQuery, parts.ts, parts.nonce, parts.bodySha256Hex].join("\n");
4996
+ }
4997
+ function signatureHex(token, parts) {
4998
+ const input = canonicalSignatureInput({
4999
+ method: parts.method,
5000
+ pathAndQuery: parts.pathAndQuery,
5001
+ ts: parts.ts,
5002
+ nonce: parts.nonce,
5003
+ bodySha256Hex: sha256Hex(parts.body)
5004
+ });
5005
+ return bytesToHex(hmacSha256(utf8Bytes(token), utf8Bytes(input)));
5006
+ }
5007
+ function headerValues(headers, name) {
5008
+ if (!headers) return [];
5009
+ const wanted = name.toLowerCase();
5010
+ return Object.keys(headers).filter((key) => key.toLowerCase() === wanted).map((key) => headers[key]);
5011
+ }
5012
+ function ipv4Octets(address) {
5013
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address);
5014
+ if (!match) return null;
5015
+ const octets = match.slice(1).map(Number);
5016
+ return octets.every((octet) => octet <= 255) ? octets : null;
5017
+ }
5018
+ function isLoopbackIpv4(address) {
5019
+ return ipv4Octets(address)?.[0] === 127;
5020
+ }
5021
+ function parseIpv6(address) {
5022
+ if (!address.includes(":")) return null;
5023
+ let text = address;
5024
+ const trailing = [];
5025
+ const lastColon = text.lastIndexOf(":");
5026
+ const tail = text.slice(lastColon + 1);
5027
+ if (tail.includes(".")) {
5028
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(tail);
5029
+ if (!match) return null;
5030
+ const octets = match.slice(1).map(Number);
5031
+ if (octets.some((octet) => octet > 255)) return null;
5032
+ trailing.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
5033
+ text = text.slice(0, lastColon + 1);
5034
+ if (!text.endsWith("::")) text = text.slice(0, -1);
5035
+ }
5036
+ const groupsOf = (part) => {
5037
+ if (part === "") return [];
5038
+ const groups = [];
5039
+ for (const group of part.split(":")) {
5040
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
5041
+ groups.push(parseInt(group, 16));
5042
+ }
5043
+ return groups;
5044
+ };
5045
+ const gapAt = text.indexOf("::");
5046
+ if (gapAt !== text.lastIndexOf("::")) return null;
5047
+ const width = 8 - trailing.length;
5048
+ if (gapAt === -1) {
5049
+ const groups = groupsOf(text);
5050
+ return groups && groups.length === width ? [...groups, ...trailing] : null;
5051
+ }
5052
+ const head = groupsOf(text.slice(0, gapAt));
5053
+ const rest = groupsOf(text.slice(gapAt + 2));
5054
+ if (!head || !rest || head.length + rest.length >= width) return null;
5055
+ const zeros = new Array(width - head.length - rest.length).fill(0);
5056
+ return [...head, ...zeros, ...rest, ...trailing];
5057
+ }
5058
+ function isLoopbackIpv6(address) {
5059
+ const zone = address.indexOf("%");
5060
+ const groups = parseIpv6(zone === -1 ? address : address.slice(0, zone));
5061
+ if (!groups) return false;
5062
+ const leadingZeros = (count) => groups.slice(0, count).every((g) => g === 0);
5063
+ if (leadingZeros(7) && groups[7] === 1) return true;
5064
+ return leadingZeros(5) && groups[5] === 65535 && groups[6] >> 8 === 127;
5065
+ }
5066
+ function isLoopbackAddress(address) {
5067
+ return isLoopbackIpv4(address) || isLoopbackIpv6(address);
5068
+ }
5069
+ function isLoopbackHost(host) {
5070
+ const value = host.trim().toLowerCase();
5071
+ const bracketed = /^\[([^\]]+)\](?::(\d{1,5}))?$/.exec(value);
5072
+ if (bracketed) {
5073
+ return isLoopbackIpv6(bracketed[1]) && isValidPort(bracketed[2]);
5074
+ }
5075
+ const plain = /^([^:]+)(?::(\d{1,5}))?$/.exec(value);
5076
+ if (!plain) return false;
5077
+ return (plain[1] === "localhost" || isLoopbackIpv4(plain[1])) && isValidPort(plain[2]);
5078
+ }
5079
+ function isValidPort(port) {
5080
+ return port === void 0 || Number(port) <= 65535;
5081
+ }
5082
+ function isUnrebindableHost(host) {
5083
+ const value = host.trim().toLowerCase();
5084
+ const bracketed = /^\[([^\]%]+)(?:%[^\]]*)?\](?::(\d{1,5}))?$/.exec(value);
5085
+ if (bracketed) return parseIpv6(bracketed[1]) !== null && isValidPort(bracketed[2]);
5086
+ const plain = /^([^:]+)(?::(\d{1,5}))?$/.exec(value);
5087
+ if (!plain) return false;
5088
+ const named = plain[1] === "localhost" || plain[1].endsWith(".local") || ipv4Octets(plain[1]) !== null;
5089
+ return named && isValidPort(plain[2]);
5090
+ }
5091
+ function carriesWebContext(request) {
5092
+ const { headers } = request;
5093
+ if (headerValues(headers, "origin").length > 0) return true;
5094
+ if (headerValues(headers, "referer").length > 0) return true;
5095
+ return headerValues(headers, "sec-fetch-site").some(
5096
+ (site) => site.trim().toLowerCase() !== "none"
5097
+ );
5098
+ }
5099
+ function isTrustedLoopbackRequest(request) {
5100
+ if (!request.remoteAddress || !isLoopbackAddress(request.remoteAddress)) return false;
5101
+ if (carriesWebContext(request)) return false;
5102
+ const hosts = headerValues(request.headers, "host");
5103
+ return hosts.length > 0 && hosts.every(isLoopbackHost);
5104
+ }
5105
+ function isWebReachableRequest(request) {
5106
+ if (carriesWebContext(request)) return true;
5107
+ const hosts = headerValues(request.headers, "host");
5108
+ return hosts.length === 0 || !hosts.every(isUnrebindableHost);
5109
+ }
5110
+ async function bounded(work) {
5111
+ let timer;
5112
+ try {
5113
+ return await Promise.race([
5114
+ Promise.resolve().then(work),
5115
+ new Promise((_, reject) => {
5116
+ timer = setTimeout(() => reject(new Error("store call timed out")), STORE_TIMEOUT_MS);
5117
+ })
5118
+ ]);
5119
+ } finally {
5120
+ if (timer !== void 0) clearTimeout(timer);
5121
+ }
5122
+ }
5123
+ var UNAVAILABLE = {
5124
+ ok: false,
5125
+ code: "PAIRING_UNAVAILABLE",
5126
+ error: "The pairing store is not available yet. Try again."
5127
+ };
5128
+ var TOKEN_NOT_LOADED = /* @__PURE__ */ Symbol("token-not-loaded");
5129
+ var PairingAuthority = class {
5130
+ constructor(store) {
5131
+ this.store = store;
5132
+ this.armedCode = null;
5133
+ /** A correct code waiting on the store step, restored if that step fails. */
5134
+ this.heldCode = null;
5135
+ this.disarmReason = null;
5136
+ this.consumedBy = null;
5137
+ this.consumedAt = null;
5138
+ this.lastWrite = Promise.resolve();
5139
+ /** Nonce to its timestamp, oldest insertion first. */
5140
+ this.nonces = /* @__PURE__ */ new Map();
5141
+ /**
5142
+ * The oldest timestamp still accepted. A nonce this process did not record
5143
+ * cannot be checked for a replay, so a signature from before it existed is
5144
+ * refused: otherwise an app restart, which empties the cache, would reopen
5145
+ * the whole window to a captured request. Eviction raises it for the same
5146
+ * reason. A clock step re-bases it to the new now, or a clock set back past it
5147
+ * would refuse every signature until the app restarted. That leaves one gap with
5148
+ * two arms: a request captured before a step back can replay once, if its
5149
+ * timestamp is at or above the new now and this process does not remember its
5150
+ * nonce, because it has forgotten it, or because the app restarted since and it
5151
+ * never recorded it. Without `performance.now` no step is seen, and the floor
5152
+ * holds until the clock passes it.
5153
+ */
5154
+ this.oldestAcceptedTs = Date.now();
5155
+ /**
5156
+ * The wall clock minus the monotonic clock at the last re-base. It moves only
5157
+ * then, so small steps add up until they cross the tolerance, and a clock step
5158
+ * leaves the floor at most twice the tolerance ahead of the clock: the clock can
5159
+ * swing from just over the baseline to just under it without crossing it.
5160
+ */
5161
+ this.clockOffset = this.readClockOffset();
5162
+ /** Moves when a revoke ends, so a read of the old token that spans one cannot admit. */
5163
+ this.tokenGeneration = 0;
5164
+ /** Revokes under way. The old token may already be dying, so nothing verifies. */
5165
+ this.revokesInFlight = 0;
5166
+ /** A revoke failed in a way that may have left the old token in the store. */
5167
+ this.storeSuspect = false;
5168
+ }
5169
+ /**
5170
+ * The stored token, `null` when there is none, or `TOKEN_NOT_LOADED`. Only
5171
+ * `readStore` tells these apart: an empty or non-string value is never a
5172
+ * token, because an empty key would verify anything signed with it.
5173
+ */
5174
+ async readToken() {
5175
+ return this.storeSuspect ? TOKEN_NOT_LOADED : this.readStore();
5176
+ }
5177
+ async readStore() {
5178
+ try {
5179
+ const token = await bounded(() => this.store.get());
5180
+ if (token === null) return null;
5181
+ return typeof token === "string" && token.length > 0 ? token : TOKEN_NOT_LOADED;
5182
+ } catch {
5183
+ return TOKEN_NOT_LOADED;
5184
+ }
5185
+ }
5186
+ async randomBytes(n) {
5187
+ const unusable = new PairingError(
5188
+ "PAIRING_UNAVAILABLE",
5189
+ "The secure random source returned no usable bytes."
5190
+ );
5191
+ let bytes;
5192
+ try {
5193
+ bytes = await bounded(() => this.store.randomBytes(n));
5194
+ } catch {
5195
+ throw unusable;
5196
+ }
5197
+ if (!ArrayBuffer.isView(bytes) || bytes.length !== n || bytes.every((byte) => byte === 0)) {
5198
+ throw unusable;
5199
+ }
5200
+ return bytes;
5201
+ }
5202
+ async mintAndStoreToken() {
5203
+ const token = bytesToHex(await this.randomBytes(TOKEN_BYTES));
5204
+ await bounded(() => this.store.set(token));
5205
+ return token;
5206
+ }
5207
+ /** Run token writes one at a time, so two first pairs cannot mint two tokens. */
5208
+ exclusive(work) {
5209
+ const run = this.lastWrite.then(work, work);
5210
+ this.lastWrite = run.catch(() => void 0);
5211
+ return run;
5212
+ }
5213
+ /** Check a `<ts>.<nonce>.<sig>` value against this request and the stored token. */
5214
+ async verifySignature(request, header) {
5215
+ const match = /^(\d{1,16})\.([A-Za-z0-9_-]{16,64})\.([0-9a-f]{64})$/.exec(header);
5216
+ if (!match) return "refused";
5217
+ const [, ts, nonce, sig] = match;
5218
+ const tsMs = Number(ts);
5219
+ this.noticeClockStep();
5220
+ if (Math.abs(Date.now() - tsMs) > SIGNATURE_WINDOW_MS) return "timestamp_refused";
5221
+ if (tsMs < this.oldestAcceptedTs) return "timestamp_refused";
5222
+ const pathAndQuery = signedTarget(request);
5223
+ const body = signedBody(request);
5224
+ if (pathAndQuery === null || body === null) return "refused";
5225
+ if (body.length > SIGNED_BODY_MAX_CHARS) return "body_too_large";
5226
+ if (request.method.length > SIGNED_METHOD_MAX_CHARS) return "target_too_long";
5227
+ if (pathAndQuery.length > SIGNED_TARGET_MAX_CHARS) return "target_too_long";
5228
+ const generation = this.tokenGeneration;
5229
+ const token = await this.readToken();
5230
+ if (this.revokesInFlight > 0) return "refused";
5231
+ if (token === TOKEN_NOT_LOADED) return "store_unavailable";
5232
+ if (token === null) return "refused";
5233
+ const expected = signatureHex(token, { method: request.method, pathAndQuery, ts, nonce, body });
5234
+ if (!constantTimeEqual(expected, sig)) return "refused";
5235
+ if (generation !== this.tokenGeneration) return "refused";
5236
+ if (this.nonces.has(nonce)) return "refused";
5237
+ this.rememberNonce(nonce, tsMs);
5238
+ return "valid";
5239
+ }
5240
+ readClockOffset() {
5241
+ const monotonic = monotonicNow();
5242
+ return monotonic === null ? null : Date.now() - monotonic;
5243
+ }
5244
+ /**
5245
+ * Re-base the floor when the wall clock has stepped against the monotonic one.
5246
+ * A nonce remembered from above the new now is clamped to just below the new
5247
+ * floor. Left as it is, forgetting it later would raise the floor back to a time
5248
+ * before the step, and the lockout would return when the cache overflowed.
5249
+ */
5250
+ noticeClockStep() {
5251
+ const offset = this.readClockOffset();
5252
+ if (offset === null || this.clockOffset === null) return;
5253
+ if (Math.abs(offset - this.clockOffset) > CLOCK_STEP_TOLERANCE_MS) {
5254
+ this.clockOffset = offset;
5255
+ this.oldestAcceptedTs = Date.now();
5256
+ for (const [nonce, tsMs] of this.nonces) {
5257
+ if (tsMs >= this.oldestAcceptedTs) this.nonces.set(nonce, this.oldestAcceptedTs - 1);
5258
+ }
5259
+ }
5260
+ }
5261
+ rememberNonce(nonce, tsMs) {
5262
+ if (this.nonces.size >= NONCE_CACHE_MAX) {
5263
+ const now = Date.now();
5264
+ for (const [seen, seenTs] of this.nonces) {
5265
+ if (seenTs + SIGNATURE_WINDOW_MS < now) this.forgetNonce(seen, seenTs);
5266
+ }
5267
+ }
5268
+ while (this.nonces.size >= NONCE_CACHE_MAX) {
5269
+ const [seen, seenTs] = this.nonces.entries().next().value;
5270
+ this.forgetNonce(seen, seenTs);
5271
+ }
5272
+ this.nonces.set(nonce, tsMs);
5273
+ }
5274
+ /** Forgetting a nonce must not readmit it, so its timestamp stops being accepted. */
5275
+ forgetNonce(nonce, tsMs) {
5276
+ this.nonces.delete(nonce);
5277
+ this.oldestAcceptedTs = Math.max(this.oldestAcceptedTs, tsMs + 1);
5278
+ }
5279
+ /**
5280
+ * The route's decision. A trusted loopback caller needs no code. Anyone else
5281
+ * spends the armed code, and an unarmed one refuses without comparing.
5282
+ */
5283
+ async pair(input) {
5284
+ if (!input.trustedLoopback) {
5285
+ const refusal = this.spendCode(input.body);
5286
+ if (refusal) return refusal;
5287
+ }
5288
+ const spent = input.trustedLoopback ? null : this.heldCode;
5289
+ const outcome = await this.exclusive(async () => {
5290
+ const existing = await this.readToken();
5291
+ if (existing === TOKEN_NOT_LOADED) return UNAVAILABLE;
5292
+ if (existing !== null) return { ok: true, token: existing };
5293
+ try {
5294
+ return { ok: true, token: await this.mintAndStoreToken() };
5295
+ } catch {
5296
+ return UNAVAILABLE;
5297
+ }
5298
+ });
5299
+ if (spent) this.settleSpentCode(spent, outcome.ok, input.peer);
5300
+ return outcome;
5301
+ }
5302
+ /**
5303
+ * The store step has answered. A failure gives the code back, since the user
5304
+ * typed the right one and the phone let them down, unless the app has since
5305
+ * cancelled it or armed another.
5306
+ */
5307
+ settleSpentCode(spent, paired, peer) {
5308
+ const stillHeld = this.heldCode === spent;
5309
+ if (stillHeld) this.heldCode = null;
5310
+ if (paired) {
5311
+ if (stillHeld) this.disarmReason = "consumed";
5312
+ this.consumedBy = peer ?? "unknown";
5313
+ this.consumedAt = Date.now();
5314
+ } else if (stillHeld) {
5315
+ this.armedCode = spent;
5316
+ }
5317
+ }
5318
+ /** No await in here: the attempt count has to move with the comparison. */
5319
+ spendCode(body) {
5320
+ this.expireCode();
5321
+ const armed = this.armedCode;
5322
+ if (!armed) {
5323
+ return {
5324
+ ok: false,
5325
+ code: "PAIRING_NOT_ARMED",
5326
+ error: "No pairing code is armed. Arm one in the app, or pair over a cable."
5327
+ };
5328
+ }
5329
+ const code = body?.code;
5330
+ if (typeof code !== "string" || !/^\d{6}$/.test(code)) {
5331
+ return {
5332
+ ok: false,
5333
+ code: "PAIRING_CODE_REQUIRED",
5334
+ error: 'Send the 6-digit code shown in the app, as { "code": "123456" }.'
5335
+ };
5336
+ }
5337
+ if (constantTimeEqual(armed.code, code)) {
5338
+ this.armedCode = null;
5339
+ this.heldCode = armed;
5340
+ return null;
5341
+ }
5342
+ armed.wrongAttempts += 1;
5343
+ if (armed.wrongAttempts >= PAIRING_CODE_MAX_ATTEMPTS) {
5344
+ this.armedCode = null;
5345
+ this.disarmReason = "attempts_exhausted";
5346
+ }
5347
+ return { ok: false, code: "PAIRING_CODE_INVALID", error: "That code is not correct." };
5348
+ }
5349
+ /** Either clock ends the code, so a clock set back cannot extend it. */
5350
+ expireCode() {
5351
+ if (!this.armedCode) return;
5352
+ const { expiresAt, monotonicDeadline } = this.armedCode;
5353
+ const monotonic = monotonicNow();
5354
+ const monotonicExpired = monotonic !== null && monotonicDeadline !== null && monotonic >= monotonicDeadline;
5355
+ if (Date.now() >= expiresAt || monotonicExpired) {
5356
+ this.armedCode = null;
5357
+ this.disarmReason = "expired";
5358
+ }
5359
+ }
5360
+ /** Arm a fresh code, replacing any live one. */
5361
+ async arm() {
5362
+ const stored = await this.readToken();
5363
+ if (stored === TOKEN_NOT_LOADED) {
5364
+ throw new PairingError("PAIRING_UNAVAILABLE", "The pairing store is not available yet.");
5365
+ }
5366
+ const code = await this.drawCode();
5367
+ const expiresAt = Date.now() + PAIRING_CODE_TTL_MS;
5368
+ const monotonic = monotonicNow();
5369
+ this.armedCode = {
5370
+ code,
5371
+ expiresAt,
5372
+ monotonicDeadline: monotonic === null ? null : monotonic + PAIRING_CODE_TTL_MS,
5373
+ wrongAttempts: 0
5374
+ };
5375
+ this.heldCode = null;
5376
+ this.disarmReason = null;
5377
+ this.consumedBy = null;
5378
+ this.consumedAt = null;
5379
+ return { code, expiresAt };
5380
+ }
5381
+ /** A uniform 6-digit code, by rejection sampling so that no digit is favoured. */
5382
+ async drawCode() {
5383
+ for (let attempt = 0; attempt < CODE_SAMPLE_ATTEMPTS; attempt++) {
5384
+ const bytes = await this.randomBytes(4);
5385
+ const value = new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0);
5386
+ if (value < CODE_SAMPLE_LIMIT) return String(value % 1e6).padStart(6, "0");
5387
+ }
5388
+ throw new PairingError(
5389
+ "PAIRING_UNAVAILABLE",
5390
+ "The secure random source returned no usable bytes."
5391
+ );
5392
+ }
5393
+ /** Cancel a live code. */
5394
+ disarm() {
5395
+ this.dropCode("cancelled");
5396
+ }
5397
+ dropCode(reason) {
5398
+ if (this.armedCode || this.heldCode) {
5399
+ this.armedCode = null;
5400
+ this.heldCode = null;
5401
+ this.disarmReason = reason;
5402
+ }
5403
+ }
5404
+ /**
5405
+ * Replace the token, so every signature made with the old one stops verifying.
5406
+ * If it cannot be replaced it is cleared. If that fails too, signatures and
5407
+ * pairing stay refused until a revoke succeeds: the user asked for the old
5408
+ * token to die, and a store that would not say so cannot be trusted to have
5409
+ * done it.
5410
+ *
5411
+ * Nothing verifies from the moment this starts until it ends. The generation
5412
+ * moves when it ends, whichever way, so a check that read the old token before
5413
+ * then cannot admit its request afterwards. A request a check has already
5414
+ * admitted runs to its end.
5415
+ */
5416
+ async revoke() {
5417
+ this.revokesInFlight += 1;
5418
+ this.dropCode("revoked");
5419
+ try {
5420
+ await this.exclusive(async () => {
5421
+ const stored = await this.readStore();
5422
+ if (stored === TOKEN_NOT_LOADED) {
5423
+ throw new PairingError("PAIRING_UNAVAILABLE", "The pairing store is not available yet.");
5424
+ }
5425
+ try {
5426
+ await this.mintAndStoreToken();
5427
+ this.storeSuspect = false;
5428
+ } catch {
5429
+ try {
5430
+ await bounded(() => this.store.clear());
5431
+ this.storeSuspect = false;
5432
+ } catch {
5433
+ this.storeSuspect = true;
5434
+ }
5435
+ throw new PairingError("PAIRING_UNAVAILABLE", "The pairing token could not be replaced.");
5436
+ }
5437
+ });
5438
+ } finally {
5439
+ this.revokesInFlight -= 1;
5440
+ this.tokenGeneration += 1;
5441
+ }
5442
+ }
5443
+ /** What Settings shows. */
5444
+ getState() {
5445
+ this.expireCode();
5446
+ return {
5447
+ configured: true,
5448
+ armed: this.armedCode !== null,
5449
+ expiresAt: this.armedCode?.expiresAt ?? null,
5450
+ attemptsRemaining: this.armedCode ? PAIRING_CODE_MAX_ATTEMPTS - this.armedCode.wrongAttempts : null,
5451
+ consumedBy: this.consumedBy,
5452
+ consumedAt: this.consumedAt,
5453
+ disarmReason: this.disarmReason
5454
+ };
5455
+ }
5456
+ };
5457
+ var UNCONFIGURED_PAIRING_STATE = {
5458
+ configured: false,
5459
+ armed: false,
5460
+ expiresAt: null,
5461
+ attemptsRemaining: null,
5462
+ consumedBy: null,
5463
+ consumedAt: null,
5464
+ disarmReason: null
5465
+ };
5466
+ function signedTarget(request) {
5467
+ if (request.rawTarget === void 0) {
5468
+ return Object.keys(request.query ?? {}).length === 0 ? request.path : null;
5469
+ }
5470
+ const queryAt = request.rawTarget.indexOf("?");
5471
+ const path = queryAt === -1 ? request.rawTarget : request.rawTarget.slice(0, queryAt);
5472
+ return path === request.path ? request.rawTarget : null;
5473
+ }
5474
+ function signedBody(request) {
5475
+ if (request.rawBody !== void 0) return request.rawBody;
5476
+ return request.body === void 0 ? "" : null;
5477
+ }
5478
+
4922
5479
  // src/server/observability.ts
4923
5480
  var RingBuffer = class {
4924
5481
  constructor(capacity) {
@@ -5249,7 +5806,20 @@ function isUnsubscribe(msg) {
5249
5806
  var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
5250
5807
  ["NOT_SUPPORTED", 501],
5251
5808
  ["NOT_FOUND", 404],
5252
- ["METHOD_NOT_ALLOWED", 405]
5809
+ ["METHOD_NOT_ALLOWED", 405],
5810
+ // Device pairing, all answered by the network entry. A 401 means this caller
5811
+ // has not proved it may act; a 403 means proving it would change nothing; a 503
5812
+ // means the phone cannot check yet.
5813
+ ["PAIRING_REQUIRED", 401],
5814
+ ["PAIRING_UNCONFIGURED", 401],
5815
+ ["PAIRING_NOT_ARMED", 401],
5816
+ ["PAIRING_CODE_INVALID", 401],
5817
+ ["PAIRING_TIMESTAMP_REFUSED", 401],
5818
+ ["PAIRING_WS_LOOPBACK_ONLY", 403],
5819
+ ["PAIRING_WEB_CALLER_REFUSED", 403],
5820
+ ["PAIRING_BODY_TOO_LARGE", 413],
5821
+ ["PAIRING_TARGET_TOO_LONG", 414],
5822
+ ["PAIRING_UNAVAILABLE", 503]
5253
5823
  ]);
5254
5824
  function httpStatusForResponse(response) {
5255
5825
  if (response.success) return 200;
@@ -5275,6 +5845,10 @@ function parsePath(pattern, actual) {
5275
5845
  var WS_ROUTES = [
5276
5846
  // Health
5277
5847
  { pattern: "health", handler: "health", httpMethods: ["GET"] },
5848
+ // Device pairing. Always NOT_SUPPORTED from this table: the real route is in
5849
+ // `handleNetworkRequest`, because route context carries no peer, and this
5850
+ // entry is what JSON-RPC and the relay reach instead.
5851
+ { pattern: "pair", handler: "pair", httpMethods: ["POST"] },
5278
5852
  // Elements
5279
5853
  { pattern: "control/elements", handler: "getElements", httpMethods: ["GET"] },
5280
5854
  // Flat alias — matches the runner UI Bridge's `/ui-bridge/elements` path so
@@ -5437,6 +6011,7 @@ function allowedMethodsForPath(path, testHooks) {
5437
6011
  }
5438
6012
  var HANDLER_DESCRIPTIONS = {
5439
6013
  health: "Liveness probe; returns app metadata + element count",
6014
+ pair: "Get the pairing token: loopback with no code, otherwise body { code }. Direct HTTP only",
5440
6015
  getElements: "Flat list of registered UI elements",
5441
6016
  getElement: "Single element by id",
5442
6017
  getElementState: "Dynamic state (value, focused, checked) for one element",
@@ -5538,10 +6113,24 @@ function buildHelpPayload(testHooks) {
5538
6113
  ]
5539
6114
  };
5540
6115
  }
6116
+ var HEALTH_PATH = "/ui-bridge/health";
6117
+ var PAIR_PATH = "/ui-bridge/pair";
6118
+ function jsonRpcIdOf(rawMessage) {
6119
+ try {
6120
+ const id = JSON.parse(rawMessage)?.id;
6121
+ return typeof id === "number" || typeof id === "string" ? id : 0;
6122
+ } catch {
6123
+ return 0;
6124
+ }
6125
+ }
5541
6126
  var NativeUIBridgeServer = class {
5542
6127
  constructor(registry, executor, config = {}) {
5543
6128
  this.registry = registry;
5544
6129
  this.executor = executor;
6130
+ /** Whether this run's adapter has called `onWebSocketUpgrade` at all. */
6131
+ this.upgradeGateSeen = false;
6132
+ /** Warned once per run rather than per request, which would flood the error ring. */
6133
+ this.warnedMissingPeer = false;
5545
6134
  this.running = false;
5546
6135
  /**
5547
6136
  * Per-connection cleanup thunks for in-flight waiters (waitForElement,
@@ -5554,6 +6143,9 @@ var NativeUIBridgeServer = class {
5554
6143
  cors: true,
5555
6144
  ...config
5556
6145
  };
6146
+ if (this.config.pairingStore) {
6147
+ this.pairing = new PairingAuthority(this.config.pairingStore);
6148
+ }
5557
6149
  this.consoleErrorBuffer = new ConsoleErrorBuffer();
5558
6150
  this.networkRequestBuffer = new NetworkRequestBuffer();
5559
6151
  if (this.config.appInfo) {
@@ -5599,6 +6191,14 @@ var NativeUIBridgeServer = class {
5599
6191
  setAdapter(adapter) {
5600
6192
  this.adapter = adapter;
5601
6193
  }
6194
+ /**
6195
+ * Set the device's stable id. An app usually loads it from storage after the
6196
+ * server has started, and this spares it a restart that would drop every
6197
+ * socket and any armed code.
6198
+ */
6199
+ setDeviceId(deviceId) {
6200
+ this.config.deviceId = deviceId;
6201
+ }
5602
6202
  /**
5603
6203
  * Set a navigation provider for programmatic route navigation.
5604
6204
  * This enables `control/page/navigate` and `control/page/back` on native.
@@ -5832,7 +6432,18 @@ var NativeUIBridgeServer = class {
5832
6432
  this.consoleErrorBuffer.install();
5833
6433
  this.networkRequestBuffer.install();
5834
6434
  }
5835
- await this.adapter.start(this.config.serverPort, this.handleRequest.bind(this));
6435
+ this.upgradeGateSeen = false;
6436
+ this.warnedMissingPeer = false;
6437
+ if (this.config.allowUnauthenticatedNetwork) {
6438
+ console.warn(
6439
+ "[ui-bridge-native] allowUnauthenticatedNetwork is on: anyone who can reach this port can drive the app over HTTP."
6440
+ );
6441
+ } else if (!this.pairing) {
6442
+ console.warn(
6443
+ "[ui-bridge-native] No pairingStore configured: non-loopback callers are refused with PAIRING_UNCONFIGURED."
6444
+ );
6445
+ }
6446
+ await this.adapter.start(this.config.serverPort, this.handleNetworkRequest.bind(this));
5836
6447
  this.running = true;
5837
6448
  console.log(`[ui-bridge-native] HTTP server started on port ${this.config.serverPort}`);
5838
6449
  }
@@ -5861,6 +6472,26 @@ var NativeUIBridgeServer = class {
5861
6472
  setEventBridge(bridge) {
5862
6473
  this.eventBridge = bridge;
5863
6474
  }
6475
+ /**
6476
+ * The entry a socket's messages come through. An adapter that never called
6477
+ * `onWebSocketUpgrade` admitted the socket with no check, and a message carries
6478
+ * no peer to check now, so it is refused: the same fail-closed reading as a
6479
+ * missing peer address. An adapter that gates sees a call before its first socket.
6480
+ */
6481
+ async handleNetworkWebSocketMessage(connId, rawMessage) {
6482
+ if (!this.upgradeGateSeen) {
6483
+ return JSON.stringify({
6484
+ id: jsonRpcIdOf(rawMessage),
6485
+ result: {
6486
+ success: false,
6487
+ error: "This server adapter does not gate WebSocket upgrades (onWebSocketUpgrade).",
6488
+ code: "PAIRING_UNCONFIGURED",
6489
+ timestamp: Date.now()
6490
+ }
6491
+ });
6492
+ }
6493
+ return this.handleWebSocketMessage(connId, rawMessage);
6494
+ }
5864
6495
  /**
5865
6496
  * Handle a JSON-RPC message from a WebSocket client.
5866
6497
  * Parses the message, routes to the appropriate handler, and returns
@@ -6389,21 +7020,272 @@ var NativeUIBridgeServer = class {
6389
7020
  timestamp: Date.now()
6390
7021
  };
6391
7022
  }
7023
+ // ── Device pairing ──────────────────────────────────────────────────────
7024
+ /**
7025
+ * What `start()` hands the adapter: every request off the socket comes through
7026
+ * here, and `handleRequest` sits behind it.
7027
+ *
7028
+ * | Caller | Admitted |
7029
+ * |----------------------------------------------------------|----------|
7030
+ * | Loopback peer, no web context, loopback `Host` | yes |
7031
+ * | Anyone with a valid `X-UI-Bridge-Auth` signature | yes |
7032
+ * | Anyone, `GET /ui-bridge/health` (trimmed) | yes |
7033
+ * | Anyone with no web context, `POST /ui-bridge/pair` | yes |
7034
+ * | A web page, `POST /ui-bridge/pair` | 403 `PAIRING_WEB_CALLER_REFUSED` |
7035
+ * | Signature outside the window or below the floor | 401 `PAIRING_TIMESTAMP_REFUSED` |
7036
+ * | Signed body over `SIGNED_BODY_MAX_CHARS` | 413 `PAIRING_BODY_TOO_LARGE` |
7037
+ * | Signed method or target over its cap | 414 `PAIRING_TARGET_TOO_LONG` |
7038
+ * | Everyone else | 401 `PAIRING_REQUIRED` |
7039
+ * | No store configured, not loopback | 401 `PAIRING_UNCONFIGURED` |
7040
+ * | Under `allowUnauthenticatedNetwork`, a web page or a rebindable `Host` | 403 `PAIRING_WEB_CALLER_REFUSED` |
7041
+ *
7042
+ * The gate is here rather than in `handleRequest` because `CloudRelayClient`
7043
+ * calls that directly and must keep working: relay access already means the
7044
+ * same user account, checked by the backend.
7045
+ */
7046
+ async handleNetworkRequest(request) {
7047
+ try {
7048
+ if (request.method === "POST" && request.path === PAIR_PATH) {
7049
+ return await this.handlePair(request);
7050
+ }
7051
+ const admission = await this.admit(request);
7052
+ if (request.method === "GET" && request.path === HEALTH_PATH) {
7053
+ return await this.handleNetworkHealth(request, admission);
7054
+ }
7055
+ if (!admission.admitted) {
7056
+ return this.refusal(admission.code, admission.error, { data: admission.data });
7057
+ }
7058
+ return await this.handleRequest(request);
7059
+ } catch {
7060
+ return this.refusal("INTERNAL_ERROR", "Internal server error", { status: 500 });
7061
+ }
7062
+ }
7063
+ /**
7064
+ * The WebSocket door: trusted loopback or nothing, whatever else the request
7065
+ * carries. A signature covers no upgrade intent, so one swallowed GET re-sent
7066
+ * with `Upgrade: websocket` would become a permanent control socket, and a socket
7067
+ * outlives the signature's window. `allowUnauthenticatedNetwork` opens HTTP only,
7068
+ * and no runner dials a device's socket.
7069
+ */
7070
+ handleWebSocketUpgrade(request) {
7071
+ this.upgradeGateSeen = true;
7072
+ if (this.isTrustedLoopback(request)) return { admit: true };
7073
+ return {
7074
+ admit: false,
7075
+ response: this.refusal(
7076
+ "PAIRING_WS_LOOPBACK_ONLY",
7077
+ "WebSocket upgrades are accepted only from this machine. Use signed HTTP from anywhere else."
7078
+ )
7079
+ };
7080
+ }
7081
+ isTrustedLoopback(request) {
7082
+ if (!request.remoteAddress && !this.warnedMissingPeer) {
7083
+ this.warnedMissingPeer = true;
7084
+ console.warn(
7085
+ "[ui-bridge-native] A request arrived with no peer address and is treated as not loopback. The server adapter must pass remoteAddress."
7086
+ );
7087
+ }
7088
+ return isTrustedLoopbackRequest(request);
7089
+ }
7090
+ async admit(request) {
7091
+ if (this.isTrustedLoopback(request)) return { admitted: true, as: "loopback" };
7092
+ if (this.config.allowUnauthenticatedNetwork) {
7093
+ if (isWebReachableRequest(request)) {
7094
+ return {
7095
+ admitted: false,
7096
+ code: "PAIRING_WEB_CALLER_REFUSED",
7097
+ error: "A web page cannot drive this device."
7098
+ };
7099
+ }
7100
+ return { admitted: true, as: "unauthenticated-network" };
7101
+ }
7102
+ if (!this.pairing) {
7103
+ return {
7104
+ admitted: false,
7105
+ code: "PAIRING_UNCONFIGURED",
7106
+ error: "This app has not configured device pairing, so it accepts only local callers."
7107
+ };
7108
+ }
7109
+ const required = {
7110
+ admitted: false,
7111
+ code: "PAIRING_REQUIRED",
7112
+ error: "This device requires pairing. Sign the request with the token from POST /ui-bridge/pair."
7113
+ };
7114
+ const signatures = headerValues(request.headers, UI_BRIDGE_AUTH_HEADER);
7115
+ if (signatures.length !== 1) return required;
7116
+ const verdict = await this.pairing.verifySignature(request, signatures[0]);
7117
+ if (verdict === "valid") return { admitted: true, as: "signed" };
7118
+ if (verdict === "store_unavailable") {
7119
+ return {
7120
+ admitted: false,
7121
+ code: "PAIRING_UNAVAILABLE",
7122
+ error: "The pairing store is not available yet. Try again."
7123
+ };
7124
+ }
7125
+ if (verdict === "timestamp_refused") {
7126
+ return {
7127
+ admitted: false,
7128
+ code: "PAIRING_TIMESTAMP_REFUSED",
7129
+ error: "The signature timestamp is not acceptable. Re-sync to data.serverTime and sign again.",
7130
+ data: { serverTime: Date.now() }
7131
+ };
7132
+ }
7133
+ if (verdict === "body_too_large") {
7134
+ return {
7135
+ admitted: false,
7136
+ code: "PAIRING_BODY_TOO_LARGE",
7137
+ error: `A signed body may be at most ${SIGNED_BODY_MAX_CHARS} characters.`
7138
+ };
7139
+ }
7140
+ if (verdict === "target_too_long") {
7141
+ return {
7142
+ admitted: false,
7143
+ code: "PAIRING_TARGET_TOO_LONG",
7144
+ error: `A signed target may be at most ${SIGNED_TARGET_MAX_CHARS} characters.`
7145
+ };
7146
+ }
7147
+ return required;
7148
+ }
7149
+ corsHeaders() {
7150
+ if (!this.config.cors) return {};
7151
+ return {
7152
+ "Access-Control-Allow-Origin": this.config.allowedOrigins ? this.config.allowedOrigins.join(",") : "*",
7153
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
7154
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
7155
+ };
7156
+ }
7157
+ /** No CORS header: a page must not be able to read what the gate says. */
7158
+ refusal(code, error3, extra = {}) {
7159
+ const body = {
7160
+ success: false,
7161
+ error: error3,
7162
+ code,
7163
+ timestamp: Date.now(),
7164
+ ...extra.data ? { data: extra.data } : {}
7165
+ };
7166
+ return {
7167
+ status: extra.status ?? httpStatusForResponse(body),
7168
+ headers: { "Content-Type": "application/json" },
7169
+ body: JSON.stringify(body)
7170
+ };
7171
+ }
7172
+ /**
7173
+ * Open health. An admitted caller gets the full answer. Anyone else gets what
7174
+ * discovery needs and nothing about the app's contents, and that answer is
7175
+ * built without going through `handleRequest`, whose keep-awake auto-arm is no
7176
+ * business of an anonymous LAN probe. It carries no CORS header, so a web page
7177
+ * cannot read the stable device id.
7178
+ */
7179
+ async handleNetworkHealth(request, admission) {
7180
+ const pairing = {
7181
+ required: !(admission.admitted && (admission.as === "loopback" || admission.as === "unauthenticated-network")),
7182
+ authenticated: admission.admitted && admission.as !== "unauthenticated-network"
7183
+ };
7184
+ const identity = {
7185
+ deviceId: this.config.deviceId ?? null,
7186
+ serverTime: Date.now(),
7187
+ pairing
7188
+ };
7189
+ if (admission.admitted) {
7190
+ const response = await this.handleRequest(request);
7191
+ const parsed = JSON.parse(response.body);
7192
+ if (!parsed.success || !parsed.data) return response;
7193
+ return {
7194
+ ...response,
7195
+ body: JSON.stringify({ ...parsed, data: { ...parsed.data, ...identity } })
7196
+ };
7197
+ }
7198
+ const health = await this.handlers.health({ params: {}, query: {}, body: void 0 });
7199
+ const full = health.data ?? {};
7200
+ const trimmed = { status: full.status, ...identity };
7201
+ if (full.uiBridge) {
7202
+ const { version, appId, appName, appType, framework } = full.uiBridge;
7203
+ trimmed.uiBridge = { version, appId, appName, appType, framework };
7204
+ }
7205
+ return {
7206
+ status: 200,
7207
+ headers: { "Content-Type": "application/json" },
7208
+ body: JSON.stringify({ success: true, data: trimmed, timestamp: Date.now() })
7209
+ };
7210
+ }
7211
+ /**
7212
+ * `POST /ui-bridge/pair`, the only way to a token, and only ever from here.
7213
+ * Its answer carries no CORS header, because no web page has any business
7214
+ * reading a token, and it answers only after the store write has finished. A web
7215
+ * page is refused first of all, so it cannot burn an attempt or the armed code.
7216
+ */
7217
+ async handlePair(request) {
7218
+ if (carriesWebContext(request)) {
7219
+ return this.refusal("PAIRING_WEB_CALLER_REFUSED", "A web page cannot pair with this device.");
7220
+ }
7221
+ if (!this.pairing) {
7222
+ return this.refusal(
7223
+ "PAIRING_UNCONFIGURED",
7224
+ "This app has not configured device pairing, so it cannot issue a token."
7225
+ );
7226
+ }
7227
+ const deviceId = this.config.deviceId;
7228
+ if (!deviceId) {
7229
+ return this.refusal("PAIRING_UNAVAILABLE", "The device id is not available yet. Try again.");
7230
+ }
7231
+ const outcome = await this.pairing.pair({
7232
+ trustedLoopback: this.isTrustedLoopback(request),
7233
+ peer: request.remoteAddress,
7234
+ body: request.body
7235
+ });
7236
+ if (!outcome.ok) return this.refusal(outcome.code, outcome.error);
7237
+ const body = {
7238
+ success: true,
7239
+ data: { token: outcome.token, deviceId, serverTime: Date.now() },
7240
+ timestamp: Date.now()
7241
+ };
7242
+ return {
7243
+ status: 200,
7244
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
7245
+ body: JSON.stringify(body)
7246
+ };
7247
+ }
7248
+ /**
7249
+ * Arm a 6-digit code for a LAN pairing. In memory and single use. It stops
7250
+ * working after about 2 minutes, or after 5 wrong attempts from anyone. Rejects
7251
+ * with a `PairingError` when no store is configured or it has not loaded.
7252
+ */
7253
+ async armPairingCode() {
7254
+ return this.requirePairing().arm();
7255
+ }
7256
+ /** Cancel a live code. */
7257
+ disarmPairingCode() {
7258
+ this.pairing?.disarm();
7259
+ }
7260
+ /** What Settings shows, including the peer that consumed the last code. */
7261
+ getPairingState() {
7262
+ return this.pairing?.getState() ?? UNCONFIGURED_PAIRING_STATE;
7263
+ }
7264
+ /**
7265
+ * Replace the token, so every signature made with the old one stops verifying.
7266
+ * There are no network sockets to close, because none is ever admitted.
7267
+ */
7268
+ async revokePairing() {
7269
+ await this.requirePairing().revoke();
7270
+ }
7271
+ requirePairing() {
7272
+ if (!this.pairing) {
7273
+ throw new PairingError("PAIRING_UNCONFIGURED", "No pairingStore is configured.");
7274
+ }
7275
+ return this.pairing;
7276
+ }
6392
7277
  /**
6393
- * Handle incoming HTTP request
7278
+ * Handle incoming HTTP request, past the gate.
6394
7279
  *
6395
7280
  * Exposed as public so that CloudRelayClient can call it directly (without
6396
- * loopback TCP overhead) when processing tunneled requests.
7281
+ * loopback TCP overhead) when processing tunneled requests. Anything reached
7282
+ * from a socket goes through `handleNetworkRequest` first.
6397
7283
  */
6398
7284
  async handleRequest(request) {
6399
7285
  const headers = {
6400
- "Content-Type": "application/json"
7286
+ "Content-Type": "application/json",
7287
+ ...this.corsHeaders()
6401
7288
  };
6402
- if (this.config.cors) {
6403
- headers["Access-Control-Allow-Origin"] = this.config.allowedOrigins ? this.config.allowedOrigins.join(",") : "*";
6404
- headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS";
6405
- headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization";
6406
- }
6407
7289
  if (request.method === "OPTIONS") {
6408
7290
  return { status: 204, headers, body: "" };
6409
7291
  }
@@ -6873,7 +7755,6 @@ var DeviceAnnouncer = class {
6873
7755
  */
6874
7756
  this.publishInFlight = false;
6875
7757
  this.config = config;
6876
- this.pairingToken = generateHex(16);
6877
7758
  }
6878
7759
  // ── mDNS ──────────────────────────────────────────────────────────────────
6879
7760
  /**
@@ -6909,8 +7790,7 @@ var DeviceAnnouncer = class {
6909
7790
  result = await zeroconf.publishService("uibridge", "tcp", "local.", serviceName, port, {
6910
7791
  device_id: this.config.deviceId,
6911
7792
  app_id: this.config.appId,
6912
- version: this.config.version ?? "unknown",
6913
- pairing_token: this.pairingToken
7793
+ version: this.config.version ?? "unknown"
6914
7794
  });
6915
7795
  } finally {
6916
7796
  this.publishInFlight = false;
@@ -7034,13 +7914,6 @@ var DeviceAnnouncer = class {
7034
7914
  this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
7035
7915
  }
7036
7916
  };
7037
- function generateHex(bytes) {
7038
- const arr = new Uint8Array(bytes);
7039
- for (let i = 0; i < bytes; i++) {
7040
- arr[i] = Math.floor(Math.random() * 256);
7041
- }
7042
- return Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
7043
- }
7044
7917
  function getPlatform() {
7045
7918
  return Platform?.OS ?? "unknown";
7046
7919
  }
@@ -7412,6 +8285,26 @@ function readWindowViewport() {
7412
8285
  const win = Dimensions.get("window");
7413
8286
  return { width: win.width, height: win.height };
7414
8287
  }
8288
+ function useStablePairingStore(store) {
8289
+ const latestRef = useRef(store);
8290
+ useEffect(() => {
8291
+ latestRef.current = store;
8292
+ });
8293
+ const hasStore = store !== void 0;
8294
+ return useMemo(() => {
8295
+ if (!hasStore) return void 0;
8296
+ const current = () => {
8297
+ if (!latestRef.current) throw new Error("pairingStore was removed");
8298
+ return latestRef.current;
8299
+ };
8300
+ return {
8301
+ get: () => current().get(),
8302
+ set: (token) => current().set(token),
8303
+ clear: () => current().clear(),
8304
+ randomBytes: (n) => current().randomBytes(n)
8305
+ };
8306
+ }, [hasStore]);
8307
+ }
7415
8308
  function UIBridgeNativeProvider({
7416
8309
  children,
7417
8310
  features: featuresProp = EMPTY_FEATURES,
@@ -7425,10 +8318,13 @@ function UIBridgeNativeProvider({
7425
8318
  cloudRelayConfig,
7426
8319
  enableMdnsAnnounce,
7427
8320
  zeroconf,
7428
- deviceId
8321
+ deviceId,
8322
+ pairingStore: pairingStoreProp,
8323
+ allowUnauthenticatedNetwork
7429
8324
  }) {
7430
8325
  const features = useValueStable(featuresProp);
7431
8326
  const config = useValueStable(configProp);
8327
+ const pairingStore = useStablePairingStore(pairingStoreProp);
7432
8328
  const registryRef = useRef(null);
7433
8329
  const executorRef = useRef(null);
7434
8330
  const modalDetectorRef = useRef(null);
@@ -7459,6 +8355,11 @@ function UIBridgeNativeProvider({
7459
8355
  const eventBridgeRef = useRef(null);
7460
8356
  const announcerRef = useRef(null);
7461
8357
  const cloudRelayRef = useRef(null);
8358
+ const deviceIdRef = useRef(deviceId);
8359
+ useEffect(() => {
8360
+ deviceIdRef.current = deviceId;
8361
+ serverRef.current?.setDeviceId(deviceId);
8362
+ }, [deviceId]);
7462
8363
  const startServer = useCallback(async () => {
7463
8364
  if (!features.server) return;
7464
8365
  if (!serverAdapter) {
@@ -7479,7 +8380,10 @@ function UIBridgeNativeProvider({
7479
8380
  // Injected device-viewport getter for POST /control/page-health and the
7480
8381
  // design handlers. Same source as the registry's clipping bounds — see
7481
8382
  // `readWindowViewport`.
7482
- viewportProvider: readWindowViewport
8383
+ viewportProvider: readWindowViewport,
8384
+ pairingStore,
8385
+ allowUnauthenticatedNetwork,
8386
+ deviceId: deviceIdRef.current
7483
8387
  });
7484
8388
  server.setAdapter(serverAdapter);
7485
8389
  if (navigationProvider) {
@@ -7521,8 +8425,9 @@ function UIBridgeNativeProvider({
7521
8425
  server.abortWaitersForConnection(connId);
7522
8426
  eventBridge.removeConnection(connId);
7523
8427
  };
8428
+ wsAdapter.onWebSocketUpgrade = (req) => server.handleWebSocketUpgrade(req);
7524
8429
  wsAdapter.onWebSocketMessage = async (connId, message) => {
7525
- return server.handleWebSocketMessage(connId, message);
8430
+ return server.handleNetworkWebSocketMessage(connId, message);
7526
8431
  };
7527
8432
  eventBridge.start();
7528
8433
  eventBridgeRef.current = eventBridge;
@@ -7530,6 +8435,7 @@ function UIBridgeNativeProvider({
7530
8435
  try {
7531
8436
  await server.start();
7532
8437
  serverRef.current = server;
8438
+ server.setDeviceId(deviceIdRef.current);
7533
8439
  setServerRunning(true);
7534
8440
  } catch (err) {
7535
8441
  console.warn("[ui-bridge-native] Failed to start HTTP server:", err);
@@ -7546,7 +8452,9 @@ function UIBridgeNativeProvider({
7546
8452
  navigationProvider,
7547
8453
  screenshotProvider,
7548
8454
  keepAwakeProvider,
7549
- routeProvider
8455
+ routeProvider,
8456
+ pairingStore,
8457
+ allowUnauthenticatedNetwork
7550
8458
  ]);
7551
8459
  const stopServer = useCallback(() => {
7552
8460
  if (eventBridgeRef.current) {
@@ -7654,6 +8562,25 @@ function UIBridgeNativeProvider({
7654
8562
  const getCurrentRoute = useCallback(() => {
7655
8563
  return routeProvider?.getCurrentRoute() ?? null;
7656
8564
  }, [routeProvider]);
8565
+ const armPairingCode = useCallback(async () => {
8566
+ if (!serverRef.current) {
8567
+ throw new PairingError("PAIRING_UNCONFIGURED", "The UI Bridge server is not running.");
8568
+ }
8569
+ return serverRef.current.armPairingCode();
8570
+ }, []);
8571
+ const disarmPairingCode = useCallback(() => {
8572
+ serverRef.current?.disarmPairingCode();
8573
+ }, []);
8574
+ const getPairingState = useCallback(
8575
+ () => serverRef.current?.getPairingState() ?? UNCONFIGURED_PAIRING_STATE,
8576
+ []
8577
+ );
8578
+ const revokePairing = useCallback(async () => {
8579
+ if (!serverRef.current) {
8580
+ throw new PairingError("PAIRING_UNCONFIGURED", "The UI Bridge server is not running.");
8581
+ }
8582
+ await serverRef.current.revokePairing();
8583
+ }, []);
7657
8584
  useEffect(() => {
7658
8585
  if (!routeProvider) return;
7659
8586
  let lastRoute = routeProvider.getCurrentRoute();
@@ -7706,7 +8633,11 @@ function UIBridgeNativeProvider({
7706
8633
  serverRunning,
7707
8634
  startServer,
7708
8635
  stopServer,
7709
- getCurrentRoute
8636
+ getCurrentRoute,
8637
+ armPairingCode,
8638
+ disarmPairingCode,
8639
+ getPairingState,
8640
+ revokePairing
7710
8641
  }),
7711
8642
  [
7712
8643
  features,
@@ -7724,7 +8655,11 @@ function UIBridgeNativeProvider({
7724
8655
  serverRunning,
7725
8656
  startServer,
7726
8657
  stopServer,
7727
- getCurrentRoute
8658
+ getCurrentRoute,
8659
+ armPairingCode,
8660
+ disarmPairingCode,
8661
+ getPairingState,
8662
+ revokePairing
7728
8663
  ]
7729
8664
  );
7730
8665
  return /* @__PURE__ */ jsx(UIBridgeNativeContext, { value: contextValue, children });