@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
@@ -332,6 +332,12 @@ var UI_BRIDGE_NATIVE_ROUTES = {
332
332
  method: "GET",
333
333
  path: "/ui-bridge/health",
334
334
  description: "Health check"
335
+ },
336
+ // Device pairing
337
+ PAIR: {
338
+ method: "POST",
339
+ path: "/ui-bridge/pair",
340
+ description: "Get the pairing token: loopback with no code, otherwise body { code }. Direct HTTP only"
335
341
  }
336
342
  };
337
343
 
@@ -445,8 +451,7 @@ function utf8Bytes(input) {
445
451
  return Uint8Array.from(out);
446
452
  }
447
453
  var rotr = (x, n) => x >>> n | x << 32 - n;
448
- function sha256Hex(input) {
449
- const bytes = utf8Bytes(input);
454
+ function sha256Bytes(bytes) {
450
455
  const blockBytes = Math.ceil((bytes.length + 9) / 64) * 64;
451
456
  const buffer = new Uint8Array(blockBytes);
452
457
  buffer.set(bytes);
@@ -490,7 +495,38 @@ function sha256Hex(input) {
490
495
  h[6] = h[6] + g | 0;
491
496
  h[7] = h[7] + hh | 0;
492
497
  }
493
- return h.map((word) => (word >>> 0).toString(16).padStart(8, "0")).join("");
498
+ const digest = new Uint8Array(32);
499
+ const digestView = new DataView(digest.buffer);
500
+ h.forEach((word, i) => digestView.setUint32(i * 4, word >>> 0));
501
+ return digest;
502
+ }
503
+ function bytesToHex(bytes) {
504
+ let out = "";
505
+ for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
506
+ return out;
507
+ }
508
+ function sha256Hex(input) {
509
+ return bytesToHex(sha256Bytes(utf8Bytes(input)));
510
+ }
511
+ var SHA256_BLOCK_BYTES = 64;
512
+ function hmacSha256(key, message) {
513
+ const normalizedKey = key.length > SHA256_BLOCK_BYTES ? sha256Bytes(key) : key;
514
+ const inner = new Uint8Array(SHA256_BLOCK_BYTES + message.length);
515
+ const outer = new Uint8Array(SHA256_BLOCK_BYTES + 32);
516
+ for (let i = 0; i < SHA256_BLOCK_BYTES; i++) {
517
+ const keyByte = i < normalizedKey.length ? normalizedKey[i] : 0;
518
+ inner[i] = keyByte ^ 54;
519
+ outer[i] = keyByte ^ 92;
520
+ }
521
+ inner.set(message, SHA256_BLOCK_BYTES);
522
+ outer.set(sha256Bytes(inner), SHA256_BLOCK_BYTES);
523
+ return sha256Bytes(outer);
524
+ }
525
+ function constantTimeEqual(a, b) {
526
+ if (a.length !== b.length) return false;
527
+ let diff = 0;
528
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
529
+ return diff === 0;
494
530
  }
495
531
 
496
532
  // src/core/vision-fields.ts
@@ -1323,7 +1359,14 @@ var UNKNOWN_CODES = [
1323
1359
  "below_confidence_floor",
1324
1360
  "model_reply_unparseable",
1325
1361
  "needs_multi_frame_input",
1326
- "stale_input"
1362
+ "stale_input",
1363
+ "frontier_open",
1364
+ "ledger_schema_absent",
1365
+ "ledger_write_failing",
1366
+ "no_observations",
1367
+ "to_node_unobserved",
1368
+ "performance_observer_unsupported",
1369
+ "no_event_before_timeout"
1327
1370
  ];
1328
1371
  function isUnknownCode(value) {
1329
1372
  return typeof value === "string" && UNKNOWN_CODES.includes(value);
@@ -1513,7 +1556,7 @@ var NATIVE_PAGE_HEALTH_PRODUCER_ID = "sdk-native/page-health";
1513
1556
  function defaultProducer() {
1514
1557
  return {
1515
1558
  id: NATIVE_PAGE_HEALTH_PRODUCER_ID,
1516
- version: "0.8.0"
1559
+ version: "0.11.0"
1517
1560
  };
1518
1561
  }
1519
1562
  function pageHealthUnknown(code, detail, options = {}) {
@@ -2416,6 +2459,16 @@ function createServerHandlers(registry, executor, config) {
2416
2459
  // the envelope agree; an unmounted path answers 404 instead. Each error
2417
2460
  // message names an in-tree mobile replacement so operators aren't left
2418
2461
  // guessing what to use instead.
2462
+ // The real pairing route lives in the server's network entry, ahead of this
2463
+ // table, because only that entry knows the socket peer. This answer is what
2464
+ // JSON-RPC over a WebSocket and the cloud relay get, so neither can mint a
2465
+ // token or spend a code attempt.
2466
+ pair: async () => {
2467
+ return error2(
2468
+ "Pairing is served only on a direct HTTP connection to the device, not over JSON-RPC or the cloud relay.",
2469
+ "NOT_SUPPORTED"
2470
+ );
2471
+ },
2419
2472
  aiForms: async () => {
2420
2473
  return error2(
2421
2474
  '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.',
@@ -2810,7 +2863,7 @@ function createServerHandlers(registry, executor, config) {
2810
2863
  };
2811
2864
  if (config?.appInfo) {
2812
2865
  response.uiBridge = {
2813
- version: "0.8.0",
2866
+ version: "0.11.0",
2814
2867
  ...config.appInfo,
2815
2868
  capabilities: ["elements", "components", "actions", "design"],
2816
2869
  elementCount: stats.elements,
@@ -2822,6 +2875,527 @@ function createServerHandlers(registry, executor, config) {
2822
2875
  };
2823
2876
  }
2824
2877
 
2878
+ // src/server/pairing.ts
2879
+ var UI_BRIDGE_AUTH_HEADER = "X-UI-Bridge-Auth";
2880
+ var SIGNATURE_WINDOW_MS = 3e5;
2881
+ var PAIRING_CODE_TTL_MS = 12e4;
2882
+ var PAIRING_CODE_MAX_ATTEMPTS = 5;
2883
+ var SIGNED_BODY_MAX_CHARS = 256 * 1024;
2884
+ var SIGNED_METHOD_MAX_CHARS = 16;
2885
+ var SIGNED_TARGET_MAX_CHARS = 8192;
2886
+ var TOKEN_BYTES = 32;
2887
+ var NONCE_CACHE_MAX = 4096;
2888
+ var STORE_TIMEOUT_MS = 1e4;
2889
+ var CODE_SAMPLE_ATTEMPTS = 32;
2890
+ var CODE_SAMPLE_LIMIT = Math.floor(4294967296 / 1e6) * 1e6;
2891
+ var CLOCK_STEP_TOLERANCE_MS = 5e3;
2892
+ function monotonicNow() {
2893
+ const clock = globalThis.performance;
2894
+ return typeof clock?.now === "function" ? clock.now() : null;
2895
+ }
2896
+ var PairingError = class extends Error {
2897
+ constructor(code, message) {
2898
+ super(message);
2899
+ this.code = code;
2900
+ this.name = "PairingError";
2901
+ }
2902
+ };
2903
+ function canonicalSignatureInput(parts) {
2904
+ return [parts.method, parts.pathAndQuery, parts.ts, parts.nonce, parts.bodySha256Hex].join("\n");
2905
+ }
2906
+ function signatureHex(token, parts) {
2907
+ const input = canonicalSignatureInput({
2908
+ method: parts.method,
2909
+ pathAndQuery: parts.pathAndQuery,
2910
+ ts: parts.ts,
2911
+ nonce: parts.nonce,
2912
+ bodySha256Hex: sha256Hex(parts.body)
2913
+ });
2914
+ return bytesToHex(hmacSha256(utf8Bytes(token), utf8Bytes(input)));
2915
+ }
2916
+ function signUIBridgeRequest(token, request) {
2917
+ const ts = String(request.ts);
2918
+ const sig = signatureHex(token, {
2919
+ method: request.method,
2920
+ pathAndQuery: request.pathAndQuery,
2921
+ ts,
2922
+ nonce: request.nonce,
2923
+ body: request.body ?? ""
2924
+ });
2925
+ return `${ts}.${request.nonce}.${sig}`;
2926
+ }
2927
+ function headerValues(headers, name) {
2928
+ if (!headers) return [];
2929
+ const wanted = name.toLowerCase();
2930
+ return Object.keys(headers).filter((key) => key.toLowerCase() === wanted).map((key) => headers[key]);
2931
+ }
2932
+ function ipv4Octets(address) {
2933
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address);
2934
+ if (!match) return null;
2935
+ const octets = match.slice(1).map(Number);
2936
+ return octets.every((octet) => octet <= 255) ? octets : null;
2937
+ }
2938
+ function isLoopbackIpv4(address) {
2939
+ return ipv4Octets(address)?.[0] === 127;
2940
+ }
2941
+ function parseIpv6(address) {
2942
+ if (!address.includes(":")) return null;
2943
+ let text = address;
2944
+ const trailing = [];
2945
+ const lastColon = text.lastIndexOf(":");
2946
+ const tail = text.slice(lastColon + 1);
2947
+ if (tail.includes(".")) {
2948
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(tail);
2949
+ if (!match) return null;
2950
+ const octets = match.slice(1).map(Number);
2951
+ if (octets.some((octet) => octet > 255)) return null;
2952
+ trailing.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
2953
+ text = text.slice(0, lastColon + 1);
2954
+ if (!text.endsWith("::")) text = text.slice(0, -1);
2955
+ }
2956
+ const groupsOf = (part) => {
2957
+ if (part === "") return [];
2958
+ const groups = [];
2959
+ for (const group of part.split(":")) {
2960
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
2961
+ groups.push(parseInt(group, 16));
2962
+ }
2963
+ return groups;
2964
+ };
2965
+ const gapAt = text.indexOf("::");
2966
+ if (gapAt !== text.lastIndexOf("::")) return null;
2967
+ const width = 8 - trailing.length;
2968
+ if (gapAt === -1) {
2969
+ const groups = groupsOf(text);
2970
+ return groups && groups.length === width ? [...groups, ...trailing] : null;
2971
+ }
2972
+ const head = groupsOf(text.slice(0, gapAt));
2973
+ const rest = groupsOf(text.slice(gapAt + 2));
2974
+ if (!head || !rest || head.length + rest.length >= width) return null;
2975
+ const zeros = new Array(width - head.length - rest.length).fill(0);
2976
+ return [...head, ...zeros, ...rest, ...trailing];
2977
+ }
2978
+ function isLoopbackIpv6(address) {
2979
+ const zone = address.indexOf("%");
2980
+ const groups = parseIpv6(zone === -1 ? address : address.slice(0, zone));
2981
+ if (!groups) return false;
2982
+ const leadingZeros = (count) => groups.slice(0, count).every((g) => g === 0);
2983
+ if (leadingZeros(7) && groups[7] === 1) return true;
2984
+ return leadingZeros(5) && groups[5] === 65535 && groups[6] >> 8 === 127;
2985
+ }
2986
+ function isLoopbackAddress(address) {
2987
+ return isLoopbackIpv4(address) || isLoopbackIpv6(address);
2988
+ }
2989
+ function isLoopbackHost(host) {
2990
+ const value = host.trim().toLowerCase();
2991
+ const bracketed = /^\[([^\]]+)\](?::(\d{1,5}))?$/.exec(value);
2992
+ if (bracketed) {
2993
+ return isLoopbackIpv6(bracketed[1]) && isValidPort(bracketed[2]);
2994
+ }
2995
+ const plain = /^([^:]+)(?::(\d{1,5}))?$/.exec(value);
2996
+ if (!plain) return false;
2997
+ return (plain[1] === "localhost" || isLoopbackIpv4(plain[1])) && isValidPort(plain[2]);
2998
+ }
2999
+ function isValidPort(port) {
3000
+ return port === void 0 || Number(port) <= 65535;
3001
+ }
3002
+ function isUnrebindableHost(host) {
3003
+ const value = host.trim().toLowerCase();
3004
+ const bracketed = /^\[([^\]%]+)(?:%[^\]]*)?\](?::(\d{1,5}))?$/.exec(value);
3005
+ if (bracketed) return parseIpv6(bracketed[1]) !== null && isValidPort(bracketed[2]);
3006
+ const plain = /^([^:]+)(?::(\d{1,5}))?$/.exec(value);
3007
+ if (!plain) return false;
3008
+ const named = plain[1] === "localhost" || plain[1].endsWith(".local") || ipv4Octets(plain[1]) !== null;
3009
+ return named && isValidPort(plain[2]);
3010
+ }
3011
+ function carriesWebContext(request) {
3012
+ const { headers } = request;
3013
+ if (headerValues(headers, "origin").length > 0) return true;
3014
+ if (headerValues(headers, "referer").length > 0) return true;
3015
+ return headerValues(headers, "sec-fetch-site").some(
3016
+ (site) => site.trim().toLowerCase() !== "none"
3017
+ );
3018
+ }
3019
+ function isTrustedLoopbackRequest(request) {
3020
+ if (!request.remoteAddress || !isLoopbackAddress(request.remoteAddress)) return false;
3021
+ if (carriesWebContext(request)) return false;
3022
+ const hosts = headerValues(request.headers, "host");
3023
+ return hosts.length > 0 && hosts.every(isLoopbackHost);
3024
+ }
3025
+ function isWebReachableRequest(request) {
3026
+ if (carriesWebContext(request)) return true;
3027
+ const hosts = headerValues(request.headers, "host");
3028
+ return hosts.length === 0 || !hosts.every(isUnrebindableHost);
3029
+ }
3030
+ async function bounded(work) {
3031
+ let timer;
3032
+ try {
3033
+ return await Promise.race([
3034
+ Promise.resolve().then(work),
3035
+ new Promise((_, reject) => {
3036
+ timer = setTimeout(() => reject(new Error("store call timed out")), STORE_TIMEOUT_MS);
3037
+ })
3038
+ ]);
3039
+ } finally {
3040
+ if (timer !== void 0) clearTimeout(timer);
3041
+ }
3042
+ }
3043
+ var UNAVAILABLE = {
3044
+ ok: false,
3045
+ code: "PAIRING_UNAVAILABLE",
3046
+ error: "The pairing store is not available yet. Try again."
3047
+ };
3048
+ var TOKEN_NOT_LOADED = /* @__PURE__ */ Symbol("token-not-loaded");
3049
+ var PairingAuthority = class {
3050
+ constructor(store) {
3051
+ this.store = store;
3052
+ this.armedCode = null;
3053
+ /** A correct code waiting on the store step, restored if that step fails. */
3054
+ this.heldCode = null;
3055
+ this.disarmReason = null;
3056
+ this.consumedBy = null;
3057
+ this.consumedAt = null;
3058
+ this.lastWrite = Promise.resolve();
3059
+ /** Nonce to its timestamp, oldest insertion first. */
3060
+ this.nonces = /* @__PURE__ */ new Map();
3061
+ /**
3062
+ * The oldest timestamp still accepted. A nonce this process did not record
3063
+ * cannot be checked for a replay, so a signature from before it existed is
3064
+ * refused: otherwise an app restart, which empties the cache, would reopen
3065
+ * the whole window to a captured request. Eviction raises it for the same
3066
+ * reason. A clock step re-bases it to the new now, or a clock set back past it
3067
+ * would refuse every signature until the app restarted. That leaves one gap with
3068
+ * two arms: a request captured before a step back can replay once, if its
3069
+ * timestamp is at or above the new now and this process does not remember its
3070
+ * nonce, because it has forgotten it, or because the app restarted since and it
3071
+ * never recorded it. Without `performance.now` no step is seen, and the floor
3072
+ * holds until the clock passes it.
3073
+ */
3074
+ this.oldestAcceptedTs = Date.now();
3075
+ /**
3076
+ * The wall clock minus the monotonic clock at the last re-base. It moves only
3077
+ * then, so small steps add up until they cross the tolerance, and a clock step
3078
+ * leaves the floor at most twice the tolerance ahead of the clock: the clock can
3079
+ * swing from just over the baseline to just under it without crossing it.
3080
+ */
3081
+ this.clockOffset = this.readClockOffset();
3082
+ /** Moves when a revoke ends, so a read of the old token that spans one cannot admit. */
3083
+ this.tokenGeneration = 0;
3084
+ /** Revokes under way. The old token may already be dying, so nothing verifies. */
3085
+ this.revokesInFlight = 0;
3086
+ /** A revoke failed in a way that may have left the old token in the store. */
3087
+ this.storeSuspect = false;
3088
+ }
3089
+ /**
3090
+ * The stored token, `null` when there is none, or `TOKEN_NOT_LOADED`. Only
3091
+ * `readStore` tells these apart: an empty or non-string value is never a
3092
+ * token, because an empty key would verify anything signed with it.
3093
+ */
3094
+ async readToken() {
3095
+ return this.storeSuspect ? TOKEN_NOT_LOADED : this.readStore();
3096
+ }
3097
+ async readStore() {
3098
+ try {
3099
+ const token = await bounded(() => this.store.get());
3100
+ if (token === null) return null;
3101
+ return typeof token === "string" && token.length > 0 ? token : TOKEN_NOT_LOADED;
3102
+ } catch {
3103
+ return TOKEN_NOT_LOADED;
3104
+ }
3105
+ }
3106
+ async randomBytes(n) {
3107
+ const unusable = new PairingError(
3108
+ "PAIRING_UNAVAILABLE",
3109
+ "The secure random source returned no usable bytes."
3110
+ );
3111
+ let bytes;
3112
+ try {
3113
+ bytes = await bounded(() => this.store.randomBytes(n));
3114
+ } catch {
3115
+ throw unusable;
3116
+ }
3117
+ if (!ArrayBuffer.isView(bytes) || bytes.length !== n || bytes.every((byte) => byte === 0)) {
3118
+ throw unusable;
3119
+ }
3120
+ return bytes;
3121
+ }
3122
+ async mintAndStoreToken() {
3123
+ const token = bytesToHex(await this.randomBytes(TOKEN_BYTES));
3124
+ await bounded(() => this.store.set(token));
3125
+ return token;
3126
+ }
3127
+ /** Run token writes one at a time, so two first pairs cannot mint two tokens. */
3128
+ exclusive(work) {
3129
+ const run = this.lastWrite.then(work, work);
3130
+ this.lastWrite = run.catch(() => void 0);
3131
+ return run;
3132
+ }
3133
+ /** Check a `<ts>.<nonce>.<sig>` value against this request and the stored token. */
3134
+ async verifySignature(request, header) {
3135
+ const match = /^(\d{1,16})\.([A-Za-z0-9_-]{16,64})\.([0-9a-f]{64})$/.exec(header);
3136
+ if (!match) return "refused";
3137
+ const [, ts, nonce, sig] = match;
3138
+ const tsMs = Number(ts);
3139
+ this.noticeClockStep();
3140
+ if (Math.abs(Date.now() - tsMs) > SIGNATURE_WINDOW_MS) return "timestamp_refused";
3141
+ if (tsMs < this.oldestAcceptedTs) return "timestamp_refused";
3142
+ const pathAndQuery = signedTarget(request);
3143
+ const body = signedBody(request);
3144
+ if (pathAndQuery === null || body === null) return "refused";
3145
+ if (body.length > SIGNED_BODY_MAX_CHARS) return "body_too_large";
3146
+ if (request.method.length > SIGNED_METHOD_MAX_CHARS) return "target_too_long";
3147
+ if (pathAndQuery.length > SIGNED_TARGET_MAX_CHARS) return "target_too_long";
3148
+ const generation = this.tokenGeneration;
3149
+ const token = await this.readToken();
3150
+ if (this.revokesInFlight > 0) return "refused";
3151
+ if (token === TOKEN_NOT_LOADED) return "store_unavailable";
3152
+ if (token === null) return "refused";
3153
+ const expected = signatureHex(token, { method: request.method, pathAndQuery, ts, nonce, body });
3154
+ if (!constantTimeEqual(expected, sig)) return "refused";
3155
+ if (generation !== this.tokenGeneration) return "refused";
3156
+ if (this.nonces.has(nonce)) return "refused";
3157
+ this.rememberNonce(nonce, tsMs);
3158
+ return "valid";
3159
+ }
3160
+ readClockOffset() {
3161
+ const monotonic = monotonicNow();
3162
+ return monotonic === null ? null : Date.now() - monotonic;
3163
+ }
3164
+ /**
3165
+ * Re-base the floor when the wall clock has stepped against the monotonic one.
3166
+ * A nonce remembered from above the new now is clamped to just below the new
3167
+ * floor. Left as it is, forgetting it later would raise the floor back to a time
3168
+ * before the step, and the lockout would return when the cache overflowed.
3169
+ */
3170
+ noticeClockStep() {
3171
+ const offset = this.readClockOffset();
3172
+ if (offset === null || this.clockOffset === null) return;
3173
+ if (Math.abs(offset - this.clockOffset) > CLOCK_STEP_TOLERANCE_MS) {
3174
+ this.clockOffset = offset;
3175
+ this.oldestAcceptedTs = Date.now();
3176
+ for (const [nonce, tsMs] of this.nonces) {
3177
+ if (tsMs >= this.oldestAcceptedTs) this.nonces.set(nonce, this.oldestAcceptedTs - 1);
3178
+ }
3179
+ }
3180
+ }
3181
+ rememberNonce(nonce, tsMs) {
3182
+ if (this.nonces.size >= NONCE_CACHE_MAX) {
3183
+ const now = Date.now();
3184
+ for (const [seen, seenTs] of this.nonces) {
3185
+ if (seenTs + SIGNATURE_WINDOW_MS < now) this.forgetNonce(seen, seenTs);
3186
+ }
3187
+ }
3188
+ while (this.nonces.size >= NONCE_CACHE_MAX) {
3189
+ const [seen, seenTs] = this.nonces.entries().next().value;
3190
+ this.forgetNonce(seen, seenTs);
3191
+ }
3192
+ this.nonces.set(nonce, tsMs);
3193
+ }
3194
+ /** Forgetting a nonce must not readmit it, so its timestamp stops being accepted. */
3195
+ forgetNonce(nonce, tsMs) {
3196
+ this.nonces.delete(nonce);
3197
+ this.oldestAcceptedTs = Math.max(this.oldestAcceptedTs, tsMs + 1);
3198
+ }
3199
+ /**
3200
+ * The route's decision. A trusted loopback caller needs no code. Anyone else
3201
+ * spends the armed code, and an unarmed one refuses without comparing.
3202
+ */
3203
+ async pair(input) {
3204
+ if (!input.trustedLoopback) {
3205
+ const refusal = this.spendCode(input.body);
3206
+ if (refusal) return refusal;
3207
+ }
3208
+ const spent = input.trustedLoopback ? null : this.heldCode;
3209
+ const outcome = await this.exclusive(async () => {
3210
+ const existing = await this.readToken();
3211
+ if (existing === TOKEN_NOT_LOADED) return UNAVAILABLE;
3212
+ if (existing !== null) return { ok: true, token: existing };
3213
+ try {
3214
+ return { ok: true, token: await this.mintAndStoreToken() };
3215
+ } catch {
3216
+ return UNAVAILABLE;
3217
+ }
3218
+ });
3219
+ if (spent) this.settleSpentCode(spent, outcome.ok, input.peer);
3220
+ return outcome;
3221
+ }
3222
+ /**
3223
+ * The store step has answered. A failure gives the code back, since the user
3224
+ * typed the right one and the phone let them down, unless the app has since
3225
+ * cancelled it or armed another.
3226
+ */
3227
+ settleSpentCode(spent, paired, peer) {
3228
+ const stillHeld = this.heldCode === spent;
3229
+ if (stillHeld) this.heldCode = null;
3230
+ if (paired) {
3231
+ if (stillHeld) this.disarmReason = "consumed";
3232
+ this.consumedBy = peer ?? "unknown";
3233
+ this.consumedAt = Date.now();
3234
+ } else if (stillHeld) {
3235
+ this.armedCode = spent;
3236
+ }
3237
+ }
3238
+ /** No await in here: the attempt count has to move with the comparison. */
3239
+ spendCode(body) {
3240
+ this.expireCode();
3241
+ const armed = this.armedCode;
3242
+ if (!armed) {
3243
+ return {
3244
+ ok: false,
3245
+ code: "PAIRING_NOT_ARMED",
3246
+ error: "No pairing code is armed. Arm one in the app, or pair over a cable."
3247
+ };
3248
+ }
3249
+ const code = body?.code;
3250
+ if (typeof code !== "string" || !/^\d{6}$/.test(code)) {
3251
+ return {
3252
+ ok: false,
3253
+ code: "PAIRING_CODE_REQUIRED",
3254
+ error: 'Send the 6-digit code shown in the app, as { "code": "123456" }.'
3255
+ };
3256
+ }
3257
+ if (constantTimeEqual(armed.code, code)) {
3258
+ this.armedCode = null;
3259
+ this.heldCode = armed;
3260
+ return null;
3261
+ }
3262
+ armed.wrongAttempts += 1;
3263
+ if (armed.wrongAttempts >= PAIRING_CODE_MAX_ATTEMPTS) {
3264
+ this.armedCode = null;
3265
+ this.disarmReason = "attempts_exhausted";
3266
+ }
3267
+ return { ok: false, code: "PAIRING_CODE_INVALID", error: "That code is not correct." };
3268
+ }
3269
+ /** Either clock ends the code, so a clock set back cannot extend it. */
3270
+ expireCode() {
3271
+ if (!this.armedCode) return;
3272
+ const { expiresAt, monotonicDeadline } = this.armedCode;
3273
+ const monotonic = monotonicNow();
3274
+ const monotonicExpired = monotonic !== null && monotonicDeadline !== null && monotonic >= monotonicDeadline;
3275
+ if (Date.now() >= expiresAt || monotonicExpired) {
3276
+ this.armedCode = null;
3277
+ this.disarmReason = "expired";
3278
+ }
3279
+ }
3280
+ /** Arm a fresh code, replacing any live one. */
3281
+ async arm() {
3282
+ const stored = await this.readToken();
3283
+ if (stored === TOKEN_NOT_LOADED) {
3284
+ throw new PairingError("PAIRING_UNAVAILABLE", "The pairing store is not available yet.");
3285
+ }
3286
+ const code = await this.drawCode();
3287
+ const expiresAt = Date.now() + PAIRING_CODE_TTL_MS;
3288
+ const monotonic = monotonicNow();
3289
+ this.armedCode = {
3290
+ code,
3291
+ expiresAt,
3292
+ monotonicDeadline: monotonic === null ? null : monotonic + PAIRING_CODE_TTL_MS,
3293
+ wrongAttempts: 0
3294
+ };
3295
+ this.heldCode = null;
3296
+ this.disarmReason = null;
3297
+ this.consumedBy = null;
3298
+ this.consumedAt = null;
3299
+ return { code, expiresAt };
3300
+ }
3301
+ /** A uniform 6-digit code, by rejection sampling so that no digit is favoured. */
3302
+ async drawCode() {
3303
+ for (let attempt = 0; attempt < CODE_SAMPLE_ATTEMPTS; attempt++) {
3304
+ const bytes = await this.randomBytes(4);
3305
+ const value = new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0);
3306
+ if (value < CODE_SAMPLE_LIMIT) return String(value % 1e6).padStart(6, "0");
3307
+ }
3308
+ throw new PairingError(
3309
+ "PAIRING_UNAVAILABLE",
3310
+ "The secure random source returned no usable bytes."
3311
+ );
3312
+ }
3313
+ /** Cancel a live code. */
3314
+ disarm() {
3315
+ this.dropCode("cancelled");
3316
+ }
3317
+ dropCode(reason) {
3318
+ if (this.armedCode || this.heldCode) {
3319
+ this.armedCode = null;
3320
+ this.heldCode = null;
3321
+ this.disarmReason = reason;
3322
+ }
3323
+ }
3324
+ /**
3325
+ * Replace the token, so every signature made with the old one stops verifying.
3326
+ * If it cannot be replaced it is cleared. If that fails too, signatures and
3327
+ * pairing stay refused until a revoke succeeds: the user asked for the old
3328
+ * token to die, and a store that would not say so cannot be trusted to have
3329
+ * done it.
3330
+ *
3331
+ * Nothing verifies from the moment this starts until it ends. The generation
3332
+ * moves when it ends, whichever way, so a check that read the old token before
3333
+ * then cannot admit its request afterwards. A request a check has already
3334
+ * admitted runs to its end.
3335
+ */
3336
+ async revoke() {
3337
+ this.revokesInFlight += 1;
3338
+ this.dropCode("revoked");
3339
+ try {
3340
+ await this.exclusive(async () => {
3341
+ const stored = await this.readStore();
3342
+ if (stored === TOKEN_NOT_LOADED) {
3343
+ throw new PairingError("PAIRING_UNAVAILABLE", "The pairing store is not available yet.");
3344
+ }
3345
+ try {
3346
+ await this.mintAndStoreToken();
3347
+ this.storeSuspect = false;
3348
+ } catch {
3349
+ try {
3350
+ await bounded(() => this.store.clear());
3351
+ this.storeSuspect = false;
3352
+ } catch {
3353
+ this.storeSuspect = true;
3354
+ }
3355
+ throw new PairingError("PAIRING_UNAVAILABLE", "The pairing token could not be replaced.");
3356
+ }
3357
+ });
3358
+ } finally {
3359
+ this.revokesInFlight -= 1;
3360
+ this.tokenGeneration += 1;
3361
+ }
3362
+ }
3363
+ /** What Settings shows. */
3364
+ getState() {
3365
+ this.expireCode();
3366
+ return {
3367
+ configured: true,
3368
+ armed: this.armedCode !== null,
3369
+ expiresAt: this.armedCode?.expiresAt ?? null,
3370
+ attemptsRemaining: this.armedCode ? PAIRING_CODE_MAX_ATTEMPTS - this.armedCode.wrongAttempts : null,
3371
+ consumedBy: this.consumedBy,
3372
+ consumedAt: this.consumedAt,
3373
+ disarmReason: this.disarmReason
3374
+ };
3375
+ }
3376
+ };
3377
+ var UNCONFIGURED_PAIRING_STATE = {
3378
+ configured: false,
3379
+ armed: false,
3380
+ expiresAt: null,
3381
+ attemptsRemaining: null,
3382
+ consumedBy: null,
3383
+ consumedAt: null,
3384
+ disarmReason: null
3385
+ };
3386
+ function signedTarget(request) {
3387
+ if (request.rawTarget === void 0) {
3388
+ return Object.keys(request.query ?? {}).length === 0 ? request.path : null;
3389
+ }
3390
+ const queryAt = request.rawTarget.indexOf("?");
3391
+ const path = queryAt === -1 ? request.rawTarget : request.rawTarget.slice(0, queryAt);
3392
+ return path === request.path ? request.rawTarget : null;
3393
+ }
3394
+ function signedBody(request) {
3395
+ if (request.rawBody !== void 0) return request.rawBody;
3396
+ return request.body === void 0 ? "" : null;
3397
+ }
3398
+
2825
3399
  // src/server/observability.ts
2826
3400
  var RingBuffer = class {
2827
3401
  constructor(capacity) {
@@ -3152,7 +3726,20 @@ function isUnsubscribe(msg) {
3152
3726
  var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
3153
3727
  ["NOT_SUPPORTED", 501],
3154
3728
  ["NOT_FOUND", 404],
3155
- ["METHOD_NOT_ALLOWED", 405]
3729
+ ["METHOD_NOT_ALLOWED", 405],
3730
+ // Device pairing, all answered by the network entry. A 401 means this caller
3731
+ // has not proved it may act; a 403 means proving it would change nothing; a 503
3732
+ // means the phone cannot check yet.
3733
+ ["PAIRING_REQUIRED", 401],
3734
+ ["PAIRING_UNCONFIGURED", 401],
3735
+ ["PAIRING_NOT_ARMED", 401],
3736
+ ["PAIRING_CODE_INVALID", 401],
3737
+ ["PAIRING_TIMESTAMP_REFUSED", 401],
3738
+ ["PAIRING_WS_LOOPBACK_ONLY", 403],
3739
+ ["PAIRING_WEB_CALLER_REFUSED", 403],
3740
+ ["PAIRING_BODY_TOO_LARGE", 413],
3741
+ ["PAIRING_TARGET_TOO_LONG", 414],
3742
+ ["PAIRING_UNAVAILABLE", 503]
3156
3743
  ]);
3157
3744
  function httpStatusForResponse(response) {
3158
3745
  if (response.success) return 200;
@@ -3178,6 +3765,10 @@ function parsePath(pattern, actual) {
3178
3765
  var WS_ROUTES = [
3179
3766
  // Health
3180
3767
  { pattern: "health", handler: "health", httpMethods: ["GET"] },
3768
+ // Device pairing. Always NOT_SUPPORTED from this table: the real route is in
3769
+ // `handleNetworkRequest`, because route context carries no peer, and this
3770
+ // entry is what JSON-RPC and the relay reach instead.
3771
+ { pattern: "pair", handler: "pair", httpMethods: ["POST"] },
3181
3772
  // Elements
3182
3773
  { pattern: "control/elements", handler: "getElements", httpMethods: ["GET"] },
3183
3774
  // Flat alias — matches the runner UI Bridge's `/ui-bridge/elements` path so
@@ -3340,6 +3931,7 @@ function allowedMethodsForPath(path, testHooks) {
3340
3931
  }
3341
3932
  var HANDLER_DESCRIPTIONS = {
3342
3933
  health: "Liveness probe; returns app metadata + element count",
3934
+ pair: "Get the pairing token: loopback with no code, otherwise body { code }. Direct HTTP only",
3343
3935
  getElements: "Flat list of registered UI elements",
3344
3936
  getElement: "Single element by id",
3345
3937
  getElementState: "Dynamic state (value, focused, checked) for one element",
@@ -3441,10 +4033,24 @@ function buildHelpPayload(testHooks) {
3441
4033
  ]
3442
4034
  };
3443
4035
  }
4036
+ var HEALTH_PATH = "/ui-bridge/health";
4037
+ var PAIR_PATH = "/ui-bridge/pair";
4038
+ function jsonRpcIdOf(rawMessage) {
4039
+ try {
4040
+ const id = JSON.parse(rawMessage)?.id;
4041
+ return typeof id === "number" || typeof id === "string" ? id : 0;
4042
+ } catch {
4043
+ return 0;
4044
+ }
4045
+ }
3444
4046
  var NativeUIBridgeServer = class {
3445
4047
  constructor(registry, executor, config = {}) {
3446
4048
  this.registry = registry;
3447
4049
  this.executor = executor;
4050
+ /** Whether this run's adapter has called `onWebSocketUpgrade` at all. */
4051
+ this.upgradeGateSeen = false;
4052
+ /** Warned once per run rather than per request, which would flood the error ring. */
4053
+ this.warnedMissingPeer = false;
3448
4054
  this.running = false;
3449
4055
  /**
3450
4056
  * Per-connection cleanup thunks for in-flight waiters (waitForElement,
@@ -3457,6 +4063,9 @@ var NativeUIBridgeServer = class {
3457
4063
  cors: true,
3458
4064
  ...config
3459
4065
  };
4066
+ if (this.config.pairingStore) {
4067
+ this.pairing = new PairingAuthority(this.config.pairingStore);
4068
+ }
3460
4069
  this.consoleErrorBuffer = new ConsoleErrorBuffer();
3461
4070
  this.networkRequestBuffer = new NetworkRequestBuffer();
3462
4071
  if (this.config.appInfo) {
@@ -3502,6 +4111,14 @@ var NativeUIBridgeServer = class {
3502
4111
  setAdapter(adapter) {
3503
4112
  this.adapter = adapter;
3504
4113
  }
4114
+ /**
4115
+ * Set the device's stable id. An app usually loads it from storage after the
4116
+ * server has started, and this spares it a restart that would drop every
4117
+ * socket and any armed code.
4118
+ */
4119
+ setDeviceId(deviceId) {
4120
+ this.config.deviceId = deviceId;
4121
+ }
3505
4122
  /**
3506
4123
  * Set a navigation provider for programmatic route navigation.
3507
4124
  * This enables `control/page/navigate` and `control/page/back` on native.
@@ -3735,7 +4352,18 @@ var NativeUIBridgeServer = class {
3735
4352
  this.consoleErrorBuffer.install();
3736
4353
  this.networkRequestBuffer.install();
3737
4354
  }
3738
- await this.adapter.start(this.config.serverPort, this.handleRequest.bind(this));
4355
+ this.upgradeGateSeen = false;
4356
+ this.warnedMissingPeer = false;
4357
+ if (this.config.allowUnauthenticatedNetwork) {
4358
+ console.warn(
4359
+ "[ui-bridge-native] allowUnauthenticatedNetwork is on: anyone who can reach this port can drive the app over HTTP."
4360
+ );
4361
+ } else if (!this.pairing) {
4362
+ console.warn(
4363
+ "[ui-bridge-native] No pairingStore configured: non-loopback callers are refused with PAIRING_UNCONFIGURED."
4364
+ );
4365
+ }
4366
+ await this.adapter.start(this.config.serverPort, this.handleNetworkRequest.bind(this));
3739
4367
  this.running = true;
3740
4368
  console.log(`[ui-bridge-native] HTTP server started on port ${this.config.serverPort}`);
3741
4369
  }
@@ -3764,6 +4392,26 @@ var NativeUIBridgeServer = class {
3764
4392
  setEventBridge(bridge) {
3765
4393
  this.eventBridge = bridge;
3766
4394
  }
4395
+ /**
4396
+ * The entry a socket's messages come through. An adapter that never called
4397
+ * `onWebSocketUpgrade` admitted the socket with no check, and a message carries
4398
+ * no peer to check now, so it is refused: the same fail-closed reading as a
4399
+ * missing peer address. An adapter that gates sees a call before its first socket.
4400
+ */
4401
+ async handleNetworkWebSocketMessage(connId, rawMessage) {
4402
+ if (!this.upgradeGateSeen) {
4403
+ return JSON.stringify({
4404
+ id: jsonRpcIdOf(rawMessage),
4405
+ result: {
4406
+ success: false,
4407
+ error: "This server adapter does not gate WebSocket upgrades (onWebSocketUpgrade).",
4408
+ code: "PAIRING_UNCONFIGURED",
4409
+ timestamp: Date.now()
4410
+ }
4411
+ });
4412
+ }
4413
+ return this.handleWebSocketMessage(connId, rawMessage);
4414
+ }
3767
4415
  /**
3768
4416
  * Handle a JSON-RPC message from a WebSocket client.
3769
4417
  * Parses the message, routes to the appropriate handler, and returns
@@ -4292,21 +4940,272 @@ var NativeUIBridgeServer = class {
4292
4940
  timestamp: Date.now()
4293
4941
  };
4294
4942
  }
4943
+ // ── Device pairing ──────────────────────────────────────────────────────
4295
4944
  /**
4296
- * Handle incoming HTTP request
4945
+ * What `start()` hands the adapter: every request off the socket comes through
4946
+ * here, and `handleRequest` sits behind it.
4947
+ *
4948
+ * | Caller | Admitted |
4949
+ * |----------------------------------------------------------|----------|
4950
+ * | Loopback peer, no web context, loopback `Host` | yes |
4951
+ * | Anyone with a valid `X-UI-Bridge-Auth` signature | yes |
4952
+ * | Anyone, `GET /ui-bridge/health` (trimmed) | yes |
4953
+ * | Anyone with no web context, `POST /ui-bridge/pair` | yes |
4954
+ * | A web page, `POST /ui-bridge/pair` | 403 `PAIRING_WEB_CALLER_REFUSED` |
4955
+ * | Signature outside the window or below the floor | 401 `PAIRING_TIMESTAMP_REFUSED` |
4956
+ * | Signed body over `SIGNED_BODY_MAX_CHARS` | 413 `PAIRING_BODY_TOO_LARGE` |
4957
+ * | Signed method or target over its cap | 414 `PAIRING_TARGET_TOO_LONG` |
4958
+ * | Everyone else | 401 `PAIRING_REQUIRED` |
4959
+ * | No store configured, not loopback | 401 `PAIRING_UNCONFIGURED` |
4960
+ * | Under `allowUnauthenticatedNetwork`, a web page or a rebindable `Host` | 403 `PAIRING_WEB_CALLER_REFUSED` |
4961
+ *
4962
+ * The gate is here rather than in `handleRequest` because `CloudRelayClient`
4963
+ * calls that directly and must keep working: relay access already means the
4964
+ * same user account, checked by the backend.
4965
+ */
4966
+ async handleNetworkRequest(request) {
4967
+ try {
4968
+ if (request.method === "POST" && request.path === PAIR_PATH) {
4969
+ return await this.handlePair(request);
4970
+ }
4971
+ const admission = await this.admit(request);
4972
+ if (request.method === "GET" && request.path === HEALTH_PATH) {
4973
+ return await this.handleNetworkHealth(request, admission);
4974
+ }
4975
+ if (!admission.admitted) {
4976
+ return this.refusal(admission.code, admission.error, { data: admission.data });
4977
+ }
4978
+ return await this.handleRequest(request);
4979
+ } catch {
4980
+ return this.refusal("INTERNAL_ERROR", "Internal server error", { status: 500 });
4981
+ }
4982
+ }
4983
+ /**
4984
+ * The WebSocket door: trusted loopback or nothing, whatever else the request
4985
+ * carries. A signature covers no upgrade intent, so one swallowed GET re-sent
4986
+ * with `Upgrade: websocket` would become a permanent control socket, and a socket
4987
+ * outlives the signature's window. `allowUnauthenticatedNetwork` opens HTTP only,
4988
+ * and no runner dials a device's socket.
4989
+ */
4990
+ handleWebSocketUpgrade(request) {
4991
+ this.upgradeGateSeen = true;
4992
+ if (this.isTrustedLoopback(request)) return { admit: true };
4993
+ return {
4994
+ admit: false,
4995
+ response: this.refusal(
4996
+ "PAIRING_WS_LOOPBACK_ONLY",
4997
+ "WebSocket upgrades are accepted only from this machine. Use signed HTTP from anywhere else."
4998
+ )
4999
+ };
5000
+ }
5001
+ isTrustedLoopback(request) {
5002
+ if (!request.remoteAddress && !this.warnedMissingPeer) {
5003
+ this.warnedMissingPeer = true;
5004
+ console.warn(
5005
+ "[ui-bridge-native] A request arrived with no peer address and is treated as not loopback. The server adapter must pass remoteAddress."
5006
+ );
5007
+ }
5008
+ return isTrustedLoopbackRequest(request);
5009
+ }
5010
+ async admit(request) {
5011
+ if (this.isTrustedLoopback(request)) return { admitted: true, as: "loopback" };
5012
+ if (this.config.allowUnauthenticatedNetwork) {
5013
+ if (isWebReachableRequest(request)) {
5014
+ return {
5015
+ admitted: false,
5016
+ code: "PAIRING_WEB_CALLER_REFUSED",
5017
+ error: "A web page cannot drive this device."
5018
+ };
5019
+ }
5020
+ return { admitted: true, as: "unauthenticated-network" };
5021
+ }
5022
+ if (!this.pairing) {
5023
+ return {
5024
+ admitted: false,
5025
+ code: "PAIRING_UNCONFIGURED",
5026
+ error: "This app has not configured device pairing, so it accepts only local callers."
5027
+ };
5028
+ }
5029
+ const required = {
5030
+ admitted: false,
5031
+ code: "PAIRING_REQUIRED",
5032
+ error: "This device requires pairing. Sign the request with the token from POST /ui-bridge/pair."
5033
+ };
5034
+ const signatures = headerValues(request.headers, UI_BRIDGE_AUTH_HEADER);
5035
+ if (signatures.length !== 1) return required;
5036
+ const verdict = await this.pairing.verifySignature(request, signatures[0]);
5037
+ if (verdict === "valid") return { admitted: true, as: "signed" };
5038
+ if (verdict === "store_unavailable") {
5039
+ return {
5040
+ admitted: false,
5041
+ code: "PAIRING_UNAVAILABLE",
5042
+ error: "The pairing store is not available yet. Try again."
5043
+ };
5044
+ }
5045
+ if (verdict === "timestamp_refused") {
5046
+ return {
5047
+ admitted: false,
5048
+ code: "PAIRING_TIMESTAMP_REFUSED",
5049
+ error: "The signature timestamp is not acceptable. Re-sync to data.serverTime and sign again.",
5050
+ data: { serverTime: Date.now() }
5051
+ };
5052
+ }
5053
+ if (verdict === "body_too_large") {
5054
+ return {
5055
+ admitted: false,
5056
+ code: "PAIRING_BODY_TOO_LARGE",
5057
+ error: `A signed body may be at most ${SIGNED_BODY_MAX_CHARS} characters.`
5058
+ };
5059
+ }
5060
+ if (verdict === "target_too_long") {
5061
+ return {
5062
+ admitted: false,
5063
+ code: "PAIRING_TARGET_TOO_LONG",
5064
+ error: `A signed target may be at most ${SIGNED_TARGET_MAX_CHARS} characters.`
5065
+ };
5066
+ }
5067
+ return required;
5068
+ }
5069
+ corsHeaders() {
5070
+ if (!this.config.cors) return {};
5071
+ return {
5072
+ "Access-Control-Allow-Origin": this.config.allowedOrigins ? this.config.allowedOrigins.join(",") : "*",
5073
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
5074
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
5075
+ };
5076
+ }
5077
+ /** No CORS header: a page must not be able to read what the gate says. */
5078
+ refusal(code, error3, extra = {}) {
5079
+ const body = {
5080
+ success: false,
5081
+ error: error3,
5082
+ code,
5083
+ timestamp: Date.now(),
5084
+ ...extra.data ? { data: extra.data } : {}
5085
+ };
5086
+ return {
5087
+ status: extra.status ?? httpStatusForResponse(body),
5088
+ headers: { "Content-Type": "application/json" },
5089
+ body: JSON.stringify(body)
5090
+ };
5091
+ }
5092
+ /**
5093
+ * Open health. An admitted caller gets the full answer. Anyone else gets what
5094
+ * discovery needs and nothing about the app's contents, and that answer is
5095
+ * built without going through `handleRequest`, whose keep-awake auto-arm is no
5096
+ * business of an anonymous LAN probe. It carries no CORS header, so a web page
5097
+ * cannot read the stable device id.
5098
+ */
5099
+ async handleNetworkHealth(request, admission) {
5100
+ const pairing = {
5101
+ required: !(admission.admitted && (admission.as === "loopback" || admission.as === "unauthenticated-network")),
5102
+ authenticated: admission.admitted && admission.as !== "unauthenticated-network"
5103
+ };
5104
+ const identity = {
5105
+ deviceId: this.config.deviceId ?? null,
5106
+ serverTime: Date.now(),
5107
+ pairing
5108
+ };
5109
+ if (admission.admitted) {
5110
+ const response = await this.handleRequest(request);
5111
+ const parsed = JSON.parse(response.body);
5112
+ if (!parsed.success || !parsed.data) return response;
5113
+ return {
5114
+ ...response,
5115
+ body: JSON.stringify({ ...parsed, data: { ...parsed.data, ...identity } })
5116
+ };
5117
+ }
5118
+ const health = await this.handlers.health({ params: {}, query: {}, body: void 0 });
5119
+ const full = health.data ?? {};
5120
+ const trimmed = { status: full.status, ...identity };
5121
+ if (full.uiBridge) {
5122
+ const { version, appId, appName, appType, framework } = full.uiBridge;
5123
+ trimmed.uiBridge = { version, appId, appName, appType, framework };
5124
+ }
5125
+ return {
5126
+ status: 200,
5127
+ headers: { "Content-Type": "application/json" },
5128
+ body: JSON.stringify({ success: true, data: trimmed, timestamp: Date.now() })
5129
+ };
5130
+ }
5131
+ /**
5132
+ * `POST /ui-bridge/pair`, the only way to a token, and only ever from here.
5133
+ * Its answer carries no CORS header, because no web page has any business
5134
+ * reading a token, and it answers only after the store write has finished. A web
5135
+ * page is refused first of all, so it cannot burn an attempt or the armed code.
5136
+ */
5137
+ async handlePair(request) {
5138
+ if (carriesWebContext(request)) {
5139
+ return this.refusal("PAIRING_WEB_CALLER_REFUSED", "A web page cannot pair with this device.");
5140
+ }
5141
+ if (!this.pairing) {
5142
+ return this.refusal(
5143
+ "PAIRING_UNCONFIGURED",
5144
+ "This app has not configured device pairing, so it cannot issue a token."
5145
+ );
5146
+ }
5147
+ const deviceId = this.config.deviceId;
5148
+ if (!deviceId) {
5149
+ return this.refusal("PAIRING_UNAVAILABLE", "The device id is not available yet. Try again.");
5150
+ }
5151
+ const outcome = await this.pairing.pair({
5152
+ trustedLoopback: this.isTrustedLoopback(request),
5153
+ peer: request.remoteAddress,
5154
+ body: request.body
5155
+ });
5156
+ if (!outcome.ok) return this.refusal(outcome.code, outcome.error);
5157
+ const body = {
5158
+ success: true,
5159
+ data: { token: outcome.token, deviceId, serverTime: Date.now() },
5160
+ timestamp: Date.now()
5161
+ };
5162
+ return {
5163
+ status: 200,
5164
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
5165
+ body: JSON.stringify(body)
5166
+ };
5167
+ }
5168
+ /**
5169
+ * Arm a 6-digit code for a LAN pairing. In memory and single use. It stops
5170
+ * working after about 2 minutes, or after 5 wrong attempts from anyone. Rejects
5171
+ * with a `PairingError` when no store is configured or it has not loaded.
5172
+ */
5173
+ async armPairingCode() {
5174
+ return this.requirePairing().arm();
5175
+ }
5176
+ /** Cancel a live code. */
5177
+ disarmPairingCode() {
5178
+ this.pairing?.disarm();
5179
+ }
5180
+ /** What Settings shows, including the peer that consumed the last code. */
5181
+ getPairingState() {
5182
+ return this.pairing?.getState() ?? UNCONFIGURED_PAIRING_STATE;
5183
+ }
5184
+ /**
5185
+ * Replace the token, so every signature made with the old one stops verifying.
5186
+ * There are no network sockets to close, because none is ever admitted.
5187
+ */
5188
+ async revokePairing() {
5189
+ await this.requirePairing().revoke();
5190
+ }
5191
+ requirePairing() {
5192
+ if (!this.pairing) {
5193
+ throw new PairingError("PAIRING_UNCONFIGURED", "No pairingStore is configured.");
5194
+ }
5195
+ return this.pairing;
5196
+ }
5197
+ /**
5198
+ * Handle incoming HTTP request, past the gate.
4297
5199
  *
4298
5200
  * Exposed as public so that CloudRelayClient can call it directly (without
4299
- * loopback TCP overhead) when processing tunneled requests.
5201
+ * loopback TCP overhead) when processing tunneled requests. Anything reached
5202
+ * from a socket goes through `handleNetworkRequest` first.
4300
5203
  */
4301
5204
  async handleRequest(request) {
4302
5205
  const headers = {
4303
- "Content-Type": "application/json"
5206
+ "Content-Type": "application/json",
5207
+ ...this.corsHeaders()
4304
5208
  };
4305
- if (this.config.cors) {
4306
- headers["Access-Control-Allow-Origin"] = this.config.allowedOrigins ? this.config.allowedOrigins.join(",") : "*";
4307
- headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS";
4308
- headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization";
4309
- }
4310
5209
  if (request.method === "OPTIONS") {
4311
5210
  return { status: 204, headers, body: "" };
4312
5211
  }
@@ -5100,6 +5999,6 @@ async function bindWithRetry(attempt, options = {}) {
5100
5999
  }
5101
6000
  }
5102
6001
 
5103
- export { ConsoleErrorBuffer, NATIVE_PAGE_HEALTH_PRODUCER_ID, NativeUIBridgeServer, NetworkRequestBuffer, OPCODE_BINARY, OPCODE_CLOSE, OPCODE_CONTINUATION, OPCODE_PING, OPCODE_PONG, OPCODE_TEXT, Observation, ObservationError, UI_BRIDGE_NATIVE_ROUTES, UNKNOWN_CODES, WebSocketConnection, WebSocketEventBridge, bindWithRetry, buildUpgradeResponse, computeAcceptKey, createDesignHandlers, createNativeServer, createServerHandlers, decodeFrames, decodeTextPayload, diagnosePageHealth, encodeCloseFrame, encodePingFrame, encodePongFrame, encodeTextFrame, httpStatusForResponse, isAddrInUseError, isBatchRequest, isSubscribe, isUnknownCode, isUnsubscribe, pageHealthUnknown };
6002
+ export { ConsoleErrorBuffer, NATIVE_PAGE_HEALTH_PRODUCER_ID, NativeUIBridgeServer, NetworkRequestBuffer, OPCODE_BINARY, OPCODE_CLOSE, OPCODE_CONTINUATION, OPCODE_PING, OPCODE_PONG, OPCODE_TEXT, Observation, ObservationError, PAIRING_CODE_MAX_ATTEMPTS, PAIRING_CODE_TTL_MS, PairingAuthority, PairingError, SIGNATURE_WINDOW_MS, SIGNED_BODY_MAX_CHARS, SIGNED_METHOD_MAX_CHARS, SIGNED_TARGET_MAX_CHARS, UI_BRIDGE_AUTH_HEADER, UI_BRIDGE_NATIVE_ROUTES, UNCONFIGURED_PAIRING_STATE, UNKNOWN_CODES, WebSocketConnection, WebSocketEventBridge, bindWithRetry, buildUpgradeResponse, canonicalSignatureInput, carriesWebContext, computeAcceptKey, createDesignHandlers, createNativeServer, createServerHandlers, decodeFrames, decodeTextPayload, diagnosePageHealth, encodeCloseFrame, encodePingFrame, encodePongFrame, encodeTextFrame, headerValues, httpStatusForResponse, isAddrInUseError, isBatchRequest, isLoopbackAddress, isLoopbackHost, isSubscribe, isTrustedLoopbackRequest, isUnknownCode, isUnrebindableHost, isUnsubscribe, isWebReachableRequest, pageHealthUnknown, signUIBridgeRequest };
5104
6003
  //# sourceMappingURL=index.mjs.map
5105
6004
  //# sourceMappingURL=index.mjs.map