From 6140cf83b797c404c0822c08ea6410f61ee3a6da Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 10 Sep 2026 07:53:47 +0100 Subject: [PATCH 1/3] feat(load-balancer): make the development provider URL configurable In development the widget looked for its provider at a hardcoded https://localhost:9229. That is right when the page and the provider share a machine and wrong as soon as they do not: a phone or an iOS simulator loading the demo over the LAN resolves localhost to itself, never reaches the provider, and reports the site key as unregistered. PROSOPO_PROVIDER_URL_DEVELOPMENT now overrides it. Unset, it still falls back to https://localhost:9229, so existing setups are unaffected. Staging and production resolve through their DNS-routed endpoints and ignore it. The override is read as a static process.env access so the frontend build can substitute it for a literal, and the endpoint map is resolved per call rather than once at module load. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/dev-provider-url-env-var.md | 18 +++++ dev/config/src/vite/vite.frontend.config.ts | 6 ++ packages/load-balancer/src/balancer.ts | 3 +- .../src/developmentProviderUrl.ts | 53 +++++++++++++ packages/load-balancer/src/index.ts | 1 + packages/load-balancer/src/providers.ts | 15 ++-- .../tests/developmentProviderUrl.unit.test.ts | 76 +++++++++++++++++++ 7 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 .changeset/dev-provider-url-env-var.md create mode 100644 packages/load-balancer/src/developmentProviderUrl.ts create mode 100644 packages/load-balancer/src/tests/developmentProviderUrl.unit.test.ts diff --git a/.changeset/dev-provider-url-env-var.md b/.changeset/dev-provider-url-env-var.md new file mode 100644 index 0000000000..de86797c3c --- /dev/null +++ b/.changeset/dev-provider-url-env-var.md @@ -0,0 +1,18 @@ +--- +"@prosopo/load-balancer": patch +"@prosopo/config": patch +--- + +Let the development provider URL be set with an environment variable. + +In development the widget looked for its provider at a hardcoded +`https://localhost:9229`. That is right when the page and the provider share a +machine and wrong as soon as they do not: a phone or an iOS simulator loading +the demo over the LAN resolves `localhost` to itself, never reaches the +provider, and reports the site key as unregistered — which is a confusing way +to be told the request went nowhere. + +`PROSOPO_PROVIDER_URL_DEVELOPMENT` now overrides it, so that case can be +pointed at the host actually running the provider. Unset, it still defaults to +`https://localhost:9229`, so existing setups are unaffected. Staging and +production resolve through their DNS-routed endpoints and ignore it. diff --git a/dev/config/src/vite/vite.frontend.config.ts b/dev/config/src/vite/vite.frontend.config.ts index 25909f6792..71c69bcb8d 100644 --- a/dev/config/src/vite/vite.frontend.config.ts +++ b/dev/config/src/vite/vite.frontend.config.ts @@ -75,6 +75,12 @@ export default async function ( "process.env.PROSOPO_SERVER_URL": JSON.stringify( process.env.PROSOPO_SERVER_URL, ), + // Lets a development widget reach a provider that is not on the machine + // serving the page — a phone or simulator testing over the LAN would + // otherwise resolve the default `localhost` to itself. + "process.env.PROSOPO_PROVIDER_URL_DEVELOPMENT": JSON.stringify( + process.env.PROSOPO_PROVIDER_URL_DEVELOPMENT, + ), "process.env._DEV_ONLY_WATCH_EVENTS": JSON.stringify( process.env._DEV_ONLY_WATCH_EVENTS, ), diff --git a/packages/load-balancer/src/balancer.ts b/packages/load-balancer/src/balancer.ts index bb7214df53..4489624536 100644 --- a/packages/load-balancer/src/balancer.ts +++ b/packages/load-balancer/src/balancer.ts @@ -14,6 +14,7 @@ import { ProsopoEnvError } from "@prosopo/common"; import type { EnvironmentTypes } from "@prosopo/types"; import { z } from "zod"; +import { getDevelopmentProviderUrl } from "./developmentProviderUrl.js"; const HardcodedProviderSchema = z.object({ address: z.string(), @@ -89,7 +90,7 @@ export const loadBalancer = async ( return [ { address: "5EjTA28bKSbFPPyMbUjNtArxyqjwq38r1BapVmLZShaqEedV", - url: "https://localhost:9229", + url: getDevelopmentProviderUrl(), datasetId: "0x7984714b92d61fd92fd6a7bc9b56b729481470bcc771c19c382ec679acf02e67", weight: 1, diff --git a/packages/load-balancer/src/developmentProviderUrl.ts b/packages/load-balancer/src/developmentProviderUrl.ts new file mode 100644 index 0000000000..61b6ac98ac --- /dev/null +++ b/packages/load-balancer/src/developmentProviderUrl.ts @@ -0,0 +1,53 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Where a development widget looks for its provider. + * + * `localhost` is right when the page and the provider share a machine, and + * wrong the moment they do not: a phone or a simulator loading the demo over + * the LAN resolves `localhost` to itself, so the widget never reaches the + * provider and reports the site key as unregistered. The override exists so + * that case can be pointed at the host running the provider. + * + * Development only — staging and production resolve through their DNS-routed + * endpoints and ignore this entirely. + */ +export const DEFAULT_DEVELOPMENT_PROVIDER_URL = "https://localhost:9229"; + +/** + * Read the override without assuming a runtime. + * + * Written as a static `process.env.X` access on purpose: the frontend build + * substitutes that exact expression for a literal, which a computed lookup + * (`process.env[key]`) would not match. For the same reason there is no + * `typeof process` guard — in a browser bundle the whole expression is already + * a string by the time it runs, and guarding on a `process` that does not + * exist there would discard it. Under plain node with the variable unset it is + * simply undefined; where neither applies the access throws and is caught. + */ +export const readDevelopmentProviderUrlOverride = (): string | undefined => { + try { + return process.env.PROSOPO_PROVIDER_URL_DEVELOPMENT; + } catch { + return undefined; + } +}; + +export const getDevelopmentProviderUrl = ( + override: string | undefined = readDevelopmentProviderUrlOverride(), +): string => { + const trimmed = override?.trim(); + return trimmed ? trimmed.replace(/\/$/, "") : DEFAULT_DEVELOPMENT_PROVIDER_URL; +}; diff --git a/packages/load-balancer/src/index.ts b/packages/load-balancer/src/index.ts index 83b61e1191..25efc41720 100644 --- a/packages/load-balancer/src/index.ts +++ b/packages/load-balancer/src/index.ts @@ -13,3 +13,4 @@ // limitations under the License. export * from "./providers.js"; export * from "./balancer.js"; +export * from "./developmentProviderUrl.js"; diff --git a/packages/load-balancer/src/providers.ts b/packages/load-balancer/src/providers.ts index eaa6642f98..d498209c2f 100644 --- a/packages/load-balancer/src/providers.ts +++ b/packages/load-balancer/src/providers.ts @@ -18,6 +18,7 @@ import { type IpMode, loadBalancer, } from "./balancer.js"; +import { getDevelopmentProviderUrl } from "./developmentProviderUrl.js"; import { retryWithBackoff } from "./retry.js"; // Base DNS endpoint per env — the `pronode.prosopo.io` family is latency-routed @@ -25,11 +26,13 @@ import { retryWithBackoff } from "./retry.js"; // once to discover which specific pronodeN the DNS layer picked, then pin // subsequent captcha calls to that pronode so session creation and submission // land on the same backend. -const DNS_ENDPOINT: Record = { - development: "https://localhost:9229", +// Resolved per call rather than once at module load, so a development override +// set after this module is imported is still picked up. +const dnsEndpoints = (): Record => ({ + development: getDevelopmentProviderUrl(), staging: "https://staging.pronode.prosopo.io", production: "https://pronode.prosopo.io", -}; +}); // Apply the `ipv4.` / `ipv6.` DNS label to a hostname. The single-stack // sub-zones only resolve to A or AAAA records respectively, so this pins the @@ -93,8 +96,10 @@ const fetchPinnedHostWithRetry = (baseUrl: string): Promise => maxDelayMs: healthzRetryMaxDelayMs, }); -const resolveBaseUrl = (env: EnvironmentTypes): string => - DNS_ENDPOINT[env] ?? DNS_ENDPOINT.development; +const resolveBaseUrl = (env: EnvironmentTypes): string => { + const endpoints = dnsEndpoints(); + return endpoints[env] ?? endpoints.development; +}; const resolvePinnedUrl = async ( env: EnvironmentTypes, diff --git a/packages/load-balancer/src/tests/developmentProviderUrl.unit.test.ts b/packages/load-balancer/src/tests/developmentProviderUrl.unit.test.ts new file mode 100644 index 0000000000..7675b9fc89 --- /dev/null +++ b/packages/load-balancer/src/tests/developmentProviderUrl.unit.test.ts @@ -0,0 +1,76 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { afterEach, describe, expect, it } from "vitest"; +import { + DEFAULT_DEVELOPMENT_PROVIDER_URL, + getDevelopmentProviderUrl, + readDevelopmentProviderUrlOverride, +} from "../developmentProviderUrl.js"; + +const ENV_KEY = "PROSOPO_PROVIDER_URL_DEVELOPMENT"; +const original: string | undefined = process.env[ENV_KEY]; + +afterEach(() => { + if (original === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = original; +}); + +describe("the development provider url", () => { + it("is localhost when nothing overrides it", () => { + expect(getDevelopmentProviderUrl(undefined)).toBe( + "https://localhost:9229", + ); + expect(DEFAULT_DEVELOPMENT_PROVIDER_URL).toBe("https://localhost:9229"); + }); + + it("is the override when one is set", () => { + // The case this exists for: a phone loading the demo over the LAN cannot + // reach the provider via `localhost`, which resolves to the phone. + expect(getDevelopmentProviderUrl("https://192.168.1.15:9229")).toBe( + "https://192.168.1.15:9229", + ); + }); + + it("drops a trailing slash so the url composes with /healthz", () => { + expect(getDevelopmentProviderUrl("https://192.168.1.15:9229/")).toBe( + "https://192.168.1.15:9229", + ); + }); + + it("falls back rather than yielding an empty url", () => { + for (const blank of ["", " ", undefined]) { + expect(getDevelopmentProviderUrl(blank)).toBe( + DEFAULT_DEVELOPMENT_PROVIDER_URL, + ); + } + }); +}); + +describe("reading the override", () => { + it("reads the environment variable", () => { + process.env[ENV_KEY] = "https://10.0.2.2:9229"; + expect(readDevelopmentProviderUrlOverride()).toBe("https://10.0.2.2:9229"); + }); + + it("is undefined when the variable is unset", () => { + delete process.env[ENV_KEY]; + expect(readDevelopmentProviderUrlOverride()).toBeUndefined(); + }); + + it("defaults when the variable is unset", () => { + delete process.env[ENV_KEY]; + expect(getDevelopmentProviderUrl()).toBe(DEFAULT_DEVELOPMENT_PROVIDER_URL); + }); +}); From 4fec4c272ec7269069ded06809800677f7e88e67 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 10 Sep 2026 09:26:31 +0100 Subject: [PATCH 2/3] test(load-balancer): use documentation IPs in the override fixtures The fixtures used a real LAN address from the machine they were written on. RFC 5737 reserves 192.0.2.0/24 and 198.51.100.0/24 for exactly this, and they cannot resolve to anything real. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/developmentProviderUrl.unit.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/load-balancer/src/tests/developmentProviderUrl.unit.test.ts b/packages/load-balancer/src/tests/developmentProviderUrl.unit.test.ts index 7675b9fc89..7f81da2d2a 100644 --- a/packages/load-balancer/src/tests/developmentProviderUrl.unit.test.ts +++ b/packages/load-balancer/src/tests/developmentProviderUrl.unit.test.ts @@ -29,23 +29,21 @@ afterEach(() => { describe("the development provider url", () => { it("is localhost when nothing overrides it", () => { - expect(getDevelopmentProviderUrl(undefined)).toBe( - "https://localhost:9229", - ); + expect(getDevelopmentProviderUrl(undefined)).toBe("https://localhost:9229"); expect(DEFAULT_DEVELOPMENT_PROVIDER_URL).toBe("https://localhost:9229"); }); it("is the override when one is set", () => { // The case this exists for: a phone loading the demo over the LAN cannot // reach the provider via `localhost`, which resolves to the phone. - expect(getDevelopmentProviderUrl("https://192.168.1.15:9229")).toBe( - "https://192.168.1.15:9229", + expect(getDevelopmentProviderUrl("https://192.0.2.10:9229")).toBe( + "https://192.0.2.10:9229", ); }); it("drops a trailing slash so the url composes with /healthz", () => { - expect(getDevelopmentProviderUrl("https://192.168.1.15:9229/")).toBe( - "https://192.168.1.15:9229", + expect(getDevelopmentProviderUrl("https://192.0.2.10:9229/")).toBe( + "https://192.0.2.10:9229", ); }); @@ -60,8 +58,10 @@ describe("the development provider url", () => { describe("reading the override", () => { it("reads the environment variable", () => { - process.env[ENV_KEY] = "https://10.0.2.2:9229"; - expect(readDevelopmentProviderUrlOverride()).toBe("https://10.0.2.2:9229"); + process.env[ENV_KEY] = "https://198.51.100.7:9229"; + expect(readDevelopmentProviderUrlOverride()).toBe( + "https://198.51.100.7:9229", + ); }); it("is undefined when the variable is unset", () => { From b859de708d09221386ba06044cbdcdf008a47fc4 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 10 Sep 2026 09:36:59 +0100 Subject: [PATCH 3/3] lint-fix --- demos/android-webview/app/src/main/assets/procaptcha.html | 2 +- demos/ios-webview/procaptcha/procaptcha.html | 2 +- package-lock.json | 4 ++-- packages/load-balancer/src/developmentProviderUrl.ts | 4 +++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/demos/android-webview/app/src/main/assets/procaptcha.html b/demos/android-webview/app/src/main/assets/procaptcha.html index 4764f09f30..edd5036dbb 100644 --- a/demos/android-webview/app/src/main/assets/procaptcha.html +++ b/demos/android-webview/app/src/main/assets/procaptcha.html @@ -61,7 +61,7 @@
+ data-sitekey="5CQ3QeyY82R8h8VWFKTic3FMB3WsbSJExZgbaUqfVJtAFPnb">
diff --git a/demos/ios-webview/procaptcha/procaptcha.html b/demos/ios-webview/procaptcha/procaptcha.html index d2b1dc0bad..1e2358b4b8 100644 --- a/demos/ios-webview/procaptcha/procaptcha.html +++ b/demos/ios-webview/procaptcha/procaptcha.html @@ -8,7 +8,7 @@
-
+

diff --git a/package-lock.json b/package-lock.json index add67f57d3..e2514723f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@prosopo/captcha", - "version": "3.8.8", + "version": "3.8.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@prosopo/captcha", - "version": "3.8.8", + "version": "3.8.9", "license": "Apache-2.0", "workspaces": [ "dev/*", diff --git a/packages/load-balancer/src/developmentProviderUrl.ts b/packages/load-balancer/src/developmentProviderUrl.ts index 61b6ac98ac..bb7bcbc8bb 100644 --- a/packages/load-balancer/src/developmentProviderUrl.ts +++ b/packages/load-balancer/src/developmentProviderUrl.ts @@ -49,5 +49,7 @@ export const getDevelopmentProviderUrl = ( override: string | undefined = readDevelopmentProviderUrlOverride(), ): string => { const trimmed = override?.trim(); - return trimmed ? trimmed.replace(/\/$/, "") : DEFAULT_DEVELOPMENT_PROVIDER_URL; + return trimmed + ? trimmed.replace(/\/$/, "") + : DEFAULT_DEVELOPMENT_PROVIDER_URL; };